Skip to the page
EXAMPLES/A LIQUIDITY LAB

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.

130 bpscost of a big order in a crisis
46 bpsthe same order on a calm day
84%of it filled in the crisis
35 of 36orders that cost more in the crisis

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 labBacktest on price historytradefloor
The same market with and without a crisisNo. 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 orderGuessed from a fixed slippage figureMeasured, as fills against an order book, compared with the same market without the order
An order the market cannot fillNever happens. Every order fills at the price.Shown. The crisis copy filled 84% of the largest order.
The same numbers for every studentDepends on the data and the dates each student picksYes. A seed gives the same market on every machine.

Running the lab

1Installpip install tradefloor on Python 3.11 or later. The lab needs no API key and no data download.
2RunSave the script below as example-teaching.py and run python example-teaching.py. It takes under a minute and prints the table below, the same on every machine.
3AskWhy does the cost rise faster than the order size? Why does only the largest order go unfilled, and only in the crisis?
4ExtendChange 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

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_crisis changes 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.