Skip to the page
EXAMPLES/TEST A MARKET HYPOTHESIS

Test a market hypothesis

For researchers. A common claim is that momentum traders, who buy what has been rising, make markets wilder and more expensive for everyone else. Real data cannot settle it, because years with more momentum trading also differ in news, rates and mood. The answer matters to anyone who runs a fund beside these traders or designs the rules of a market. This script tests the claim directly. It copies a market, lets three momentum traders trade in one copy only, and compares the two copies, on eight markets.

The claim half holds. The momentum traders barely changed volatility, up 0.07 points a year, which is noise. They did make trading dearer for a fund whose orders arrived after theirs, by 3.9 basis points a trade on all 8 markets. A fund that traded before them paid nothing extra.

+0.07change in volatility, points a year
+3.9 bpsextra cost for a fund trading after them
8 of 8markets where that cost rose

Two copies of one market

Twin studies work because twins share so much that a difference between them points at the one thing that differs. Real markets have no twins. A busy year for momentum funds is also a year with different news, rates and mood. This script copies one market so both copies share the same past and the same random draws, and switches the momentum traders on in one copy only.

The question

Do momentum traders change volatility, or what other traders pay?

The fair test

Each market runs five days, then splits in two. In one copy the momentum traders start trading, and in the other they stay still. A check confirms the copies are identical at the split.

The result

Volatility hardly moved. A fund trading after the momentum traders paid more on every market, and a fund trading before them paid the same as without them.

tradefloor against a backtest

A backtest replays prices that already happened, so it cannot add or take away traders and see what changes. tradefloor runs the traders inside the market, so their orders move prices and use up the book, and it can fork a market so the same day plays out with and without them.

Needed for this studyBacktest or historical datatradefloor
The same market with and without the tradersNo. Each period happened once.Yes. Two copies share their past and their random draws.
Traders whose orders move pricesNo. Replayed prices ignore your orders.Yes. Every order fills against an order book.
Control over who trades firstNoYes. The order agents reach the book is set, so both ends can be tested.
A run another lab can rebuildNeeds the same data licence and cleaningA saved manifest rebuilds the market and checks it to the bit

The script

Install with pip install tradefloor. The script needs no API key and runs in about a minute and a half.

import math
import statistics
import struct

import tradefloor as tf

SEEDS, SHARED_DAYS, DAYS = range(8), 5, 30
universe = tf.Universe.random(12, seed=5)
MOMENTUM = {"momentum_05": 5, "momentum_10": 10, "momentum_20": 20}   # label: lookback days


class Fund:
    # trades 25,000 of every name each morning, buying one day and selling the next
    def act(self, obs):
        if obs.is_first_step_of_day:
            side = 1 if obs.day % 2 == 0 else -1
            return {t: side * round(25_000 / p) for t, p in zip(obs.tickers, obs.prices)}


class Momentum:
    # each morning, long names above their close `lookback` days ago, short the rest
    def __init__(self, lookback):
        self.lookback = lookback

    def act(self, obs):
        if obs.day < SHARED_DAYS or not obs.is_first_step_of_day:
            return None                          # waits until the fork
        worth, orders = obs.portfolio.net_worth(), {}
        for t in obs.tickers:
            side = 1 if obs.price(t) > obs.history.bars(t, last=self.lookback)[0]["close"] else -1
            orders[t] = int(side * 1.8 / len(obs.tickers) * worth / obs.price(t)) - obs.position(t)
        return orders


def sign_test(off, on):
    # paired two-sided sign test: seeds where the value rose with momentum, and its p-value
    up, n = sum(b > a for a, b in zip(off, on)), len(off)
    return up, min(1.0, 2 * sum(math.comb(n, k) for k in range(max(up, n - up), n + 1)) / 2 ** n)


def run(world):
    # mean annualised volatility of each name's daily close-to-close returns
    closes = []
    for _ in range(DAYS):
        world.run(1)
        closes.append(struct.unpack(f"<{len(universe)}d", world.engine.prices()))
    vols = [statistics.stdev(math.log(b[i] / a[i]) for a, b in zip(closes, closes[1:]))
            for i in range(len(universe))]
    return 100 * math.sqrt(252) * statistics.mean(vols)


# labels set arrival order at the book: a_fund trades before the momentum cohort, z_fund after
volatility = {"without momentum": [], "with momentum": []}
cost_bps = {f"{fund} {arm}": [] for fund in ("a_fund", "z_fund") for arm in ("without", "with")}
for seed in SEEDS:
    agents = {"a_fund": Fund(), "z_fund": Fund(), **{m: Momentum(days) for m, days in MOMENTUM.items()}}
    world = tf.World(seed=seed, universe=universe, agents=agents, cash=20_000_000,
                     history_days=20)
    world.run(SHARED_DAYS)                       # one shared past
    (crowded,) = world.fork("with momentum")
    quiet = world
    for label in MOMENTUM:                       # the same market, cohort frozen
        quiet = quiet.without(label)
    assert tf.agree(crowded, quiet).identical
    for arm, name in ((quiet, "without"), (crowded, "with")):
        volatility[f"{name} momentum"].append(round(run(arm), 3))
        for fund in ("a_fund", "z_fund"):
            cost_bps[f"{fund} {name}"].append(round(arm.summary(agent=fund)["execution_cost_bps"], 3))

print(f"{'':20}{'without':>9}{'with':>9}{'change':>9}{'up on':>8}{'p':>7}")
rows = {"volatility, %/yr": (volatility["without momentum"], volatility["with momentum"])}
rows |= {f"{f} cost, bps": (cost_bps[f"{f} without"], cost_bps[f"{f} with"]) for f in ("a_fund", "z_fund")}
tests = {}
for label, (off, on) in rows.items():
    up, p = tests[label] = sign_test(off, on)
    off, on = statistics.mean(off), statistics.mean(on)
    print(f"{label:20}{off:9.2f}{on:9.2f}{on - off:+9.2f}{up:>6}/{len(SEEDS)}{p:7.3f}")

(vol_up, vol_p), (cost_up, cost_p) = tests["volatility, %/yr"], tests["z_fund cost, bps"]
extra = statistics.mean(cost_bps["z_fund with"]) - statistics.mean(cost_bps["z_fund without"])
vol_change = statistics.mean(volatility["with momentum"]) - statistics.mean(volatility["without momentum"])
print(f"Verdict: volatility {'changed' if vol_p < 0.05 else 'showed no clear change'} (up on {vol_up}/{len(SEEDS)}, "
      f"p={vol_p:.2f}); the fund behind the cohort paid {extra:+.1f} bps a trade (up on {cost_up}/{len(SEEDS)}, p={cost_p:.3f}).")
manifest = crowded.manifest(label=f"with momentum, seed {seed}")
rebuilt = tf.RunManifest.from_json(manifest.to_json()).reproduce()
print(f"Reproduce: {manifest.model['name']}, universe {universe.fingerprint[:12]}, seeds {SEEDS.start}-{SEEDS.stop - 1}; "
      f"seed {seed} manifest rebuilds it: {rebuilt.state_hash() == crowded.engine.state_hash()}")
                      without     with   change   up on      p
volatility, %/yr        26.45    26.52    +0.07     6/8  0.289
a_fund cost, bps         8.29     8.29    +0.00     3/8  0.727
z_fund cost, bps         9.30    13.15    +3.86     8/8  0.008
Verdict: volatility showed no clear change (up on 6/8, p=0.29); the fund behind the cohort paid +3.9 bps a trade (up on 8/8, p=0.008).
Reproduce: pt-v20, universe 30399532fa93, seeds 0-7; seed 7 manifest rebuilds it: True

Two funds trade the same way, buying one day and selling the next. Agents reach the order book in the order of their labels, so a_fund trades before the momentum traders in each step and z_fund after them. The first saw no change in cost and the second saw its cost rise on every market. A fund behind a crowd pays for the depth the crowd has already used. A study of daily prices alone would not see this.

Volatility in each market

The pairs sit almost on top of each other. At this size, on this preset, three momentum traders do not measurably change volatility.

Adapting it

Replace the Momentum class with the strategy you want to study, or change cash= to change the cohort's size, and add seeds to SEEDS for a tighter test. The last line names what fixes the run, and world.manifest() saves a JSON record that rebuilds the market exactly. Fork a market and Reproducibility cover both.

Limits

  • One preset, one roster of twelve simulated companies and one cohort size. A larger cohort or other lookbacks may change the volatility answer.
  • The two funds sit at the two ends of the queue on purpose, so the cost result shows both ends, not an average over random arrival.
  • Cost is measured against each step's opening price for orders sent at the start of each day.
  • Volatility is close to close over 30 days. Spreads and depth during the day are not measured.
  • Eight markets is a small sample, and a small effect on volatility could sit below what this run can detect.
  • The results describe this simulated market. A claim about real markets needs real-market evidence.

Reference

World, fork and tf.agree are in Forks and counterfactuals.