A liquidity lab
For finance lecturers. Students hear that markets get expensive to trade in a crisis. It is easy to say and hard to believe until you see the numbers, and real trade records cannot show it, because no one can trade the same day twice. This lab lets students measure it. They place the same large buy order in the same market twice, once on a calm day and once in a crisis, and compare what it cost. The two markets are identical until the crisis starts, so the crisis is the only difference.
The lesson in one line: in a crisis the same order costs about three times as much, and the biggest one cannot be filled. Buying half a day's volume cost 130 basis points in the crisis against 46 on a calm day, and only 84% of it filled.
What one buy order cost, by size
Cost in basis points, hundredths of a percent, compared with the same market without the order
In a crisisOn a calm day
Fewer sellers in a crisis
Picture buying a hundred crates of apples at a market. On a normal day there are plenty of stalls, so you pay close to the sign price. On a bad day half the stalls are shut, so you walk further down the row and pay more for each crate, and you may go home with fewer than you wanted. That is liquidity, and this lab measures it.
The question
How much more does one large order cost when the market is under stress?
The fair test
The same market is run twice from the same starting point. The only change is the crisis, so any difference in cost comes from the crisis.
The result
Every order size cost about three times as much in the crisis. The biggest order only got 84% of what it asked for. In the calm market it got all of it.
tradefloor against a backtest
A backtest replays one price history. It can say what a stock did, but not what your own order would have done to it, and history never shows the same day twice. tradefloor builds the market, so it can run the same day calm and in a crisis, and let the order hit an order book that runs out.
| Needed for this lab | Backtest on price history | tradefloor |
|---|---|---|
| The same market with and without a crisis | No. Each day happened once. | Yes. Two copies share their past to the bit, and only one gets the crisis. |
| The cost of your own large order | Guessed from a fixed slippage figure | Measured, as fills against an order book, compared with the same market without the order |
| An order the market cannot fill | Never happens. Every order fills at the price. | Shown. The crisis copy filled 84% of the largest order. |
| The same numbers for every student | Depends on the data and the dates each student picks | Yes. A seed gives the same market on every machine. |
Running the lab
pip install tradefloor on Python 3.11 or later. The lab needs no API key and no data download.example-teaching.py and run python example-teaching.py. It takes under a minute and prints the table below, the same on every machine.SIZES to try other order sizes, or SEEDS to add markets. The scenario guide shows how to write a scenario that thins the book and changes nothing else."""Lab: what does one large order cost in a calm market and in a crisis?
Each run is the same simulated market twice, once calm and once with the
shipped liquidity_crisis scenario, which thins the order book from day 50.
On day 55 you buy a share of each stock's average daily volume at once,
and tca.analyse prices every fill against the same market where you never
traded.
"""
from math import comb
from statistics import median
import tradefloor as tf
universe = tf.Universe.random(4, seed=5)
crisis = tf.Scenario.load("liquidity_crisis")
SIZES = [0.01, 0.10, 0.50] # order size as a share of daily volume
SEEDS = [1, 2, 3]
ORDER_DAY = 55 # inside the crisis window, days 50-74
class OneLargeOrder:
def __init__(self, size):
self.size = size
self.price = [] # first price of each day, for the chart
def act(self, obs):
if obs.is_first_step_of_day:
self.price.append(round(obs.price("AAA"), 2))
if obs.day == ORDER_DAY and obs.is_first_step_of_day:
return {t: self.size * obs.avg_volume(t) for t in obs.tickers}
return None
def run(size, seed, scenario):
agent = OneLargeOrder(size)
# Cash with no leverage cap, so the book limits each order, not the budget.
ex = tf.tca.analyse(agent, seed=seed, universe=universe, days=ORDER_DAY + 1,
scenario=scenario, cash=1e12, max_leverage=None)
costs = [ex.shortfall_bps(f["ticker"]) for f in ex.fills]
filled = [f["quantity"] / f["requested"] for f in ex.fills]
return costs, filled, agent.price
cost_bps = {"calm": [], "crisis": []} # median cost per size, chart 1
filled_pct = {"calm": [], "crisis": []} # median share filled per size
price = {} # AAA by day, last run, chart 2
worse = total = 0
for size in SIZES:
costs, filled = {"calm": [], "crisis": []}, {"calm": [], "crisis": []}
for seed in SEEDS:
for arm, scenario in (("calm", None), ("crisis", crisis)):
c, f, price[arm] = run(size, seed, scenario)
costs[arm] += c
filled[arm] += f
for arm in costs:
cost_bps[arm].append(round(median(costs[arm]), 1))
filled_pct[arm].append(round(100 * median(filled[arm]), 1))
# Same seed, same stock, same shares: each pair differs only in the crisis.
worse += sum(b > a for a, b in zip(costs["calm"], costs["crisis"]))
total += len(costs["calm"])
print(f"{'order size':>12} {'calm bps':>9} {'crisis bps':>11} {'ratio':>6} "
f"{'calm fill':>10} {'crisis fill':>12}")
for i, size in enumerate(SIZES):
calm_c, crisis_c = cost_bps["calm"][i], cost_bps["crisis"][i]
print(f"{size:>11.0%} {calm_c:>9.1f} {crisis_c:>11.1f} {crisis_c / calm_c:>5.1f}x "
f"{filled_pct['calm'][i]:>9.0f}% {filled_pct['crisis'][i]:>11.0f}%")
# Two-sided sign test: how likely this many crisis-worse orders are by chance.
k = max(worse, total - worse)
p = min(1.0, 2 * sum(comb(total, j) for j in range(k, total + 1)) / 2 ** total)
print(f"\nCrisis cost more on {worse} of {total} paired orders (sign test p = {p:.2g}).")
ratio = cost_bps["crisis"][-1] / cost_bps["calm"][-1]
unfilled = 100 - filled_pct["crisis"][-1]
finding = "the crisis made large orders cost more" if p < 0.05 else "no clear crisis effect"
print(f"Verdict: a {SIZES[-1]:.0%}-of-volume order cost {ratio:.1f}x as much in the "
f"crisis and left {unfilled:.0f}% unfilled: {finding} on these markets.") order size calm bps crisis bps ratio calm fill crisis fill
1% 7.8 29.5 3.8x 100% 100%
10% 22.0 71.8 3.3x 100% 100%
50% 46.3 130.0 2.8x 100% 84%
Crisis cost more on 35 of 36 paired orders (sign test p = 1.1e-09).
Verdict: a 50%-of-volume order cost 2.8x as much in the crisis and left 16% unfilled: the crisis made large orders cost more on these markets.The p-value counts each order as one pair, and the four orders in one run share a market, so treat it as rough. The direction is not in doubt: one pair in 36 went the other way.
The market before the order
One stock, calm and in a crisis
First price of each day, in the last of the runs
Liquidity crisis5.34Calm market5.83
The two copies are the same market to the bit until day 50, when the liquidity_crisis scenario cuts the depth of the order book and raises volatility. The order goes in on day 55.
Limits
- One small market of four simulated companies, three seeds and one shipped scenario. Other rosters and scenarios give other costs.
liquidity_crisischanges several things at once, among them book depth, volatility and credit spreads. This lab does not say which of them raised the cost.- Each order is one market order sent at once. The lab shows the problem and does not test the fixes, such as splitting the order or waiting.
- The script gives the agent unlimited cash and no leverage cap, so the order book, not the budget, limits each order. A real fund's limits would stop some of these orders first.
- Buys only. Selling into a crisis may cost something else.
Reference
tf.tca.analyse, Execution.shortfall_bps and Execution.fills are in Agents and evaluation. Scenarios are in Run a scenario.