Stress testing a trading bot against a market crash
Every trading bot has a crash in its future. The part you get to choose is whether the first one happens on your laptop or in your brokerage account.
A backtest won't help much here. It can run your bot through 2008 and 2020, but only once each, and only on prices the bot couldn't have moved. tradefloor generates the market instead. You can run the same crash on six markets nobody has seen. The bot's own orders move the prices it trades at, too.
The bot under test
It's a momentum bot. Every morning it buys the five stocks that rose most over the last ten days and sells whatever dropped out of the top five. It does this at 1.5 times its net worth. In human terms, it buys whatever the group chat is excited about, every single day, with borrowed money.
Two more versions join it. One runs at 1x instead of 1.5x. The other also runs at 1x, rebalances once a week, and shrinks its exposure when the published VIX climbs above 20. Buy-and-hold comes along as the control, because every test needs someone who does nothing.
One past and four futures
tradefloor ships scenario files for a liquidity crisis, a rate shock and a recession. Each one fires on day 50 of a run. The same seed with no scenario is the control. Up to day 50 the control and the crisis are the same market, draw for draw. Whatever differs after that is the crisis.
article-stress-test.py
"""Stress test a trading bot against crashes it has never seen.
Each scenario file ships with tradefloor and fires on day 50 of the run. The
same seed with no scenario is the control: it is the same market, draw for
draw, until the scenario fires, so any difference after day 50 comes from
the shock. Three versions of one momentum bot are tested, and the question
is which crisis breaks a 15% drawdown limit.
"""
import tradefloor as tf
SEEDS = [21, 22, 23, 24, 25, 26]
DAYS = 80
LIMIT = 15.0 # the drawdown limit, in percent
market = tf.Universe.random(20, seed=7)
SCENARIOS = ["liquidity_crisis", "rate_shock", "recession"]
class Momentum:
"""Hold the five names that rose most over the last ten days, at a
chosen gross exposure. `every` is how many days between rebalances, and
with `vix_brake` the exposure shrinks as the published VIX rises above 20."""
def __init__(self, gross, every=1, vix_brake=False):
self.gross, self.every, self.vix_brake = gross, every, vix_brake
def act(self, obs):
if not obs.is_first_step_of_day or obs.day < 10 or obs.day % self.every:
return None
bars = {t: obs.history.bars(t, last=10) for t in obs.tickers}
rise = {t: b[-1]["close"] / b[0]["close"] - 1 for t, b in bars.items() if len(b) == 10}
top = sorted(rise, key=rise.get, reverse=True)[:5]
gross = self.gross
if self.vix_brake:
gross *= min(1.0, 20 / obs.history.macro()[-1]["vix"])
worth = obs.portfolio.net_worth()
orders = {}
for t in obs.tickers:
want = gross * worth / 5 / obs.price(t) if t in top else 0
change = round(want - obs.position(t))
if change:
orders[t] = change
return orders or None
BOTS = {
"original": dict(gross=1.5),
"lower": dict(gross=1.0),
"braked": dict(gross=1.0, every=5, vix_brake=True),
}
LABELS = {"original": "original", "lower": "less leverage",
"braked": "weekly, VIX brake", "hold": "buy and hold"}
def make(bot):
if bot == "hold":
return tf.reference_agents()["buy_and_hold"]
return Momentum(**BOTS[bot])
def worst_drawdown(bot, scenario):
worst = 0.0
for seed in SEEDS:
card = tf.evaluate({"bot": make(bot)}, seed=seed, universe=market,
days=DAYS, history_days=10,
scenario=tf.Scenario.load(scenario) if scenario else None)["bot"]
worst = max(worst, card.max_drawdown_pct)
return round(worst, 1)
ROWS = ["none"] + SCENARIOS
CALM = "none"
NAMES = list(BOTS) + ["hold"]
results = {bot: {name: worst_drawdown(bot, None if name == "none" else name) for name in ROWS}
for bot in NAMES}
print(f"worst drawdown over {len(SEEDS)} seeds and {DAYS} days (limit {LIMIT:.0f}%)")
print(f"{'':17}" + "".join(f"{LABELS[bot]:>19}" for bot in NAMES))
for name in ROWS:
print(f"{name:17}" + "".join(f"{results[bot][name]:>18.1f}%" for bot in NAMES))
breaks = {bot: [n for n in ROWS if results[bot][n] > LIMIT] for bot in NAMES}
# For the charts: the table turned round, and one seed's net worth through
# the liquidity crisis, 100 at the start, for three of the four.
by_scenario = {name: {bot: results[bot][name] for bot in NAMES} for name in ROWS}
curves = {}
for bot in ("original", "braked", "hold"):
card = tf.evaluate({"bot": make(bot)}, seed=SEEDS[0], universe=market, days=DAYS,
history_days=10, scenario=tf.Scenario.load("liquidity_crisis"))["bot"]
start = card.equity_curve[0]
curves[bot] = [round(100 * v / start, 2) for v in card.equity_curve]
for bot, names in breaks.items():
print(f"{LABELS[bot]}: " + ("over the limit in " + ", ".join(names) if names else "inside the limit everywhere"))Output on tradefloor 0.10.2
worst drawdown over 6 seeds and 80 days (limit 15%)
original less leverage weekly, VIX brake buy and hold
none 28.4% 19.5% 15.7% 16.0%
liquidity_crisis 67.4% 49.2% 27.7% 44.9%
rate_shock 33.1% 23.0% 20.8% 16.0%
recession 63.3% 46.0% 29.7% 41.0%
original: over the limit in none, liquidity_crisis, rate_shock, recession
less leverage: over the limit in none, liquidity_crisis, rate_shock, recession
weekly, VIX brake: over the limit in none, liquidity_crisis, rate_shock, recession
buy and hold: over the limit in none, liquidity_crisis, rate_shock, recessionWorst fall from a peak, over six markets
Each bar is the worst of the six seeds. The dashed line is the 15% limit.
1.5x, daily1x, daily1x, weekly, VIX brakeBuy and hold
Reading the table
Start with the top row, where nothing happens. With no crash at all, the original bot fell 28.4% from its peak. Rebalancing every morning means paying the spread every morning, and chasing last week's winners means buying high with some regularity. The biggest risk to this bot was never the crash. It was the bot itself, every morning at the open.
Then the crises arrive. The original bot loses 67.4% in the liquidity crisis and 63.3% in the recession. Cutting leverage to 1x takes about a quarter off every number, which is what you'd expect, and leaves it over the limit everywhere.
The third version rebalances once a week and shrinks when the VIX is high. That brings the liquidity crisis down to 27.7%. Holding every stock and doing nothing loses 44.9% in the same crisis. The braked bot beats the market exactly when it matters.
Net worth through the liquidity crisis, one market
100 at the start. The crisis lands on day 50.
1.5x, daily65.31x, weekly, VIX brake85.2Buy and hold86.1
It still breaks the 15% limit. So does buy-and-hold, which falls 16.0% on a quiet market with no crisis in it. When doing nothing at all breaks your risk limit, the limit and the market need a conversation.
Next steps for the bot
You now know three things you didn't. The original bot loses money to its own trading before any crash arrives. The VIX brake works. And a 15% limit over 80 days doesn't fit a portfolio of five stocks.
The fixes are ordinary ones. Hold more names, trade less often, hedge, or agree a limit that the market itself can keep. Run each one through the same three crises, and the table tells you which one earns its place.
One caution. tradefloor's scenarios move prices at about half to four-fifths of the size real crises did, in the right direction. Treat these drawdowns as the polite version. A bot that fails the polite version isn't ready for the real one.
Running it yourself
Install the library with pip install tradefloor and run the script above. It takes about a minute and a half and needs no account and no API key. Run a scenario covers writing your own crisis file, and the video Would my trading bot survive a crash shows the same kind of test on the hosted app, with Claude finding the fix.