---
title: "4.3.2 Order flow imbalance and latency"
description: "Cont, Kukanov and Stoikov's OFI on a synthetic book: β = tick/(2·depth), R², time scales and what latency costs."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/order-flow-imbalance-and-latency/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/order-flow-imbalance-and-latency.png"
---

# 4.3.2 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- Module: 4.3 · Microstructure and execution
- Duration: 65 min
- Prerequisites: [1.1.3 · Order book: bid, ask, spread and depth](https://pythonparatrading.com/en/python-trading-course/level-1/order-book-bid-ask-spread-depth/), [1.1.4 · Order types](https://pythonparatrading.com/en/python-trading-course/level-1/order-types-explained/), [1.1.5 · Costs, settlement, leverage and margin](https://pythonparatrading.com/en/python-trading-course/level-1/trading-costs-settlement-leverage-and-margin/), [2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-transaction-cost-modeling/)

## By the end of this lesson

- Calculate the order flow imbalance (OFI) of each top-of-book event and aggregate it by interval without looking at the future.
- Estimate the slope β of the regression of the mid-price change on the OFI, and compare it with the planted truth and with Cont, Kukanov and Stoikov's tick/(2·depth) approximation.
- Distinguish a contemporaneous relationship (the OFI explains the change in the same interval) from a signal that anticipates the next one, and measure explanatory power by time scale.
- Describe the round-trip latency distributions of a retail trader, a VPS, the cloud, a colocated server and an FPGA, with their median, their tail and their losses.
- Quantify what latency costs: how likely it is that the book you saw no longer exists when the order arrives and how much the mid price moves during the trip.

## 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
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 --> D
```

A 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%[^1].

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.

```python
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)
```
```output
{'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      900
```

## Cont, 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:

$$
e_n = \mathbb{1}[b_n \ge b_{n-1}]\,q^b_n - \mathbb{1}[b_n \le b_{n-1}]\,q^b_{n-1}
    - \mathbb{1}[a_n \le a_{n-1}]\,q^a_n + \mathbb{1}[a_n \ge a_{n-1}]\,q^a_{n-1}
$$

with $b$ and $a$ the best bid and ask prices and $q^b$, $q^a$ their sizes. If prices do not change, the two indicators
on each side are 1 and what remains is $e_n = \Delta q^b_n - \Delta q^a_n$: 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 $e_n$ of the events that fall in it.

```python
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)
```
```output
         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 -100
```

The 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 $\Delta P_k = \beta\,\mathrm{OFI}_k + \varepsilon_k$, with
$\Delta P_k$ 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 $\beta \approx \text{tick}/(2\cdot AD)$, with $AD$ the average top-of-book depth: it takes consuming
roughly a whole queue to move the mid by half a tick.

```python
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)
```
```output
        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.67
```

With 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
$\beta = 4.26\cdot 10^{-6}$, 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.

<aside class="callout" data-kind="nota">

Uncentered R². A regression without a constant is evaluated with $1 - \sum \hat\varepsilon^2 / \sum \Delta P^2$,
without subtracting the mean of $\Delta P$. Here the mean change is almost zero and the two versions agree to two
decimals; in a trending series they would not agree and the uncentered R² would come out inflated. When comparing
with a table in a paper, check which one it uses.

</aside>

## The slope depends on depth

The relationship $\beta \propto 1/AD$ 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):

```python
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)
```
```output
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.34
```

The 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:

*Fig. — Slope β estimated in 10-minute windows against average top-of-book depth, with the fitted curve β = 0.00455/depth and the CKS approximation, half a tick / depth.* Eighteen points in two groups: BELO, with depth from 1,050 to 1,145 shares and β from 3.5 to 4.8 millionths, and ARVA, with 1,700 to 1,860 shares and β from 2.0 to 2.8. Two decreasing curves run through the groups: the fitted one, highlighted, and the half-tick one, slightly above.

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.

```python
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)
```
```output
        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.769
```

Three 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
flowchart LR
  E[Book events] --> O[Interval OFI]
  O --> P[Mid change]
  O -. almost nothing .-> S[Next interval]
```

<aside class="callout" data-kind="aviso">

A backtest that uses the bar's OFI to decide the trade at that same bar's price has look-ahead bias (lesson 0.1.1),
even if the code does not look at any future row: the OFI and the mid change are the same thing seen twice. The
honest version uses the OFI up to the moment of the decision and the price at which you could execute afterwards,
with latency included. With that, in this data, nothing is left.

</aside>

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, $(q^b - q^a)/(q^b + q^a)$, 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 tick[^2]. 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:

```python
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)
```
```output
                              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         0
```

*Fig. — Round-trip latency of six connection profiles: median (square), whisker to the 99.9th percentile and maximum (hollow circle); the vertical line marks the 57 ms light takes in fibre between Madrid and New York, round trip.* Logarithmic horizontal axis from 1 microsecond to 1 second. Medians: home to New York 88.5 ms, home to Frankfurt 31.1 ms, VPS in New Jersey 1.2 ms, cloud in the same region 0.65 ms, colocated server 27.8 µs and FPGA 1.6 µs. The tails of the networked profiles reach several times the median: the maximum from home to New York is 730 ms. The highlighted vertical line at 57 ms sits just left of the home-to-New-York median.

Five 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.

```python
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")
```
```output
Madrid - New York              56.88 ms round trip
Madrid - Frankfurt             14.22 ms round trip
New Jersey (same center)        0.49 ms round trip
```

From 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?

```python
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)
```
```output
                         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.0000
```

*Fig. — ARVA: probability that the top of book (price or size) and the mid price have changed by the time an order sent on seeing a quote arrives, by each profile's median latency.* Two curves against latency on a log scale. The top-of-book one rises from 0% with FPGA and colocated server to 0.7% in the cloud, 1.3% with a VPS, 27.5% from home to Frankfurt and 59% from home to New York, the latter highlighted. The mid-price one barely leaves zero: 1.3% from home to New York.

In 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
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 `shift` without 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

1. 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.**

```python
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)
```
```output
  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.74
```

With 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.

2. Split the OFI into its buy part (the bid terms) and its sell part (the ask terms) and fit
   $\Delta P = \beta_b\,\mathrm{OFI}^b + \beta_a\,\mathrm{OFI}^a$ in BELO. Are the two slopes similar? What would you
   expect in the model?

**Solution.**

```python
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))
```
```output
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.

3. 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.

[^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](https://doi.org/10.1093/jjfinec/nbt003); version on
    [arXiv:1011.6402](https://arxiv.org/abs/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.
[^2]: M. D. Gould and J. Bonart, “Queue Imbalance as a One-Tick-Ahead Price Predictor in a Limit Order Book”,
    [arXiv:1512.03492](https://arxiv.org/abs/1512.03492), 2015, accessed on October 22, 2026.

## Self-assessment

1. 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
   - D. 0: prices do not change

   *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.

2. 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

   *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.

3. 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?

   - A. Also around 67%
   - B. Around 30%
   - C. Practically zero (0.3% in ARVA and 0.1% in BELO)
   - D. Negative

   *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.

4. 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?

   - A. Almost none: it is all congestion
   - B. About 57 ms: 5,800 km of fiber, there and back, at about 204,000 km/s
   - C. Exactly 88 ms
   - D. About 6 ms

   *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.

5. 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?

   - A. Both around 50%
   - 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
   - C. 59% the mid and 1.3% the top of the book
   - D. Zero for both

   *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.

## Resources

### Research

- [Limit Order Book - Aggregated Market Liquidity](https://pythonparatrading.com/en/limit-order-book-and-market-liquidity/) · Research-0008 · 11 min
- [Quantitative Models and Algorithmic Execution](https://pythonparatrading.com/en/quantitative-models-algorithmic-execution/) · Research-0009 · 11 min

### Documentation

- [Cont, Kukanov and Stoikov (2014): The Price Impact of Order Book Events, Journal of Financial Econometrics 12(1)](https://doi.org/10.1093/jjfinec/nbt003) · doi.org
- [Cont, Kukanov and Stoikov: arXiv version (1011.6402)](https://arxiv.org/abs/1011.6402) · arxiv.org
- [Gould and Bonart (2015): Queue Imbalance as a One-Tick-Ahead Price Predictor in a Limit Order Book (arXiv)](https://arxiv.org/abs/1512.03492) · arxiv.org

### Data

- [Level 4 data: best bid and ask of ARVA and BELO, event by event (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/flujo4_cotizaciones.parquet) · Download · flujo4_cotizaciones.parquet
- [Level 4 data: round-trip latencies of six connection profiles (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/latencia4_rtt.parquet) · Download · latencia4_rtt.parquet

Previous lesson: [4.3.1 · Implementation shortfall, VWAP, TWAP and Almgren-Chriss](https://pythonparatrading.com/en/python-trading-course/level-4/implementation-shortfall-vwap-twap-almgren-chriss/)

Next lesson: [4.4.1 · Choosing a broker from Spain or the EU: regulation, assets, costs and API](https://pythonparatrading.com/en/python-trading-course/level-4/choosing-a-broker-in-the-eu/)
