A risk gate for a trading bot in CI
When ordinary code breaks in production you get a stack trace. When a trading bot breaks in production you get a margin call.
Most of your code runs its tests on every pull request. The trading bot usually has a backtest that runs when somebody remembers to run it. This guide gives the bot a test of its own: a dozen markets, one rule, and a red build when a change breaks the rule.
The rule
The bot may not fall more than 25% from its peak on any of 12 markets. Six are ordinary. The other six get tradefloor's packaged liquidity crisis on day 50, because a bot only needs to meet one of those to ruin a quarter.
Three versions of a momentum bot go through the gate. Main is the bot as it runs today, at 1x. The pull request raises its leverage to 1.5x, because the backtest looked great. The fix keeps 1x and shrinks the bot's exposure whenever the VIX climbs above 15.
article-ci-gate.py
"""A risk gate for a trading bot, run in CI on every pull request.
The gate plays the bot on 12 markets: six ordinary ones and six with the
packaged liquidity crisis on day 50. It fails if the bot falls more than 25%
from its peak on any of them. Three versions meet it: the bot on main, a
pull request that raises its leverage from 1x to 1.5x, and a fix that
shrinks the bot's exposure when the VIX rises above 15.
In CI, set GATE_EXIT=1 and the script exits non-zero when the gate fails,
which is all a CI job needs to go red.
"""
import os
import sys
import tradefloor as tf
LIMIT = 25.0 # the most a bot may fall from its peak, in percent
DAYS = 80
market = tf.Universe.random(20, seed=7)
MARKETS = [(seed, None) for seed in (31, 32, 33, 34, 35, 36)] + \
[(seed, "liquidity_crisis") for seed in (41, 42, 43, 44, 45, 46)]
class Momentum:
"""Hold the five names that rose most over the last ten days, at a
chosen gross exposure, rebalanced once a week."""
def __init__(self, gross, vix_knee=None):
self.gross, self.vix_knee = gross, vix_knee
def act(self, obs):
if not obs.is_first_step_of_day or obs.day < 10 or obs.day % 5:
return None
gross = self.gross
if self.vix_knee:
gross *= min(1.0, self.vix_knee / obs.history.macro()[-1]["vix"])
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]
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
def gate(**bot):
"""The worst fall from a peak on each of the 12 markets."""
falls = []
for seed, scenario in MARKETS:
card = tf.evaluate({"bot": Momentum(**bot)}, seed=seed, universe=market, days=DAYS,
history_days=10,
scenario=tf.Scenario.load(scenario) if scenario else None)["bot"]
falls.append(round(card.max_drawdown_pct, 1))
return falls
VERSIONS = {"main": dict(gross=1.0),
"pull_request": dict(gross=1.5),
"fix": dict(gross=1.0, vix_knee=15)}
falls = {name: gate(**bot) for name, bot in VERSIONS.items()}
failed = {name: sum(f > LIMIT for f in result) for name, result in falls.items()}
worst = {name: max(result) for name, result in falls.items()}
# The six crisis markets, for the chart.
crisis = {name: result[6:] for name, result in falls.items()}
LABELS = {"main": "main, 1x", "pull_request": "pull request, 1.5x",
"fix": "fix, VIX brake"}
for name in VERSIONS:
verdict = "PASS" if not failed[name] else f"FAIL on {failed[name]} of {len(MARKETS)} markets"
print(f"{LABELS[name]:20} worst fall {worst[name]:5.1f}% {verdict}")
if os.environ.get("GATE_EXIT"):
sys.exit(1 if failed["pull_request"] else 0)Output on tradefloor 0.10.2
main, 1x worst fall 49.3% FAIL on 3 of 12 markets
pull request, 1.5x worst fall 63.1% FAIL on 6 of 12 markets
fix, VIX brake worst fall 24.0% PASSWorst fall from a peak in each crisis market
The six markets with a liquidity crisis on day 50. The dashed line is the 25% limit.
Main, 1xPull request, 1.5xFix, VIX brake
Three verdicts
The pull request fails, as everyone secretly expected. It breaks the limit on 6 of the 12 markets, and its worst fall is 63.1%. Leverage makes good markets better and bad ones a lot worse, and the backtest only had good ones.
The surprise is main. The bot already trading fails too, on 3 of the crisis markets, with a worst fall of 49.3%. That happens a lot the first time a gate goes in. It catches the code everyone stopped looking at months ago, which makes it the most useful thing the gate will ever do and slightly awkward for whoever approved main.
The fix passes. Its worst fall is 24.0%, inside the limit on every market. Only just, though. A margin of about a point says the bot has no room for a crisis worse than the ones in the test, and a real crisis is under no obligation to stay polite.
Wiring it into CI
The script exits with an error when GATE_EXIT is set and the gate fails, and that's all any CI system needs to turn the build red. In GitHub Actions it is one job:
name: risk gate
on: pull_request
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install tradefloor
- run: GATE_EXIT=1 python risk_gate.py
In your own repository the script imports the bot from the branch under test and checks that one version. The example here tests three at once so you can see all three verdicts side by side.
The markets come from fixed seeds, and the same seed gives the same market on every platform. A red build on CI is the same red build on your laptop. Fixed seeds make a good regression test. They also make it possible to tune a bot until it passes those twelve markets and nothing else. Every so often, run it on fresh seeds as well. Skill or luck in a trading strategy explains why that matters more than it sounds.
Running it yourself
Install the library with pip install tradefloor and run the script. It takes about a minute. The worked example Gate an agent on a risk rule does the same for AI agents, and the video Does my trading bot pass CI shows the hosted app's stress suite failing a pull request and Claude fixing the bot from the CI log.