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.
Trading cost for a fund that trades after the momentum traders
Basis points per trade over the same 30 days, in each market with and without the momentum traders
With momentum tradersWithout them
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 study | Backtest or historical data | tradefloor |
|---|---|---|
| The same market with and without the traders | No. Each period happened once. | Yes. Two copies share their past and their random draws. |
| Traders whose orders move prices | No. Replayed prices ignore your orders. | Yes. Every order fills against an order book. |
| Control over who trades first | No | Yes. The order agents reach the book is set, so both ends can be tested. |
| A run another lab can rebuild | Needs the same data licence and cleaning | A 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
Volatility with and without the momentum traders
Annualised, percent, the same 30 days in each market
With momentum tradersWithout them
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.