Modellazione e previsione dello spread bid-ask con il machine 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 e un prezzo ask . Lo spread quotato è:
Ogni round-trip (acquisto al bid del maker, vendita all'ask del maker, entrambi eseguiti dai taker) trasferisce fino a dai taker al maker — in teoria. Nella pratica, il maker guadagna meno di 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:
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 è:
Qui è la direzione dell'operazione (acquisto o vendita del taker), è il prezzo della transazione e è 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 :
dove è il mid a un orizzonte 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

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 ()
È 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 sulle proprie esecuzioni — e su molte piazze è un rebate, cioè negativo. Non paga la commissione taker su quelle esecuzioni passive; è la controparte che attraversa lo spread a pagare . Quindi il costo di elaborazione degli ordini per lato del maker è:
dove ha segno (un rebate abbassa e può renderlo negativo) e 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 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 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 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 ()
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:
dove è la volatilità dell'asset e è 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 ()
È 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:
dove è 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:
con , e 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

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 segua una passeggiata aleatoria:
Il prezzo osservato della transazione rimbalza tra bid e ask:
dove con uguale probabilità (cioè acquisti e vendite sono equiprobabili). La variazione di prezzo è:
Calcolando l'autocovarianza del primo ordine:
Il modello è derivato in unità di prezzo: 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 :
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:
dove è 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 — recupera dalle variazioni di prezzo. La variante basata sui rendimenti restituirebbe , 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

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 | Pressione direzionale | |
| Mid-price ponderato | Fair value di breve termine | |
| Rapporto di profondità (livelli 1-5) | Domanda/offerta multilivello | |
| Pressione del book | Pressione ponderata per la distanza | |
| Rapporto spread / tick | Strettezza rispetto al minimo |
La pressione del book qui usa un decadimento per distanza assoluta dal mid, , 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 (che è negativa sul lato bid, positiva sul lato ask, e diverge man mano che un livello si avvicina al mid). Scegli in base alla profondità tipica del book, oppure sostituisci l'esponenziale con qualsiasi peso positivo decrescente nella distanza.
Feature del flusso delle operazioni (selezione avversa)
| Feature | Formula | Approssima |
|---|---|---|
| Sbilanciamento delle operazioni | Flusso informato netto | |
| VPIN | Probabilità di trading informato sincronizzata sul volume | Tossicità |
| Lambda di Kyle | Regressione di sul volume con segno | Impatto sul prezzo per unità |
| Frequenza delle grandi operazioni | Conteggio delle operazioni nella finestra | Attività istituzionale |
Feature di volatilità (costo di inventario)
| Feature | Formula | Approssima |
|---|---|---|
| Volatilità realizzata | Rischio di breve termine | |
| Vol di Garman-Klass | Vol basata sul range | |
| Vol-of-vol | Std mobile di | Incertezza di regime |
| Autocorrelazione dei rendimenti | Momentum / mean-reversion |
Feature di regime di mercato
| Feature | Descrizione | Approssima |
|---|---|---|
| Codifica dell'ora del giorno | 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 secondi:
Nella pratica, lo approssimiamo con lo spread medio ponderato per il volume sui successivi snapshot:
Questo target è una finestra in avanti di lunghezza (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:
- Lo spread ritardato () è 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).
- La volatilità realizzata è la seconda più importante — volatilità infragiornaliera e spread sono fortemente correlati positivamente, sia in modo contemporaneo che dinamico.
- Lo sbilanciamento del book conta di più nei periodi volatili — segnala movimenti direzionali imminenti.
- Lo sbilanciamento del flusso delle operazioni cattura la selezione avversa di breve termine — un'esplosione di flusso unilaterale prevede un ampliamento dello spread.
- 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_samplesalto (100+) per impedire al modello di adattarsi al rumore di microstruttura nei singoli snapshot. - Usa
subsample < 1.0per 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 :
- = numero di passi temporali (es. 100 snapshot)
- = numero di livelli di prezzo (es. 10 bid + 10 ask = 20)
- = 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
-
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.
-
Funzione di perdita: Usa la perdita di Huber () per gestire i picchi dello spread:
-
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
horizonsnapshot 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. -
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

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 (), 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

Metriche di previsione dello spread
Oltre alle metriche di regressione standard (MAE, ), 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:
- A ogni timestamp, prevedi lo spread
- Calcola le quotazioni usando lo spread previsto + lo skew di inventario
- Simula le esecuzioni contro le operazioni storiche
- 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:
- Usa la scomposizione di Huang-Stoll offline per comprendere le tue componenti dello spread e calibrare i limiti di rischio
- Usa lo stimatore di Roll come controllo di coerenza e per gli strumenti per cui non disponi di dati del book degli ordini
- 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
- 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.
Autori
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.