Lesson 4.3.2 · 65 min
Order flow imbalance, microstructure signals and latency
Over intervals of seconds, the mid price moves mainly because of the imbalance between supply and demand at the top of the book. This lesson calculates Cont, Kukanov and Stoikov's order flow imbalance event by event, recovers the slope planted in two synthetic stocks and its relationship with depth, and measures with six connection profiles, from a home in Spain to an FPGA, how much of that information arrives in time.
- Module
- 4.3 · Microstructure and execution
- Duration
- 65 min
- Prerequisites
- 1.1.3 · Order book: bid, ask, spread and depth, 1.1.4 · Order types, 1.1.5 · Costs, settlement, leverage and margin, 2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
What moves the mid price in seconds
Lesson 1.1.3 taught how to read the order book, and lesson 4.3.1 measured how much a large order split into many child orders over hours moves the price. This lesson goes down to the scale of seconds and answers a more basic question: what makes the mid price go up or down a tick. In a book with a one-tick bid-ask spread, the mid only moves when one of the two top-of-book queues is depleted (or when someone improves the price inside the spread). And a queue is depleted by three types of event:
Mermaid source
flowchart TB
L[New limit order] --> C{Which side?}
M[Market order] --> C
X[Cancellation] --> C
C -- bid grows --> U[Upward pressure]
C -- ask shrinks --> U
C -- ask grows --> D[Downward pressure]
C -- bid shrinks --> DA market buy and the cancellation of a sell limit order do the same thing to the book: they shrink the ask queue. A new buy limit order at the best price offsets it. Cont, Kukanov and Stoikov (CKS) turned that observation into a single variable, the order flow imbalance (OFI), and showed with TAQ data for 50 NYSE stocks in April 2010 that, over 10-second intervals, the mid change is linear in the OFI, with a slope inversely proportional to depth and an average R² of 65%1Note 1 · R. Cont, A. Kukanov and S. Stoikov, “The Price Impact of Order Book Events”, Journal of Financial Econometrics 12(1), 2014, pp. 47-88, doi:10.1093/jjfinec/nbt003; version on arXiv:1011.6402 (definition of the OFI, 10 s intervals, average R² of 65% for the OFI against 32% for the trade imbalance, β inversely proportional to depth), accessed on October 22, 2026..
The data in this lesson is the best bid and ask of two fictitious stocks, ARVA and BELO, on September 16, 2025
from 10:00 to 11:30 New York time, one row per top-of-book event. It comes from the stylized CKS model: limit
orders, cancellations and market orders that arrive as Poisson processes to a one-level book with a one-tick spread.
It is synthetic, and the true slope is published in flujo4_verdad.csv: the estimate can be checked against it.
import numpy as np
import pandas as pd
cot = pd.read_parquet("flujo4_cotizaciones.parquet")
print(cot.groupby("ticker").size().to_dict())
cot[cot["ticker"] == "BELO"].head(4){'ARVA': 55599, 'BELO': 66195}
ts ticker bid ask tam_bid tam_ask
55599 2025-09-16 14:00:00+00:00 BELO 61.99 62.0 600 600
55600 2025-09-16 14:00:00.002613+00:00 BELO 61.99 62.0 700 600
55601 2025-09-16 14:00:00.012461+00:00 BELO 61.99 62.0 600 600
55602 2025-09-16 14:00:00.112666+00:00 BELO 61.99 62.0 600 900Cont, Kukanov and Stoikov's OFI
For each book event n (a change in price or size at the best bid or the best ask), CKS define its contribution to the imbalance by comparing the new state with the previous one:
with and the best bid and ask prices and , their sizes. If prices do not change, the two indicators on each side are 1 and what remains is : how much demand grows minus how much supply grows. If the bid rises a tick (the sell queue was depleted and someone improves the buy side), the whole new bid queue counts; if it falls, the whole queue that disappeared counts negatively. The OFI of an interval is the sum of the of the events that fall in it.
def ofi_eventos(g):
"""Contribution of each top-of-book event (CKS 2014)."""
b, a = g["bid"], g["ask"]
qb, qa = g["tam_bid"], g["tam_ask"]
b0, a0 = b.shift(), a.shift()
qb0, qa0 = qb.shift(), qa.shift()
e = ((b >= b0) * qb - (b <= b0) * qb0
- (a <= a0) * qa + (a >= a0) * qa0)
return e.fillna(0).astype("int64")
cot["e"] = cot.groupby("ticker", group_keys=False).apply(ofi_eventos)
cot["medio"] = (cot["bid"] + cot["ask"]) / 2
cot["prof"] = (cot["tam_bid"] + cot["tam_ask"]) / 2
cot.loc[cot["ticker"] == "BELO",
["bid", "ask", "tam_bid", "tam_ask", "e"]].head(6) bid ask tam_bid tam_ask e
55599 61.99 62.0 600 600 0
55600 61.99 62.0 700 600 100
55601 61.99 62.0 600 600 -100
55602 61.99 62.0 600 900 -300
55603 61.99 62.0 600 800 100
55604 61.99 62.0 500 800 -100The first row of each stock has no previous event and is zero. In the following ones the rule can be read: BELO's bid
goes from 600 to 700 shares (+100), back to 600 (−100), the ask grows from 600 to 900 (−300: more supply) and then
shrinks to 800 (+100). The calculation uses only the current event and the previous one (shift), so it does not look
at the future. Grouping by ticker matters: without it, BELO's first event would be compared with ARVA's last one.
The regression: mid change against OFI
CKS aggregate over 10-second intervals and fit , with the change in the mid price between the end of the previous interval and the end of this one. The data generator did the same (regression without a constant) with a simulation of the same model eight times longer: it is the reference β, the population value the 90-minute session should recover. CKS's theoretical approximation for a one-level book is , with the average top-of-book depth: it takes consuming roughly a whole queue to move the mid by half a tick.
def intervalos(g, freq="10s"):
k = g.set_index("ts")
ofi = k["e"].resample(freq).sum()
medio = k["medio"].resample(freq).last().ffill()
dp = medio.diff()
dp.iloc[0] = medio.iloc[0] - k["medio"].iloc[0]
prof = k["prof"].resample(freq).mean()
return pd.DataFrame({"ofi": ofi, "dp": dp, "prof": prof})
def ajuste(x, y):
"""Regression without a constant: slope and R² (uncentered)."""
beta = (x @ y) / (x @ x)
r2 = 1 - ((y - beta * x) ** 2).sum() / (y ** 2).sum()
return beta, r2
iv = {tk: intervalos(g) for tk, g in cot.groupby("ticker")}
ver = pd.read_csv("flujo4_verdad.csv").set_index("ticker")
filas = []
for tk, d in iv.items():
beta, r2 = ajuste(d["ofi"].to_numpy(), d["dp"].to_numpy())
ad = cot.loc[cot["ticker"] == tk, "prof"].mean()
filas.append({"ticker": tk, "intervalos": len(d),
"beta_e6": beta * 1e6, "r2": r2,
"cks_e6": 0.01 / (2 * ad) * 1e6,
"ref_e6": ver.loc[tk, "beta_referencia"] * 1e6,
"r2_ref": ver.loc[tk, "r2_referencia"]})
res = pd.DataFrame(filas).set_index("ticker")
res.round(2) intervalos beta_e6 r2 cks_e6 ref_e6 r2_ref
ticker
ARVA 540 2.38 0.67 2.72 2.44 0.68
BELO 540 4.26 0.68 4.40 4.36 0.67With 540 intervals per stock, the session recovers the slope with an error of 2-3%: 2.38 against 2.44 in ARVA and 4.26 against 4.36 in BELO (in millionths of a dollar per share of OFI). The R² is the paper's: two thirds of the variance of the mid change is explained by a single variable with no constant. The theoretical approximation, 2.72 and 4.40, gets the order of magnitude and the ratio between the two stocks right, though not the exact figure: it is an approximation from a stylized model, and in the paper the constant and the exponent of the relationship between β and depth are estimated from the data, not fixed.
The figure at the top plots BELO's intervals with the fitted line. The reading in trading units: with , an OFI of +2,350 shares raises the mid one cent, one tick. In ARVA, with an average queue of 1,836 shares per side against BELO's 1,136, it takes about 4,200.
The slope depends on depth
The relationship is what gives the regression its economic content: in a deep book, the same imbalance moves the price less. CKS test it by estimating β in each half hour and regressing it on the depth of that half hour. A 90-minute session has few half hours, so the cell uses 10-minute windows (nine per stock, 60 intervals of 10 s each):
ventanas = []
for tk, d in iv.items():
for inicio, w in d.groupby(pd.Grouper(freq="10min")):
b, _ = ajuste(w["ofi"].to_numpy(), w["dp"].to_numpy())
ventanas.append({"ticker": tk, "inicio": inicio,
"beta_e6": b * 1e6, "prof": w["prof"].mean()})
vent = pd.DataFrame(ventanas)
x = 1 / vent["prof"].to_numpy()
y = vent["beta_e6"].to_numpy() * 1e-6
c = (x @ y) / (x @ x) # β = c / depth
print(f"c = β·depth = {c:.5f} USD (half tick: 0.005)")
print(f"correlation β - 1/depth: {np.corrcoef(x, y)[0, 1]:.2f}")
vent.groupby("ticker")[["beta_e6", "prof"]].agg(["min", "max"]).round(2)c = β·depth = 0.00455 USD (half tick: 0.005)
correlation β - 1/depth: 0.95
beta_e6 prof
min max min max
ticker
ARVA 2.04 2.83 1699.03 1857.01
BELO 3.52 4.81 1054.51 1143.34The constant comes out at 0.0046 USD, 9% away from the half tick that the CKS approximation predicts. Almost all of the correlation between β and 1/depth comes from the difference between the two stocks (BELO has about three fifths of ARVA's depth and almost double the slope); within each stock, depth barely changes from one window to another (from 1,699 to 1,857 shares in ARVA) and the β estimated on 60 intervals varies more from noise than from depth:
For a system, the practical consequence is that impact per share is not a constant of the asset: it rises when the book thins out (opens, news, low-volume days) and an execution that uses an average β underestimates its cost just when it matters most. It is the same argument lesson 4.3.1 makes with the square-root law on a scale of days.
Contemporaneous is not predictive
An R² of 0.67 invites thinking of a signal. It is not one in the way it has been calculated: the OFI of interval k explains the mid change of the same interval. The cell compares four things: the OFI against the trade imbalance (aggressor buys minus aggressor sells, the variable seen by whoever only has the trade tape), different interval sizes and the OFI of one interval against the change in the next.
ops = pd.read_parquet("flujo4_operaciones.parquet")
signo = np.where(ops["lado_agresor"] == "compra", 1, -1)
ops["firmado"] = signo * ops["tamano"]
filas = []
for tk, g in cot.groupby("ticker"):
d = iv[tk]
ti = (ops[ops["ticker"] == tk].set_index("ts")["firmado"]
.resample("10s").sum().reindex(d.index, fill_value=0))
sig = d["ofi"].shift(1).fillna(0)
dp = d["dp"].to_numpy()
fila = {"ticker": tk,
"r2_ofi": ajuste(d["ofi"].to_numpy(), dp)[1],
"r2_trades": ajuste(ti.to_numpy(), dp)[1],
"r2_siguiente": ajuste(sig.to_numpy(), dp)[1]}
for f in ["1s", "60s"]:
d2 = intervalos(g, f)
fila["r2_" + f] = ajuste(d2["ofi"].to_numpy(),
d2["dp"].to_numpy())[1]
filas.append(fila)
pd.DataFrame(filas).set_index("ticker").round(3) r2_ofi r2_trades r2_siguiente r2_1s r2_60s
ticker
ARVA 0.670 0.373 0.003 0.329 0.818
BELO 0.676 0.418 0.001 0.296 0.769Three results:
- The OFI explains more than trades: 0.67 against 0.37 in ARVA and 0.68 against 0.42 in BELO. Trades only see part of what moves the book; cancellations and new limit orders leave no trace on the tape. CKS find the same in real data: 65% against 32%.
- Scale matters: at 1 second the R² falls to 0.33 and 0.30 (the mid changes in half-tick jumps and many intervals have no change even though the OFI is not zero); at 60 seconds it rises to 0.82 and 0.77. More aggregation, less discretization noise.
- The OFI of one interval says nothing about the next: 0.003 and 0.001. In the model, as in a market that is efficient at this scale, the imbalance has already turned into price by the time it has been measured.
Mermaid source
flowchart LR E[Book events] --> O[Interval OFI] O --> P[Mid change] O -. almost nothing .-> S[Next interval]
What does anticipate the next move at this scale is the state of the book, not the flow: lesson 1.1.3 showed that the imbalance between the two queues, , and the microprice indicate where the mid is more likely to move, because the thinner queue is the one that gets depleted first. Gould and Bonart document it with Nasdaq data as a predictor of the next tick2Note 2 · M. D. Gould and J. Bonart, “Queue Imbalance as a One-Tick-Ahead Price Predictor in a Limit Order Book”, arXiv:1512.03492, 2015, accessed on October 22, 2026.. Whether that information is useful or not depends on how long the order takes to arrive: that is the question for the rest of the lesson.
Latency: from a home in Spain to an FPGA
Round-trip latency (RTT) is the time between sending a message and receiving the response. For an order, what counts
is something similar: the data leaves the market, reaches the system, the system decides and the order goes back to
the market. The file latencia4_rtt.parquet has a ping every 2 seconds for 100 minutes for six profiles, with a
planted median, dispersion, spikes (congestion, Wi-Fi, garbage collection) and losses:
lat = pd.read_parquet("latencia4_rtt.parquet")
orden = list(dict.fromkeys(lat["perfil"]))
resumen = (lat.groupby("perfil", sort=False)["rtt_us"]
.describe(percentiles=[0.5, 0.99, 0.999])
[["50%", "99%", "99.9%", "max"]])
resumen["perdidos"] = lat.groupby("perfil", sort=False)["perdido"].sum()
resumen.round(1) 50% 99% 99.9% max perdidos
perfil
retail_espana_nueva_york 88497.8 126363.4 506389.5 729917.2 11
retail_espana_frankfurt 31077.6 45943.1 184613.9 249154.2 4
vps_nueva_jersey 1206.5 2440.7 9636.6 38463.5 0
nube_misma_region 652.3 1237.4 4984.1 16316.9 0
colocado 27.8 44.3 53.9 69.1 0
fpga 1.6 1.8 1.9 4.5 0Five orders of magnitude separate the first profile from the last. The figure uses a log scale: the median is the square and the line reaches the 99.9th percentile. Three things that an average does not show:
- The tail is long in every profile with a network in between. From home, the p99.9 to New York is more than five times the median and the maximum, 730 ms. A system with a 200 ms timeout would see, several times per session, an order “with no response” that actually arrived: it is the origin of the duplicate orders dealt with in lessons 4.5.2 and 4.5.5.
- There are losses: 11 of 3,000 pings from home to New York (0.4%). A protocol like TCP retries them and they turn into spikes; one that does not retry, into lost messages.
- Distance is a floor. The next cell calculates it.
c_vacio = 299_792.458 # km/s
n_fibra = 1.47 # typical refractive index of glass
v = c_vacio / n_fibra
for tramo, km in [("Madrid - New York", 5800),
("Madrid - Frankfurt", 1450),
("New Jersey (same center)", 50)]:
print(f"{tramo:<28s} {2 * km / v * 1000:7.2f} ms round trip")Madrid - New York 56.88 ms round trip
Madrid - Frankfurt 14.22 ms round trip
New Jersey (same center) 0.49 ms round tripFrom home, 57 of the 88 ms median to New York are light in glass; the rest is equipment, routes and queues. Paying for better fiber can shave off a few milliseconds; removing the 57 requires moving the machine. That is why the following profiles are not “faster” but “closer”: a VPS in New Jersey (1.2 ms), a machine in the same cloud region as the broker's gateway (0.65 ms), a server in the market's data center (28 µs) and logic on an FPGA next to the market (1.6 µs), where there is no longer an operating system in between.
What latency costs
Latency costs in two ways. The first: the price moves while the order travels, and the order executes at a price different from the one that was seen. The second: the rest of the book (sizes, queue position) has changed, and a strategy that depends on that information decides on a book that no longer exists. The cell measures both with the quote events: for 20,000 random instants in the session, has the mid changed, and has anything at the top of the book changed, in the following RTT microseconds?
rng = np.random.default_rng(432)
medianas = resumen["50%"]
filas = []
for tk, g in cot.groupby("ticker"):
t = g["ts"].astype("int64").to_numpy() # µs
m = g["medio"].to_numpy()
t_medio = t[1:][m[1:] != m[:-1]] # mid changes
u = np.sort(rng.integers(t[0], t[-1] - 1_000_000, 20_000))
for perfil, rtt in medianas.items():
fin = u + int(rtt)
cambia_m = (np.searchsorted(t_medio, fin, "right")
> np.searchsorted(t_medio, u, "right"))
cambia_l = (np.searchsorted(t, fin, "right")
> np.searchsorted(t, u, "right"))
filas.append({"ticker": tk, "perfil": perfil,
"medio_cambia": cambia_m.mean(),
"libro_cambia": cambia_l.mean()})
viejo = pd.DataFrame(filas).pivot(index="perfil", columns="ticker")
viejo.loc[orden].round(4) medio_cambia libro_cambia
ticker ARVA BELO ARVA BELO
perfil
retail_espana_nueva_york 0.0134 0.0250 0.5941 0.6526
retail_espana_frankfurt 0.0046 0.0097 0.2750 0.3138
vps_nueva_jersey 0.0003 0.0004 0.0131 0.0138
nube_misma_region 0.0001 0.0004 0.0072 0.0070
colocado 0.0000 0.0000 0.0004 0.0003
fpga 0.0000 0.0000 0.0000 0.0000In ARVA, with the median from home to New York, the mid price has changed in 1.3% of the cases when the order arrives, and in BELO, which changes its mid twice as often per minute, in 2.5%. But the top of the book (price or size) has changed in 59% and 65%: there is almost always a different queue. From a VPS in New Jersey the two figures fall below 0.05% and 1.4%; from a colocated server, to zero in practice.
Translated into cost: if the mid changes in 1.3% of the sends and when it changes it moves half a tick in either direction, the expected price shift is of the order of 0.013 × 0.005 ≈ 0.00007 USD per share, less than a hundredth of a basis point on a 180 USD stock, against the half spread of 0.27 basis points that any market order pays. For a strategy with a horizon of minutes or days, a retail trader's latency is not its cost; the spread, impact (lesson 4.3.1) and commissions (lesson 2.4.7) are.
The calculation changes when there is competition for the same information. The queue imbalance of lesson 1.1.3 is public: anyone with the top of the book can see it. In the model that generates this data nobody acts on it, so the next mid move takes seconds to come and a slow order almost always catches it. In a real market, when a queue is down to a few shares, whoever arrives first takes what is left at the old price and the rest pay the next tick; the race is always won by the fastest (between the colocated median, 28 µs, and the home one, 88,500 µs, there is a factor of more than 3,000). Strategies whose edge is getting there first (cross-venue arbitrage, reacting to the imbalance, market making with priority queues) are out of reach for a home connection, and even for a VPS.
Mermaid source
flowchart TB M[Market data] --> R[Inbound network] R --> D[Decision] D --> I[Outbound network] I --> B[Broker and risk] B --> X[Market]
The chain has more links than the network: the decision (the code, the interpreter, garbage collection) and the broker's risk checks before sending the order to the market. In a low-frequency system, the part to watch is the tail of that chain, not the median: a 700 ms spike on a news day, a gateway restart, a lost packet. That is solved with design (timeouts, client identifiers, reconciliation; module 4.5), not with speed.
Common mistakes
- Using the interval's OFI to trade in that interval. It is look-ahead bias: the OFI is only fully known once the price has already moved.
- Grouping wrongly. A
shiftwithout grouping by instrument mixes the last event of one asset with the first of the next; with several days, you also have to split by session. - Measuring latency with the average. The average hides the tail, which is what causes expired timeouts and duplicate orders. Look at p99 and p99.9 and count the losses.
- Confusing network latency with system latency. Ping measures the network; the decision in Python, serialization and the broker's checks also count, and they are measured with your own timestamps at each link.
- Buying speed for a strategy that does not need it. On a horizon of minutes, going from 88 ms to 1 ms does not change the result; a closer server can make sense for reliability (fewer hops, fewer losses).
Exercises
- Repeat the OFI regression with intervals of 30 seconds and 5 minutes. How do β and R² change? Why, with 5 minutes, should you distrust the figure even if the R² is high?
Solution
filas = []
for tk, g in cot.groupby("ticker"):
for f in ["30s", "5min"]:
d = intervalos(g, f)
b, r2 = ajuste(d["ofi"].to_numpy(), d["dp"].to_numpy())
filas.append({"ticker": tk, "intervalo": f, "n": len(d),
"beta_e6": b * 1e6, "r2": r2})
pd.DataFrame(filas).round(2) ticker intervalo n beta_e6 r2
0 ARVA 30s 180 2.57 0.76
1 ARVA 5min 18 3.35 0.89
2 BELO 30s 180 4.21 0.74
3 BELO 5min 18 4.44 0.74With 30 seconds the R² rises to 0.76 and 0.74 and β changes little (2.57 in ARVA, 4.21 in BELO, against the reference 2.44 and 4.36 at 10 s): the linear relationship holds when aggregating, which is what CKS call stability across scales. With 5 minutes there are only 18 intervals per stock: ARVA gives β = 3.35 with R² = 0.89, a slope 37% higher than the reference with the best fit in the table. With 18 points the estimation error is large and the high R² reflects aggregation more than a stronger relationship (with few points, a few large intervals dominate the regression). CKS choose 10 seconds as a compromise: many intervals and enough events in each one.
- Split the OFI into its buy part (the bid terms) and its sell part (the ask terms) and fit in BELO. Are the two slopes similar? What would you expect in the model?
Solution
g = cot[cot["ticker"] == "BELO"].copy()
b, a = g["bid"], g["ask"]
qb, qa = g["tam_bid"], g["tam_ask"]
g["e_b"] = ((b >= b.shift()) * qb - (b <= b.shift()) * qb.shift())
g["e_a"] = ((a >= a.shift()) * qa.shift() - (a <= a.shift()) * qa)
k = g.fillna(0).set_index("ts")
X = np.column_stack([k["e_b"].resample("10s").sum(),
k["e_a"].resample("10s").sum()])
y = iv["BELO"]["dp"].to_numpy()
coef, *_ = np.linalg.lstsq(X, y, rcond=None)
print("beta_b, beta_a (x1e-6):", (coef * 1e6).round(2))beta_b, beta_a (x1e-6): [4.28 4.24]The two slopes come out almost equal (4.28 and 4.24, against 4.26 for the joint one), as befits a symmetric model: buy and sell orders arrive with the same rates per side. In real data they can differ (for example, on days of sharp declines) and the split version is a simple way to check it.
- Design the timeout of a system that sends orders from home to a gateway in New York: if you set it at the p99.9 of latency, how many responses per 10,000 sends would arrive late (or not arrive) in the data? And if you set it at twice the median?
Solution
With the data from the first latency cell: the p99.9 of the retail_espana_nueva_york profile is at about 506 ms, so,
by construction, one response in a thousand arrives later, to which the losses must be added (11 of 3,000, about 37
per 10,000). With twice the median (about 177 ms) quite a few more would exceed the timeout: all the planted spikes (a
1% probability, averaging 150 ms) plus the tail of the lognormal part. In both cases, a late response does not mean
the order did not arrive: the system has to query by its client identifier before resending (lessons 3.3.3 and
4.5.5). The timeout decides when to ask, not when to resend.
Footnotes
-
R. Cont, A. Kukanov and S. Stoikov, “The Price Impact of Order Book Events”, Journal of Financial Econometrics 12(1), 2014, pp. 47-88, doi:10.1093/jjfinec/nbt003; version on arXiv:1011.6402 (definition of the OFI, 10 s intervals, average R² of 65% for the OFI against 32% for the trade imbalance, β inversely proportional to depth), accessed on October 22, 2026. ↩
-
M. D. Gould and J. Bonart, “Queue Imbalance as a One-Tick-Ahead Price Predictor in a Limit Order Book”, arXiv:1512.03492, 2015, accessed on October 22, 2026. ↩
Each answer is folded under its question
Self-assessment
In a 10-second interval, the size of a stock's best bid goes from 600 to 900 shares with no change in prices, and that of the best ask, from 500 to 300. What is the OFI of the interval if there are no other events?
- A+500: +300 for the bid that grows and +200 for the ask that shrinks
- B+100: the difference between the two changes
- C−500: the ask dominates
- D0: prices do not change
Show answer
Answer A. +500: +300 for the bid that grows and +200 for the ask that shrinks
With unchanged prices, each event adds the change in bid size and subtracts the change in ask size: (900 − 600) − (300 − 500) = +500. More demand or less supply at the top of the book pushes the mid up.
In the BELO session, the regression without a constant of the 10 s mid change on the OFI gives β ≈ 4.26 millionths of a USD per share, with an average depth of about 1,136 shares per side. What does Cont, Kukanov and Stoikov's approximation say?
- Aβ ≈ tick/(2·depth) = 0.01/(2·1,136) ≈ 4.4 millionths: the measured value is close
- Bβ ≈ tick·depth: it should be 11.4
- Cβ does not depend on depth
- Dβ ≈ 1/depth: about 880 millionths
Show answer
Answer A. β ≈ tick/(2·depth) = 0.01/(2·1,136) ≈ 4.4 millionths: the measured value is close
In the stylized one-level model, it takes depleting a queue (≈ the depth) to move the mid by half a tick: β ≈ (tick/2)/depth. The session gives 4.26 against 4.40 from the approximation and 4.36 from the generator's long reference.
The OFI explains 67% of the variance of the mid change in the same 10 s interval. What R² does the OFI of one interval have for the change in the next interval in the data?
- AAlso around 67%
- BAround 30%
- CPractically zero (0.3% in ARVA and 0.1% in BELO)
- DNegative
Show answer
Answer C. Practically zero (0.3% in ARVA and 0.1% in BELO)
The relationship is contemporaneous: the OFI is the way the price changes, not a warning of how it will change. To use it you have to know it before it finishes moving the price, and that requires being inside the interval, that is, latency.
A home fiber connection in Spain to a gateway in New York has a median round trip of about 88 ms. What part of that time is physics and cannot be bought?
- AAlmost none: it is all congestion
- BAbout 57 ms: 5,800 km of fiber, there and back, at about 204,000 km/s
- CExactly 88 ms
- DAbout 6 ms
Show answer
Answer B. About 57 ms: 5,800 km of fiber, there and back, at about 204,000 km/s
Light in glass travels at about two thirds of its speed in a vacuum. 2 × 5,800 km / 204,000 km/s ≈ 57 ms. The rest (equipment, routes, queues) can be reduced by getting closer, but distance is only eliminated by moving: VPS, cloud in the region or colocated server.
With the 88 ms median, how likely is it that ARVA's mid price has changed between seeing a quote and the order arriving, and how likely that something at the top of the book (price or size) has changed?
- ABoth around 50%
- B1.3% the mid and 59% the top of the book: the price is almost always still there, but the queue sizes are already different
- C59% the mid and 1.3% the top of the book
- DZero for both
Show answer
Answer B. 1.3% the mid and 59% the top of the book: the price is almost always still there, but the queue sizes are already different
ARVA changes its mid about nine times a minute, but its top of book moves about ten times a second. For a market order with a horizon of minutes, 88 ms costs almost nothing; for a strategy that depends on queue position or visible size, the book you saw no longer exists.
Key points
- Cont, Kukanov and Stoikov's OFI adds up, event by event, how much demand grows and how much supply is withdrawn at the top of the book (also when the price changes), and treats a market sell the same as a cancellation of a buy of the same size.
- Over 10 s intervals the mid change is linear in the OFI: in the data, β = 2.38 (ARVA) and 4.26 millionths of a USD per share (BELO), with R² of 0.67 and 0.68, against 2.44 and 4.36 for the planted truth.
- The slope is inversely proportional to depth: β ≈ tick/(2·depth). In 10-minute windows, β·depth comes out at 0.0046 USD against the half tick of 0.005.
- The OFI is contemporaneous: it explains 67% of the change in the same interval and almost nothing of the next one. A trade imbalance (trades only) explains less (R² 0.37 and 0.42), as in the original paper (65% against 32%).
- Round-trip latency goes from 1.6 µs (FPGA) to 88 ms (home in Spain with a gateway in New York), with tails that reach 730 ms and lost packets. Physics sets the floor: about 57 ms from the Madrid-New York distance alone.
- For a market order with a horizon of minutes, a retail trader's latency barely moves the mid price; what changes is the rest of the book and, above all, the long tails of the distribution, which are what force you to design timeouts and retries.