← Torna agli articoli
June 3, 2026
5 min di lettura

Modellazione e previsione dello spread bid-ask con il machine learning

Modellazione e previsione dello spread bid-ask con il machine learning
#microstructure
#spread
#market-making
#prediction
#machine-learning
#gradient-boosting
#deep-learning

Lo spread bid-ask è la singola variabile più importante che un market maker controlla. Impostalo troppo ampio e perderai flusso a vantaggio dei concorrenti. Impostalo troppo stretto e la selezione avversa divorerà vivo il tuo inventario. La teoria classica della microstruttura ci fornisce eleganti scomposizioni dello spread nelle sue componenti economiche. Il machine learning ci dà gli strumenti per prevedere come quelle componenti si spostano in tempo reale. Questo articolo collega entrambi i mondi: partiamo dalla teoria classica, costruiamo lo stimatore dello spread implicito di Roll, poi passiamo ai modelli di gradient boosting e di deep learning che prevedono gli spread a partire dalle feature del book degli ordini. Lungo il percorso segnaliamo le trappole di unità di misura, leakage e benchmarking che invalidano silenziosamente i modelli di spread nella pratica.

Perché gli spread contano per i market maker

Un market maker quota continuamente un prezzo bid PbP_b e un prezzo ask PaP_a. Lo spread quotato è:

S=PaPbS = P_a - P_b

Ogni round-trip (acquisto al bid del maker, vendita all'ask del maker, entrambi eseguiti dai taker) trasferisce fino a SS dai taker al maker — in teoria. Nella pratica, il maker guadagna meno di SS a causa della selezione avversa: alcuni taker sono informati e operano subito prima che il prezzo si muova contro il maker. Il profitto realizzato per round-trip è lo spread realizzato, che è pari allo spread effettivo meno l'impatto sul prezzo:

Srealized=SeffectivePriceImpactS_{\text{realized}} = S_{\text{effective}} - \text{PriceImpact}

Misuriamo tutte e tre le grandezze sulla stessa base di spread pieno (non metà), così l'identità è dimensionalmente coerente. Lo spread effettivo per una singola operazione è:

Seffective=2dt(PtMt)S_{\text{effective}} = 2 \cdot d_t \cdot (P_t - M_t)

Qui dt{+1,1}d_t \in \{+1, -1\} è la direzione dell'operazione (acquisto o vendita del taker), PtP_t è il prezzo della transazione e MtM_t è il punto medio tra il miglior bid e il miglior ask al momento dell'operazione. Il termine di impatto sul prezzo è definito simmetricamente su un orizzonte post-operazione τ\tau:

PriceImpact=2dt(Mt+τMt)\text{PriceImpact} = 2 \cdot d_t \cdot (M_{t+\tau} - M_t)

dove Mt+τM_{t+\tau} è il mid a un orizzonte τ\tau dopo l'operazione. L'orizzonte deve essere indicato esplicitamente — scelte comuni sono 5 minuti nell'azionario e 30 secondi nelle cripto, dove i prezzi si riprezzano più rapidamente. Sottraendo l'impatto dallo spread effettivo rimane lo spread realizzato: ciò che il maker trattiene dopo che il mercato si è mosso.

Un market maker che è in grado di prevedere lo spread — e le sue componenti — nei successivi 1, 5 o 60 secondi può adeguare dinamicamente le quotazioni per massimizzare lo spread realizzato mantenendo i tassi di esecuzione.

Le tre componenti dello spread

Lo spread bid-ask scomposto negli strati di costo di elaborazione degli ordini, inventario e selezione avversa

La letteratura sulla microstruttura di mercato (Stoll 1978, Glosten e Milgrom 1985, Huang e Stoll 1997) scompone lo spread bid-ask in tre componenti economiche.

1. Costo di elaborazione degli ordini (α\alpha)

È il costo di fornire il servizio di market making per ciascun lato eseguito: la commissione che il maker paga effettivamente più l'infrastruttura tecnologica, la conformità normativa e il costo opportunità del capitale impiegato. Demsetz (1968) e Tinic (1972) sono stati i primi a formalizzare questa componente.

La distinzione fondamentale è chi paga quale commissione. Un maker che quota passivamente paga la commissione maker fmf_m sulle proprie esecuzioni — e su molte piazze fmf_m è un rebate, cioè negativo. Non paga la commissione taker ftf_t su quelle esecuzioni passive; è la controparte che attraversa lo spread a pagare ftf_t. Quindi il costo di elaborazione degli ordini per lato del maker è:

αfm+cinfra\alpha \approx f_m + c_{\text{infra}}

dove fmf_m ha segno (un rebate abbassa α\alpha e può renderlo negativo) e cinfrac_{\text{infra}} copre connettività, colocation e calcolo. Nei mercati elettronici moderni questa componente si è ridotta drasticamente — meno di un centesimo nell'azionario, pochi punti base o un rebate netto nelle cripto.

La commissione taker ftf_t conta per una ragione diversa: fissa un limite inferiore a quanto stretto possa essere lo spread pieno in modo profittevole, perché un taker che attraversa lo spread paga ftf_t in aggiunta allo spread. Se vuoi un limite inferiore allo spread che mantenga le tue quotazioni economicamente attraenti rispetto a quel costo del taker, motivalo separatamente invece di accorpare ftf_t nel costo proprio del maker. Confondere le due cose conta due volte una commissione di round-trip dentro un singolo mezzo-spread.

2. Costo di mantenimento dell'inventario (β\beta)

Quando un market maker accumula una posizione direzionale (lunga o corta), sopporta il rischio di prezzo. La componente di inventario compensa questo rischio. Stoll (1978) e Amihud e Mendelson (1980) l'hanno modellata come funzione della volatilità e dell'inventario corrente del maker:

βσQ\beta \propto \sigma \cdot |Q|

dove σ\sigma è la volatilità dell'asset e QQ è l'inventario corrente del maker. Man mano che l'inventario cresce, il maker amplia lo spread sul lato in cui è esposto e lo restringe sull'altro, una tecnica chiamata inventory skewing.

3. Costo di selezione avversa (γ\gamma)

È la componente più pericolosa. I trader informati — quelli con informazioni superiori su movimenti di prezzo imminenti — colpiscono sistematicamente le quotazioni obsolete. Il costo di selezione avversa è pari alla perdita attesa per operazione verso controparti informate. Copeland e Galai (1983) l'hanno modellato come il valore di un'opzione gratuita che il maker concede ai trader informati. Glosten e Milgrom (1985) l'hanno formalizzato come la revisione bayesiana delle convinzioni del maker dopo aver osservato un'operazione:

γ=E[Vtrade]Mt\gamma = E[V | \text{trade}] - M_t

dove VV è il vero valore fondamentale. Nei mercati liquidi, la selezione avversa può rappresentare il 30-60% dello spread totale.

La scomposizione completa

Il mezzo-spread quotato si può scrivere come:

S2=α+β+γ\frac{S}{2} = \alpha + \beta + \gamma

con α\alpha, β\beta e γ\gamma tutti espressi come costi per lato (mezzo-spread) — è questo che mantiene coerente la contabilità. Huang e Stoll (1997) hanno proposto un metodo econometrico per stimare queste componenti a partire dai dati di operazioni e quotazioni. L'intuizione chiave: i costi di elaborazione degli ordini creano un limite inferiore fisso allo spread, i costi di inventario creano uno spread che varia con la posizione e la volatilità, e i costi di selezione avversa creano uno spread che varia con l'asimmetria informativa.

Il modello dello spread implicito di Roll

Il modello dello spread implicito di Roll: prezzi delle transazioni che rimbalzano tra bid e ask, lasciando una firma di covarianza seriale negativa

Prima che i dati ad alta frequenza fossero ampiamente disponibili, Richard Roll (1984) propose un metodo elegante per stimare lo spread effettivo usando solo i prezzi delle transazioni. La sua intuizione: in un mercato efficiente, il rimbalzo bid-ask induce una covarianza seriale negativa nelle variazioni di prezzo, anche quando non c'è alcuna nuova informazione.

Il modello

Si assume che il valore fondamentale VtV_t segua una passeggiata aleatoria:

Vt=Vt1+ut,uti.i.d.(0,σu2)V_t = V_{t-1} + u_t, \quad u_t \sim \text{i.i.d.}(0, \sigma_u^2)

Il prezzo osservato della transazione rimbalza tra bid e ask:

Pt=Vt+S2dtP_t = V_t + \frac{S}{2} \cdot d_t

dove dt{1,+1}d_t \in \{-1, +1\} con uguale probabilità (cioè acquisti e vendite sono equiprobabili). La variazione di prezzo è:

ΔPt=ut+S2(dtdt1)\Delta P_t = u_t + \frac{S}{2}(d_t - d_{t-1})

Calcolando l'autocovarianza del primo ordine:

Cov(ΔPt,ΔPt1)=S24\text{Cov}(\Delta P_t, \Delta P_{t-1}) = -\frac{S^2}{4}

Il modello è derivato in unità di prezzo: SS emerge dall'autocovarianza delle variazioni di prezzo, non dei rendimenti. Questa distinzione è il singolo errore di implementazione più comune, e nel codice qui sotto la rispettiamo fedelmente.

Lo stimatore di Roll

Risolvendo per SS:

S^Roll=2Cov(ΔPt,ΔPt1)\hat{S}_{\text{Roll}} = 2\sqrt{-\text{Cov}(\Delta P_t, \Delta P_{t-1})}

Quando l'autocovarianza campionaria è positiva (cosa che accade spesso nella pratica a causa del rumore o del momentum), lo stimatore non è definito. Una correzione comune consiste nell'impostare la stima a zero o nell'usare la radice con segno:

S^Roll=2sign(γ^1)γ^1\hat{S}_{\text{Roll}}^{*} = 2 \cdot \text{sign}(-\hat{\gamma}_1) \cdot \sqrt{|\hat{\gamma}_1|}

dove γ^1\hat{\gamma}_1 è l'autocovarianza campionaria del primo ordine.

Implementazione in Python

Lo stimatore restituisce uno spread in unità di prezzo. Per esprimerlo in punti base lo dividiamo per il midprice una sola volta — perché, a differenza di uno stimatore nello spazio dei rendimenti, non è già stato diviso per il prezzo:

import numpy as np
import pandas as pd

def roll_spread(prices: pd.Series, window: int = 200) -> pd.Series:
    """
    Rolling Roll (1984) spread estimator, in PRICE units.

    The model is P_t = V_t + (S/2) d_t with Cov(ΔP_t, ΔP_{t-1}) = -S^2/4,
    so S is recovered from the autocovariance of price CHANGES (diff),
    not returns (pct_change). Using returns rescales the estimate by the
    price level and is wrong by roughly that factor.

    Parameters
    ----------
    prices : pd.Series
        Transaction prices.
    window : int
        Rolling window size (number of price changes).

    Returns
    -------
    pd.Series
        Estimated spread per window, in price units.
    """
    dprice = prices.diff().dropna()
    autocov = dprice.rolling(window).apply(
        lambda x: np.cov(x[:-1], x[1:])[0, 1], raw=True
    )
    return 2.0 * np.sqrt(np.maximum(-autocov, 0.0))


trades = pd.read_parquet("trades.parquet")
trades["roll_spread"] = roll_spread(trades["price"], window=200)

trades["quoted_spread"] = trades["ask"] - trades["bid"]
trades["midprice"] = 0.5 * (trades["ask"] + trades["bid"])
trades["quoted_spread_bps"] = trades["quoted_spread"] / trades["midprice"] * 1e4
trades["roll_spread_bps"] = trades["roll_spread"] / trades["midprice"] * 1e4

Un rapido controllo di coerenza su una serie simulata — una passeggiata aleatoria fondamentale vicino a un prezzo di 100 con uno spread reale di S=0.10S = 0.10 — recupera 0.0999\approx 0.0999 dalle variazioni di prezzo. La variante basata sui rendimenti restituirebbe 0.001\approx 0.001, sbagliata del fattore del livello di prezzo, e dividere poi quel valore di nuovo per il midprice per ottenere i bps amplifica l'errore. Se preferisci uno stimatore nello spazio dei rendimenti, deriva il modello nello spazio del log-prezzo ed elimina la seconda divisione per il midprice; scegli una convenzione e fai in modo che il codice corrisponda alla matematica.

Limiti del modello di Roll

Il modello di Roll assume: (1) efficienza del mercato, (2) assenza di asimmetria informativa, (3) direzione delle operazioni i.i.d. e (4) spread costante. Tutte queste ipotesi sono violate nella pratica. Harris (1990) ha mostrato che lo stimatore è gravemente distorto a causa della disuguaglianza di Jensen quando applicato a dati rumorosi. Nonostante questi limiti, lo stimatore di Roll rimane utile come baseline rapida ed è ampiamente usato nella ricerca empirica in finanza.

Feature di ML per la previsione dello spread

Feature di microstruttura ingegnerizzate — volatilità, sbilanciamento del flusso degli ordini, profondità, intensità delle operazioni — che alimentano un previsore di spread

Per andare oltre i modelli statici, ci servono feature che catturino i driver dinamici della variazione dello spread. Ecco una tassonomia di feature organizzata in base alla componente dello spread che approssimano.

Feature del book degli ordini (inventario e selezione avversa)

Feature Formula Approssima
Sbilanciamento del book BI=Vb1Va1Vb1+Va1\text{BI} = \frac{V_b^1 - V_a^1}{V_b^1 + V_a^1} Pressione direzionale
Mid-price ponderato Pw=PaVbVb+Va+PbVaVb+VaP_w = P_a \cdot \frac{V_b}{V_b + V_a} + P_b \cdot \frac{V_a}{V_b + V_a} Fair value di breve termine
Rapporto di profondità (livelli 1-5) DR5=i=15Vbii=15Vai\text{DR}_5 = \frac{\sum_{i=1}^{5} V_b^i}{\sum_{i=1}^{5} V_a^i} Domanda/offerta multilivello
Pressione del book BP=i=15Vbiwibi=15Vaiwia\text{BP} = \sum_{i=1}^{5} V_b^i\, w_i^b - \sum_{i=1}^{5} V_a^i\, w_i^a Pressione ponderata per la distanza
Rapporto spread / tick S/tickS / \text{tick} Strettezza rispetto al minimo

La pressione del book qui usa un decadimento per distanza assoluta dal mid, wi=eλPiMw_i = e^{-\lambda |P_i - M|}, così il volume che si trova vicino al touch conta di più del volume profondo, ed entrambi i lati sono ponderati da una funzione positiva e decrescente. Questo evita la distorsione di segno strutturale che si ha dividendo il volume per la distanza con segno PiMP_i - M (che è negativa sul lato bid, positiva sul lato ask, e diverge man mano che un livello si avvicina al mid). Scegli λ\lambda in base alla profondità tipica del book, oppure sostituisci l'esponenziale con qualsiasi peso positivo w(PiM)w(|P_i - M|) decrescente nella distanza.

Feature del flusso delle operazioni (selezione avversa)

Feature Formula Approssima
Sbilanciamento delle operazioni TIn=i=1ndivii=1nvi\text{TI}_{n} = \frac{\sum_{i=1}^{n} d_i \cdot v_i}{\sum_{i=1}^{n} v_i} Flusso informato netto
VPIN Probabilità di trading informato sincronizzata sul volume Tossicità
Lambda di Kyle Regressione di ΔM\Delta M sul volume con segno Impatto sul prezzo per unità
Frequenza delle grandi operazioni Conteggio delle operazioni >kmedian> k \cdot \text{median} nella finestra Attività istituzionale

Feature di volatilità (costo di inventario)

Feature Formula Approssima
Volatilità realizzata σrv=(ΔlogM)2\sigma_{\text{rv}} = \sqrt{\sum (\Delta \log M)^2} Rischio di breve termine
Vol di Garman-Klass 12(logH/L)2(2ln21)(logC/O)2\frac{1}{2}(\log H/L)^2 - (2\ln 2 - 1)(\log C/O)^2 Vol basata sul range
Vol-of-vol Std mobile di σrv\sigma_{\text{rv}} Incertezza di regime
Autocorrelazione dei rendimenti ρ1(ΔM)\rho_1(\Delta M) Momentum / mean-reversion

Feature di regime di mercato

Feature Descrizione Approssima
Codifica dell'ora del giorno sin(2πt/T),cos(2πt/T)\sin(2\pi t / T), \cos(2\pi t / T) Stagionalità infragiornaliera
Secondi dall'ultima operazione Intervallo temporale Livello di attività
Correlazione cross-asset Corr mobile con indice/BTC Rischio sistematico
Funding rate (cripto) Funding rate del perp Posizionamento a leva

Gradient boosting per la previsione dello spread

Gli alberi con gradient boosting (XGBoost, LightGBM, CatBoost) sono il cavallo di battaglia della previsione tabellare nella finanza quantitativa. Gestiscono tipi di feature misti, catturano interazioni non lineari, richiedono una preelaborazione minima e si addestrano velocemente su milioni di righe — a patto che la costruzione delle feature sia essa stessa vettorizzata (vedi la nota sull'autocorrelazione qui sotto).

Formulazione del problema

Inquadriamo la previsione dello spread come un compito di regressione. Il target è lo spread quotato medio ponderato per il tempo sui successivi τ\tau secondi:

yt=1τtt+τS(u)duy_t = \frac{1}{\tau} \int_{t}^{t+\tau} S(u) \, du

Nella pratica, lo approssimiamo con lo spread medio ponderato per il volume sui successivi NN snapshot:

yt=i=1NSt+iVt+ii=1NVt+iy_t = \frac{\sum_{i=1}^{N} S_{t+i} \cdot V_{t+i}}{\sum_{i=1}^{N} V_{t+i}}

Questo target è una finestra in avanti di lunghezza NN (o horizon), il che significa che righe adiacenti condividono finestre future sovrapposte. Quella sovrapposizione fa trapelare informazione attraverso una semplice suddivisione train/validation — la gestiamo esplicitamente nel codice di addestramento qui sotto.

Pipeline completa

import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error, r2_score

def build_features(df: pd.DataFrame) -> pd.DataFrame:
    """Build spread-prediction features from L2 order book snapshots."""
    f = pd.DataFrame(index=df.index)

    f["imb1"] = (df["bid_vol_1"] - df["ask_vol_1"]) / (
        df["bid_vol_1"] + df["ask_vol_1"] + 1e-9
    )

    bid_depth = df[[f"bid_vol_{i}" for i in range(1, 6)]].sum(axis=1)
    ask_depth = df[[f"ask_vol_{i}" for i in range(1, 6)]].sum(axis=1)
    f["depth_imb5"] = (bid_depth - ask_depth) / (bid_depth + ask_depth + 1e-9)

    mid = 0.5 * (df["ask_1"] + df["bid_1"])
    f["spread_bps"] = (df["ask_1"] - df["bid_1"]) / mid * 1e4
    f["log_spread"] = np.log1p(df["ask_1"] - df["bid_1"])

    log_ret = np.log(mid / mid.shift(1))
    f["rvol_50"] = log_ret.rolling(50).std()
    f["rvol_200"] = log_ret.rolling(200).std()

    if "trade_sign" in df.columns and "trade_vol" in df.columns:
        signed_vol = df["trade_sign"] * df["trade_vol"]
        total_vol = df["trade_vol"].rolling(50).sum()
        f["tfi_50"] = signed_vol.rolling(50).sum() / (total_vol + 1e-9)

    lag1 = log_ret.shift(1)
    f["ret_autocorr"] = log_ret.rolling(100).corr(lag1)

    if isinstance(df.index, pd.DatetimeIndex):
        seconds = df.index.hour * 3600 + df.index.minute * 60 + df.index.second
        f["tod_sin"] = np.sin(2 * np.pi * seconds / 86400)
        f["tod_cos"] = np.cos(2 * np.pi * seconds / 86400)

    for lag in [1, 5, 10, 50]:
        f[f"spread_lag_{lag}"] = f["spread_bps"].shift(lag)

    return f.dropna()


def build_target(df: pd.DataFrame, horizon: int = 10) -> pd.Series:
    """Forward mean spread over the next `horizon` snapshots (in bps).

    target[t] = mean(spread_bps[t+1 .. t+horizon]). Note that consecutive
    targets share an overlapping forward window of length `horizon`, which
    is why the CV below purges a gap of `horizon` rows around each fold.
    """
    mid = 0.5 * (df["ask_1"] + df["bid_1"])
    spread_bps = (df["ask_1"] - df["bid_1"]) / mid * 1e4
    fwd = spread_bps.shift(-1).rolling(horizon).mean().shift(-(horizon - 1))
    return fwd


def purged_walk_forward(n: int, n_splits: int, horizon: int):
    """Expanding-window splits with a purge/embargo gap of `horizon` rows.

    Because each target spans `horizon` future snapshots, rows straddling a
    train/val boundary share overlapping target windows. Dropping a gap of
    `horizon` rows between train and validation removes that leakage
    (Lopez de Prado-style purging). Without it, validation R²/MAE are
    optimistically biased by the target overlap.
    """
    fold_size = n // (n_splits + 1)
    for k in range(1, n_splits + 1):
        train_end = fold_size * k
        val_start = train_end + horizon       # embargo gap
        val_end = val_start + fold_size
        if val_end > n:
            break
        train_idx = np.arange(0, train_end - horizon)   # purge gap
        val_idx = np.arange(val_start, val_end)
        yield train_idx, val_idx


def train_spread_model(features: pd.DataFrame, target: pd.Series, horizon: int = 10):
    """Train LightGBM with purged, embargoed walk-forward validation."""
    common = features.index.intersection(target.dropna().index)
    X = features.loc[common].reset_index(drop=True)
    y = target.loc[common].reset_index(drop=True)

    models, scores = [], []
    params = {
        "objective": "mae",
        "learning_rate": 0.05,
        "num_leaves": 63,
        "min_child_samples": 100,
        "subsample": 0.8,
        "colsample_bytree": 0.8,
        "reg_alpha": 0.1,
        "reg_lambda": 1.0,
        "verbose": -1,
    }

    for fold, (train_idx, val_idx) in enumerate(
        purged_walk_forward(len(X), n_splits=5, horizon=horizon)
    ):
        X_tr, X_val = X.iloc[train_idx], X.iloc[val_idx]
        y_tr, y_val = y.iloc[train_idx], y.iloc[val_idx]

        ds_tr = lgb.Dataset(X_tr, y_tr)
        ds_val = lgb.Dataset(X_val, y_val, reference=ds_tr)

        model = lgb.train(
            params,
            ds_tr,
            num_boost_round=2000,
            valid_sets=[ds_val],
            callbacks=[lgb.early_stopping(50), lgb.log_evaluation(200)],
        )
        preds = model.predict(X_val)
        mae = mean_absolute_error(y_val, preds)
        r2 = r2_score(y_val, preds)
        print(f"Fold {fold}: MAE={mae:.4f} bps, R²={r2:.4f}")
        models.append(model)
        scores.append({"mae": mae, "r2": r2})

    return models[-1], scores

Il dettaglio cruciale è il gap di purge/embargo. Il target della media in avanti fa sì che righe consecutive si sovrappongano fino a horizon snapshot, perciò un semplice TimeSeriesSplit consente alle righe di validation di condividere finestre future con le righe di training — facendo trapelare la risposta e gonfiando l'R² di validation. Eliminare un gap di almeno horizon righe su entrambi i lati del confine di ciascun fold (purged k-fold in stile Lopez de Prado) rimuove quella distorsione. Questo vale tanto per la pipeline di gradient boosting quanto per il deep learning, anche se il leakage è più comunemente discusso per i modelli su sequenze.

Analisi dell'importanza delle feature

Uno dei vantaggi chiave dei modelli ad albero è l'interpretabilità. Dopo l'addestramento, ispeziona i valori SHAP per capire quali feature guidano le previsioni dello spread:

import shap

explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, max_display=15)

Risultati tipici tra le diverse classi di asset:

  1. Lo spread ritardato (spread_lag_1\text{spread\_lag\_1}) è quasi sempre la feature più importante — gli spread sono fortemente autocorrelati. È anche per questo che l'R² in primo piano appare alto: gran parte del punteggio è semplicemente persistenza, perciò fai sempre il benchmark rispetto a una baseline AR/EWMA (di più su questo qui sotto).
  2. La volatilità realizzata è la seconda più importante — volatilità infragiornaliera e spread sono fortemente correlati positivamente, sia in modo contemporaneo che dinamico.
  3. Lo sbilanciamento del book conta di più nei periodi volatili — segnala movimenti direzionali imminenti.
  4. Lo sbilanciamento del flusso delle operazioni cattura la selezione avversa di breve termine — un'esplosione di flusso unilaterale prevede un ampliamento dello spread.
  5. L'ora del giorno cattura lo schema infragiornaliero a forma di U (più ampio in apertura/chiusura, più stretto a metà giornata).

Considerazioni sugli iperparametri

Specificamente per la previsione dello spread:

  • Usa la MAE o la perdita di Huber invece della MSE. Le distribuzioni dello spread sono asimmetriche a destra con occasionali outlier estremi (durante eventi di news). La MAE è più robusta.
  • Imposta min_child_samples alto (100+) per impedire al modello di adattarsi al rumore di microstruttura nei singoli snapshot.
  • Usa subsample < 1.0 per decorrelare gli alberi e migliorare la generalizzazione tra diversi regimi di volatilità.

Approcci di deep learning

Mentre il gradient boosting eccelle sulle feature tabellari, il deep learning può apprendere rappresentazioni direttamente dai dati grezzi del book degli ordini. Due architetture si sono dimostrate efficaci per i compiti di previsione legati allo spread.

Architettura 1: CNN-LSTM per gli snapshot del book degli ordini

L'architettura DeepLOB (Zhang et al. 2019) usa convoluzioni impilate a kernel piccolo — e un modulo Inception — per estrarre pattern spaziali attraverso i livelli del book degli ordini preservando quella struttura spaziale, seguite da layer LSTM per modellare le dipendenze temporali. La scelta progettuale importante è non fare global pooling dell'asse dei livelli prima del layer ricorrente: farlo collassa esattamente la struttura cross-livello che le convoluzioni sono pensate per catturare.

Per la previsione dello spread, l'input è un tensore di forma (T,L,F)(T, L, F):

  • TT = numero di passi temporali (es. 100 snapshot)
  • LL = numero di livelli di prezzo (es. 10 bid + 10 ask = 20)
  • FF = feature per livello (prezzo, volume, conteggio degli ordini)

Il modello qui sotto mantiene la mappa di feature convoluzionale sui livelli e la appiattisce nell'input dell'LSTM (input_size = 16 * L), invece di mediare la dimensione dei livelli in 16 medie di canale:

import torch
import torch.nn as nn


class SpreadPredictor(nn.Module):
    """
    CNN-LSTM model for bid-ask spread prediction from L2 order book.

    Input: (batch, seq_len, n_levels, n_features)
    Output: (batch, 1) — predicted spread in bps
    """

    def __init__(
        self,
        n_levels: int = 20,
        n_features: int = 3,
        seq_len: int = 100,
        hidden_dim: int = 64,
        n_lstm_layers: int = 2,
        dropout: float = 0.2,
    ):
        super().__init__()
        self.seq_len = seq_len
        self.n_levels = n_levels

        self.conv = nn.Sequential(
            nn.Conv1d(n_features, 32, kernel_size=3, padding=1),
            nn.BatchNorm1d(32),
            nn.LeakyReLU(0.1),
            nn.Conv1d(32, 16, kernel_size=3, padding=1),
            nn.BatchNorm1d(16),
            nn.LeakyReLU(0.1),
        )
        conv_out_dim = 16 * n_levels  # flattened (channels × levels)

        self.lstm = nn.LSTM(
            input_size=conv_out_dim,
            hidden_size=hidden_dim,
            num_layers=n_lstm_layers,
            batch_first=True,
            dropout=dropout,
        )

        self.head = nn.Sequential(
            nn.Linear(hidden_dim, 32),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(32, 1),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Parameters
        ----------
        x : Tensor of shape (batch, seq_len, n_levels, n_features)

        Returns
        -------
        Tensor of shape (batch, 1) — predicted spread
        """
        batch, T, L, F = x.shape

        x = x.reshape(batch * T, L, F).permute(0, 2, 1)
        x = self.conv(x)                  # (batch * T, 16, L)
        x = x.reshape(batch, T, 16 * L)   # (batch, T, 16 * L) — keep levels

        lstm_out, _ = self.lstm(x)        # (batch, T, hidden_dim)
        last_hidden = lstm_out[:, -1, :]  # (batch, hidden_dim)

        return self.head(last_hidden)     # (batch, 1)

Se vuoi davvero del pooling sull'asse dei livelli per controllare il numero di parametri, usa un pooling con stride o appreso che mantenga più di una singola posizione — non AdaptiveAvgPool1d(1), che media ogni livello in un solo numero e butta via il segnale spaziale.

Architettura 2: Encoder Transformer

I Transformer possono catturare dipendenze a lungo raggio nelle sequenze del book degli ordini senza il collo di bottiglia sequenziale degli LSTM. Per la previsione dello spread, un encoder transformer leggero funziona bene:

class TransformerSpreadPredictor(nn.Module):
    """Transformer encoder for spread prediction from order book sequences."""

    def __init__(
        self,
        input_dim: int = 40,   # 20 levels * 2 features (price_offset, volume)
        d_model: int = 64,
        nhead: int = 4,
        n_layers: int = 3,
        seq_len: int = 100,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.input_proj = nn.Linear(input_dim, d_model)
        self.pos_encoding = nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02)

        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=nhead,
            dim_feedforward=d_model * 4,
            dropout=dropout,
            batch_first=True,
            activation="gelu",
        )
        self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)
        self.head = nn.Sequential(
            nn.LayerNorm(d_model),
            nn.Linear(d_model, 1),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        x: (batch, seq_len, input_dim) — flattened order book snapshots
        """
        x = self.input_proj(x) + self.pos_encoding[:, : x.size(1), :]
        x = self.encoder(x)
        return self.head(x[:, -1, :])

Considerazioni sull'addestramento

  1. Normalizzazione: Normalizza i prezzi come offset dal midprice (in tick o bps). Normalizza i volumi per la loro media mobile. Prezzi e volumi grezzi causano instabilità dell'addestramento.

  2. Funzione di perdita: Usa la perdita di Huber (δ=1.0\delta = 1.0) per gestire i picchi dello spread:

Lδ(y,y^)={12(yy^)2if yy^δδyy^12δ2otherwiseL_\delta(y, \hat{y}) = \begin{cases} \frac{1}{2}(y - \hat{y})^2 & \text{if } |y - \hat{y}| \le \delta \\ \delta |y - \hat{y}| - \frac{1}{2}\delta^2 & \text{otherwise} \end{cases}

  1. Campionamento delle finestre e leakage: Usa finestre non sovrapposte per l'addestramento e — esattamente come nella pipeline di gradient boosting — applica purge/embargo a un gap di almeno horizon snapshot tra train e validation. Sia il target della media in avanti sia le finestre di input sovrapposte fanno trapelare informazione futura attraverso i confini di suddivisione e gonfiano la performance apparente.

  2. Adattamento online: In produzione, fai periodicamente fine-tuning del modello su dati recenti (ultime 1-2 ore) con un learning rate piccolo. La microstruttura di mercato cambia durante la giornata, e un modello addestrato sui dati del mattino può performare peggio nel pomeriggio.

Quando usare il deep learning rispetto al gradient boosting

Criterio Gradient Boosting Deep Learning
Tipo di input Feature tabellari Sequenze grezze del book degli ordini
Dimensione dei dati di addestramento Funziona con 100K+ righe Richiede 1M+ righe
Feature engineering Manuale (alto sforzo, alto controllo) Appreso (sforzo inferiore, meno interpretabile)
Latenza di inferenza µs a una cifra con un predittore compilato; decine di µs da Python Centinaia di µs su GPU
Interpretabilità Alta (SHAP) Bassa (mappe di attenzione)
Adattamento di regime Riaddestramento / aggiornamento online Fine-tuning su dati recenti
Abilità sullo spread a breve orizzonte Ampiamente paragonabile al DL Il vantaggio cresce su orizzonti più lunghi / dati più grandi

Evitiamo deliberatamente di citare cifre di R² specifiche: l'accuratezza della previsione dello spread dipende fortemente dall'orizzonte, dall'asset e da quanta parte del punteggio sia semplicemente autocorrelazione dello spread. Un modello può registrare un R² grezzo impressionante pur aggiungendo quasi nulla rispetto a un EWMA di una sola riga. Riporta l'abilità sopra una baseline AR/EWMA sugli stessi dati, con l'orizzonte indicato, invece di un R² in primo piano. Allo stesso modo, tratta i numeri di latenza come dipendenti dall'implementazione: un modello LightGBM a 2000 round e 63 foglie prevede una singola riga in decine di microsecondi da Python e raggiunge pochi microsecondi solo con un predittore compilato/C++.

Nella pratica, molti sistemi di produzione usano un approccio a due stadi: un veloce modello di gradient boosting per il quoting in tempo reale (critico per la latenza) e un modello di deep learning eseguito in modo asincrono per regolare i parametri del modello di boosting o fornire un segnale secondario.

Dalla previsione al quoting

Trasformare uno spread previsto in quotazioni bid/ask live, distorte dall'inventario attorno a un mid-price equo

Una previsione dello spread ha valore solo se si traduce in quotazioni migliori. Ecco una regola di quoting semplificata che usa lo spread previsto:

def compute_quotes(
    mid: float,
    predicted_spread_bps: float,
    inventory: float,
    max_inventory: float,
    skew_factor: float = 0.5,
    min_spread_bps: float = 1.0,
) -> tuple[float, float]:
    """
    Compute bid/ask quotes from predicted spread and inventory.

    Parameters
    ----------
    mid : float
        Current midprice.
    predicted_spread_bps : float
        Model-predicted spread in basis points.
    inventory : float
        Current inventory (positive = long).
    max_inventory : float
        Maximum allowed inventory.
    skew_factor : float
        How aggressively to skew quotes toward inventory neutrality.
    min_spread_bps : float
        Minimum spread floor (covers order processing costs).

    Returns
    -------
    (bid, ask) : tuple[float, float]
    """
    spread_bps = max(predicted_spread_bps, min_spread_bps)
    half_spread = mid * spread_bps / 2e4

    inv_ratio = inventory / max_inventory  # in [-1, 1]
    skew = skew_factor * inv_ratio * half_spread

    bid = mid - half_spread - skew
    ask = mid + half_spread - skew

    return bid, ask

Quando l'inventario è lungo (Q>0Q > 0), lo skew abbassa sia il bid sia l'ask. Da una prospettiva singola e coerente: un ask più basso rende più conveniente per i taker comprare da noi, il che scarica il nostro inventario lungo; un bid più basso ci rende meno probabili di essere colpiti dai venditori, rallentando un ulteriore accumulo. Lo spread previsto controlla l'ampiezza complessiva — allargandosi quando il modello si aspetta volatilità o selezione avversa, restringendosi quando le condizioni sono calme.

Valutazione e backtesting

Valutare un modello di spread: calibrazione spread previsto-vs-realizzato accanto a un indicatore di tasso di esecuzione e PnL

Metriche di previsione dello spread

Oltre alle metriche di regressione standard (MAE, R2R^2), valuta le previsioni dello spread con metriche che contano per il market making:

  • Abilità rispetto a una baseline: Riporta sempre MAE/R² relativi a una previsione AR(1) o EWMA degli spread recenti. Poiché gli spread sono fortemente persistenti, il punteggio assoluto è dominato dall'autocorrelazione; solo il miglioramento rispetto a una baseline banale riflette un reale contenuto predittivo.
  • Accuratezza direzionale: Il modello prevede correttamente se lo spread si allargherà o si restringerà? Un modello con MAE mediocre ma alta accuratezza direzionale può comunque essere profittevole.
  • Copertura delle code: Il modello prevede i picchi dello spread? Calcola la MAE separatamente per il 5% superiore dei valori dello spread — è qui che si concentrano le perdite da selezione avversa.
  • Calibrazione: Traccia i quantili dello spread previsto rispetto a quello realizzato. La previsione del 90° percentile di un modello ben calibrato dovrebbe corrispondere al 90° percentile degli spread realizzati.

Valutazione basata sul PnL

In definitiva, l'unica metrica che conta è il PnL realizzato. Fai il backtest dell'intero ciclo:

  1. A ogni timestamp, prevedi lo spread
  2. Calcola le quotazioni usando lo spread previsto + lo skew di inventario
  3. Simula le esecuzioni contro le operazioni storiche
  4. Traccia inventario, PnL realizzato e Sharpe ratio

Confronta rispetto alle baseline: (a) spread costante (la mediana della serie temporale), (b) EWMA degli spread recenti e (c) lo stimatore di Roll.

Conclusione

La modellazione dello spread si colloca all'intersezione tra teoria finanziaria e ML applicato. La scomposizione classica in costi di elaborazione degli ordini, inventario e selezione avversa fornisce l'intuizione economica del perché gli spread variano. Il modello di Roll fornisce un'elegante baseline di stima a partire da dati minimi — purché tu lo calcoli in unità di prezzo. I modelli di gradient boosting trasformano le feature di microstruttura in previsioni accurate dello spread a breve orizzonte con inferenza a bassa latenza. Le architetture di deep learning apprendono direttamente dai dati grezzi del book degli ordini, catturando pattern che le feature costruite a mano potrebbero perdere — purché l'architettura preservi la struttura cross-livello invece di farne il pooling.

Per un sistema di market making di produzione, la raccomandazione pratica è stratificata:

  1. Usa la scomposizione di Huang-Stoll offline per comprendere le tue componenti dello spread e calibrare i limiti di rischio
  2. Usa lo stimatore di Roll come controllo di coerenza e per gli strumenti per cui non disponi di dati del book degli ordini
  3. Dispiega un modello LightGBM per la previsione dello spread in tempo reale — è veloce, interpretabile e robusto — con validazione walk-forward purged e un benchmark AR/EWMA
  4. Esegui un modello CNN-LSTM o Transformer in un ciclo secondario per rilevare i cambiamenti di regime e regolare il modello primario

Lo spread non è un numero — è un segnale. Meglio lo modelli (e più onestamente misuri quel modello), più precisamente puoi prezzare la fornitura di liquidità.


Questo articolo fa parte della serie di marketmaker.cc sul market making algoritmico e la microstruttura.

Disclaimer: le informazioni fornite in questo articolo hanno solo scopo didattico e informativo e non costituiscono consulenza finanziaria, di investimento o di trading. Il trading di criptovalute comporta un rischio significativo di perdita.

Autori

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Resta un Passo Avanti al Mercato

Iscriviti alla nostra newsletter per approfondimenti esclusivi sul trading con IA, analisi di mercato e aggiornamenti sulla piattaforma.

Rispettiamo la tua privacy. Annulla l'iscrizione in qualsiasi momento.