Welcome to API Academy
Module 14 · Data · ~30 min
Risk metrics.
By the end of this module, your bot has guard rails that physically prevent it from blowing up, so the strategies you build next get to focus on finding edge, not on staying alive.
To get there, you’ll wire up position limits, correlation, and Kelly in code. Close out the Data tier with a bot that cannot blow itself up.
Data tier · Reference cardHow do you stop a trading bot from blowing up?
Four layers, all in code: pre-trade limits, a correlation check, capped Kelly sizing, and a Monte Carlo stress test. Every order must pass a submit-time gate against three limits: gross exposure (example: at most 50% of NAV deployed), a per-market ceiling (example: 5% of NAV, so one resolution surprise can’t nuke you), and a drawdown kill that stops new orders past a threshold like 10% and stays off until a human re-arms it. A Pearson correlation matrix across your per-market return series catches ten positions that are really one bet. Size with half-Kelly under a hard cap instead of gut feel, and resample your fills 10,000 times to learn your 5th-percentile drawdown, so you size to the tail of the distribution, not the mean. Limits live in config, where strategy code can’t disable them.
Endpoints verified 2026-06-09 against the OpenAPI spec.
Section 01
Position & exposure limits.
Risk management isn’t a number you compute at quarter-end. It’s a wall your bot cannot cross at submit time. Every order must pass a pre-trade check against these three limits before it reaches the exchange.
Gross exposure
Sum of |position value| across every open market. Caps total capital deployed.
example: ≤ 50% of NAV
Per-market
Hard ceiling per individual market, one resolution surprise can’t nuke you.
example: ≤ 5% of NAV
Drawdown kill
Bot stops placing new orders when cumulative drawdown breaches a threshold, human review required.
example: ≥ 10% DD
Limits live in config, not in strategy code. A strategy should never be able to disable its own risk check, and a kill switch should require you to re-arm it.
Section 02
Correlation.
Ten positions look diversified right up until the moment you realise they’re all really one bet on the same macro variable. A correlation matrix across your returns catches this before the drawdown does.
// Module 14, Correlation matrix across a portfolio of positions.
// `returns` is: { marketSlug: number[] }, daily return series per market.
function mean(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0) / xs.length;
}
function pearson(a: number[], b: number[]): number {
const ma = mean(a), mb = mean(b);
let num = 0, da = 0, db = 0;
for (let i = 0; i < a.length; i++) {
const x = a[i] - ma, y = b[i] - mb;
num += x * y;
da += x * x;
db += y * y;
}
return num / Math.sqrt(da * db);
}
function correlationMatrix(returns: Record<string, number[]>): Record<string, Record<string, number>> {
const keys = Object.keys(returns);
const out: Record<string, Record<string, number>> = {};
for (const a of keys) {
out[a] = {};
for (const b of keys) {
out[a][b] = pearson(returns[a], returns[b]);
}
}
return out;
}
// TODO: load a real returns object
const corr = correlationMatrix({});
console.table(corr);
# Module 14, Correlation matrix across a portfolio of positions.
# `returns` is: {market_slug: list[float]}, daily return series per market.
import math
from typing import Mapping, Sequence
def mean(xs: Sequence[float]) -> float:
return sum(xs) / len(xs)
def pearson(a: Sequence[float], b: Sequence[float]) -> float:
ma, mb = mean(a), mean(b)
num = da = db = 0.0
for x, y in zip(a, b):
dx, dy = x - ma, y - mb
num += dx * dy
da += dx * dx
db += dy * dy
return num / math.sqrt(da * db)
def correlation_matrix(returns: Mapping[str, Sequence[float]]) -> dict[str, dict[str, float]]:
keys = list(returns)
return {
a: {b: pearson(returns[a], returns[b]) for b in keys}
for a in keys
}
# TODO: load a real returns dict
corr = correlation_matrix({})
for row, cols in corr.items():
print(row, cols)
// Module 14, Correlation matrix across a portfolio of positions.
// `returns` is map[string][]float64, daily return series per market.
package main
import (
"fmt"
"math"
)
func mean(xs []float64) float64 {
var s float64
for _, v := range xs {
s += v
}
return s / float64(len(xs))
}
func pearson(a, b []float64) float64 {
ma, mb := mean(a), mean(b)
var num, da, db float64
for i := range a {
x, y := a[i]-ma, b[i]-mb
num += x * y
da += x * x
db += y * y
}
return num / math.Sqrt(da*db)
}
func correlationMatrix(returns map[string][]float64) map[string]map[string]float64 {
out := map[string]map[string]float64{}
for a := range returns {
out[a] = map[string]float64{}
for b := range returns {
out[a][b] = pearson(returns[a], returns[b])
}
}
return out
}
func main() {
// TODO: load a real returns map
corr := correlationMatrix(map[string][]float64{})
fmt.Println(corr)
}
How to run this
- No env vars, this is pure analysis. Replace the TODO empty returns object with real data: for each market slug, build a daily return series from Module 11’s candle fetcher (close[i] / close[i-1] - 1) or from the equity deltas in Module 13’s /portfolio/pnl-chart response.
- Save the snippet above as correlation-matrix.ts, then run npx tsx correlation-matrix.ts.
- Save the snippet above as correlation_matrix.py, then run python correlation_matrix.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- Prints an N×N matrix of Pearson coefficients in [-1, 1]. Off-diagonal values above ~0.7 are your hidden clusters, positions you thought were independent are really one bet in disguise.
Section 03
Kelly criterion in code.
The Kelly fraction maximises long-run log growth. Full-Kelly is theoretically optimal and emotionally unbearable, nobody sleeps through a 50% drawdown on purpose. Ship half-Kelly with a hard cap, and let the maths do the rest.
How to run this
- No env vars, pure sizing math. Substitute real inputs: probability is your own model’s YES estimate, marketPrice comes from GET /markets/{slug}/orderbook, nav from your live portfolio value. Keep the cap at 5% until you have 200+ trades of out-of-sample evidence.
- Save the snippet above as compute-kelly.ts, then run npx tsx compute-kelly.ts.
- Save the snippet above as compute_kelly.py, then run python compute_kelly.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- Prints one line: Size $: 500.00 with the example inputs (p=0.62, price=0.55, NAV=10k, Kelly wants more than 5% so the cap binds). If you see Size $: 0.00, your edge went non-positive and the sizer correctly refused to trade.
// Module 14, Full, half, and capped Kelly sizing.
// For a binary market: edge = p·b − (1 − p), where b = payoff/stake.
interface KellyOpts {
probability: number; // your estimate of YES
marketPrice: number; // market's YES price in [0, 1]
nav: number; // account equity
cap?: number; // hard cap as fraction of NAV (default 5%)
}
export function fullKelly({ probability: p, marketPrice: price }: KellyOpts): number {
const b = (1 - price) / price; // odds received on YES
const f = (p * b - (1 - p)) / b; // Kelly fraction
return Math.max(0, f);
}
export function halfKelly(opts: KellyOpts): number {
return fullKelly(opts) * 0.5;
}
export function cappedKelly(opts: KellyOpts): number {
const cap = opts.cap ?? 0.05;
return Math.min(halfKelly(opts), cap);
}
export function kellyNotional(opts: KellyOpts): number {
return cappedKelly(opts) * opts.nav;
}
// Example
const size = kellyNotional({ probability: 0.62, marketPrice: 0.55, nav: 10_000 });
console.log('Size $:', size.toFixed(2));
# Module 14, Full, half, and capped Kelly sizing.
# For a binary market: edge = p·b − (1 − p), where b = payoff/stake.
from dataclasses import dataclass
@dataclass
class KellyOpts:
probability: float # your estimate of YES
market_price: float # market's YES price in [0, 1]
nav: float # account equity
cap: float = 0.05 # hard cap as fraction of NAV
def full_kelly(o: KellyOpts) -> float:
b = (1 - o.market_price) / o.market_price
f = (o.probability * b - (1 - o.probability)) / b
return max(0.0, f)
def half_kelly(o: KellyOpts) -> float:
return full_kelly(o) * 0.5
def capped_kelly(o: KellyOpts) -> float:
return min(half_kelly(o), o.cap)
def kelly_notional(o: KellyOpts) -> float:
return capped_kelly(o) * o.nav
# Example
size = kelly_notional(KellyOpts(probability=0.62, market_price=0.55, nav=10_000))
print(f"Size $: {size:.2f}")
// Module 14, Full, half, and capped Kelly sizing.
package main
import (
"fmt"
"math"
)
type KellyOpts struct {
Probability float64 // your estimate of YES
MarketPrice float64 // market's YES price in [0, 1]
NAV float64 // account equity
Cap float64 // hard cap as fraction of NAV
}
func FullKelly(o KellyOpts) float64 {
b := (1 - o.MarketPrice) / o.MarketPrice
f := (o.Probability*b - (1 - o.Probability)) / b
return math.Max(0, f)
}
func HalfKelly(o KellyOpts) float64 { return FullKelly(o) * 0.5 }
func CappedKelly(o KellyOpts) float64 {
cap := o.Cap
if cap == 0 {
cap = 0.05
}
return math.Min(HalfKelly(o), cap)
}
func KellyNotional(o KellyOpts) float64 { return CappedKelly(o) * o.NAV }
func main() {
size := KellyNotional(KellyOpts{
Probability: 0.62,
MarketPrice: 0.55,
NAV: 10_000,
})
fmt.Printf("Size $: %.2f\n", size)
}
Section 04
Stress testing.
One backtest run tells you what happened. A Monte Carlo over reshuffled fills tells you what could have happened. Resample the trade PnL distribution thousands of times, track the worst drawdown of each synthetic path, and report the 5th percentile, that’s your real tail risk.
// Module 14, Monte Carlo stress test.
// Resample your realised fills and take the worst drawdown of each path.
function shuffled<T>(xs: T[]): T[] {
const a = xs.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pathDrawdown(returns: number[]): number {
let cur = 1, peak = 1, mdd = 0;
for (const r of returns) {
cur *= 1 + r;
peak = Math.max(peak, cur);
mdd = Math.min(mdd, (cur - peak) / peak);
}
return mdd;
}
function monteCarlo(tradeReturns: number[], trials = 10_000): number[] {
const mdds: number[] = [];
for (let i = 0; i < trials; i++) {
mdds.push(pathDrawdown(shuffled(tradeReturns)));
}
mdds.sort((a, b) => a - b);
return mdds;
}
// TODO: pull the real trade return array from your backtest
const mdds = monteCarlo([], 10_000);
if (mdds.length) {
const p05 = mdds[Math.floor(mdds.length * 0.05)];
console.log('5th-percentile DD:', (p05 * 100).toFixed(2), '%');
}
# Module 14, Monte Carlo stress test.
# Resample your realised fills and take the worst drawdown of each path.
import random
from typing import Sequence
def path_drawdown(returns: Sequence[float]) -> float:
cur, peak, mdd = 1.0, 1.0, 0.0
for r in returns:
cur *= 1 + r
peak = max(peak, cur)
mdd = min(mdd, (cur - peak) / peak)
return mdd
def monte_carlo(trade_returns: Sequence[float], trials: int = 10_000) -> list[float]:
mdds: list[float] = []
xs = list(trade_returns)
for _ in range(trials):
random.shuffle(xs)
mdds.append(path_drawdown(xs))
mdds.sort()
return mdds
# TODO: pull the real trade return list from your backtest
mdds = monte_carlo([], trials=10_000)
if mdds:
p05 = mdds[int(len(mdds) * 0.05)]
print(f"5th-percentile DD: {p05 * 100:.2f} %")
// Module 14, Monte Carlo stress test.
// Resample your realised fills and take the worst drawdown of each path.
package main
import (
"fmt"
"math/rand"
"sort"
)
func pathDrawdown(returns []float64) float64 {
cur, peak, mdd := 1.0, 1.0, 0.0
for _, r := range returns {
cur *= 1 + r
if cur > peak {
peak = cur
}
if d := (cur - peak) / peak; d < mdd {
mdd = d
}
}
return mdd
}
func monteCarlo(tradeReturns []float64, trials int) []float64 {
xs := make([]float64, len(tradeReturns))
copy(xs, tradeReturns)
mdds := make([]float64, 0, trials)
for i := 0; i < trials; i++ {
rand.Shuffle(len(xs), func(a, b int) { xs[a], xs[b] = xs[b], xs[a] })
mdds = append(mdds, pathDrawdown(xs))
}
sort.Float64s(mdds)
return mdds
}
func main() {
// TODO: pull the real trade return slice from your backtest
mdds := monteCarlo([]float64{}, 10_000)
if len(mdds) > 0 {
p05 := mdds[int(float64(len(mdds))*0.05)]
fmt.Printf("5th-percentile DD: %.2f %%\n", p05*100)
}
}
How to run this
- No env vars, pure resampling. Replace the empty TODO array with per-trade returns from Module 12’s backtester, or compute them inline from /portfolio/trades by dividing each realised contribution by NAV at fill time. You need at least ~100 trades for the percentile to mean anything.
- Save the snippet above as monte-carlo.ts, then run npx tsx monte-carlo.ts.
- Save the snippet above as monte_carlo.py, then run python monte_carlo.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- Prints one line: 5th-percentile DD: -XX.XX %. That’s the drawdown you should be prepared to weather 1 in 20 times, if the number is scarier than your risk budget, halve your size. Empty input prints nothing because the guard correctly short-circuits.
Bot risk management: what people ask
Each answer also ships invisibly as schema.org FAQ data for search engines and AI assistants. Tap a question to expand.
-
What pre-trade risk limits should a bot enforce?
Three, checked on every order at submit time: gross exposure, the sum of absolute position value across all open markets (example cap: 50% of NAV); a per-market ceiling so one resolution surprise can’t take you out (example: 5% of NAV); and a drawdown kill that halts new orders when cumulative drawdown breaches a threshold (example: 10%) and requires human review to re-arm. Risk management is a wall the bot cannot cross, not a number you compute at quarter-end. -
How do you compute a Kelly position size in code?
For a binary market, the odds received on YES areb = (1 − price) / priceand the Kelly fraction is(p·b − (1 − p)) / b, floored at zero so a non-positive edge refuses to trade. Ship half that fraction under a hard cap, 5% of NAV by default, and multiply by NAV for the notional. Feed it your own model’s probability and the market price fromGET /markets/{slug}/orderbook, and keep the cap at 5% until you have 200+ trades of out-of-sample evidence. -
How do you detect hidden correlation across positions?
Build a Pearson correlation matrix over a daily return series per market and scan the off-diagonal: values above roughly 0.7 mark hidden clusters, positions you thought were independent that are really one bet in disguise. Build the return series from candle closes (close[i] / close[i-1] - 1) or from the equity deltas in the/portfolio/pnl-chartresponse. Ten positions look diversified right up until they all turn out to track the same macro variable. -
Why does portfolio correlation jump during a crash?
Correlation measured in a calm regime, say 0.2 across positions, can jump to 0.7+ under stress: everything moves together, the “diversified” book becomes one big bet, and the drawdown runs about 3× what the risk model promised. Compute correlation in conditional regimes, calm versus stress (stress = the top-decile market-wide volatility window), size for the stress number, and cap correlated-cluster exposure at 10% or less per cluster, where a cluster is any set of positions with stress-regime correlation above 0.5. -
What does a Monte Carlo stress test tell you?
What could have happened, not just what did. Reshuffle your realised per-trade returns thousands of times (10,000 trials is typical), record the worst drawdown of each synthetic path, and report the 5th percentile: the drawdown you should be prepared to weather roughly 1 in 20 times. If that number is scarier than your risk budget, halve your size. You need at least about 100 trades for the percentile to mean anything.
Section 05
Module checklist.
Tick each item once you’ve actually done it. The Continue button unlocks at 5/5.
My bot enforces gross exposure, per-market, and drawdown limits at submit time
I computed a correlation matrix across my portfolio and spotted hidden clusters
I size my positions with capped half-Kelly, not full-Kelly or gut feel
I ran a Monte Carlo stress test and know my 5th-percentile drawdown
My risk limits live in config and cannot be disabled by strategy code
Data tier complete
You’ve closed out the Data tier. The fastest way to put any of this into practice is on a live market, bring your fee rate, slippage calibration, and risk gate, and trade size you can defend.
Data tier complete
Risk gated.
Your bot cannot blow itself up. It sizes to the tail of the distribution instead of the mean, refuses correlated bets that look diversified on paper, and stops itself before a drawdown becomes a liquidation. That’s the floor a real trading career stands on.
Concretely, you can size positions correctly and stress-test your strategy before it ships. Here’s what you walk away with at the end of the Data & Backtesting tier:
A pre-trade risk gate enforcing gross exposure, per-market, and drawdown-kill limits from config, plus a correlation-matrix script that surfaces the hidden clusters your intuition misses.
A compute-kelly sizer with full, half, and capped variants, the only thing between a real edge and a blow-up is the cap, and it’s in your code, not your head.
A monte-carlo harness that resamples your fills 10,000 times and reports the 5th-percentile drawdown, so you size to the tail, not to the mean.
Without scrolling back, can you answer these?
Five questions across the Data tier. Click each to reveal, the test is whether you can answer first.
-
You can pull tick data or 1-minute bars. Why pull ticks when you can?
Tick data preserves the full execution audit, every fill, the bid/ask at the time, the spread you actually crossed. Bars summarise; they hide whether the close was on the bid or ask, whether liquidity was thin or thick. You can downsample tick to 1m later for speed, but you can’t reconstruct ticks from bars when an audit question shows up. -
Why must your backtest pay costs at every fill, even when the spread looks “tiny”?
Fees + spread + slippage sum to 5–30 bps per round trip on a typical CLOB market. Skip them and your backtest’s edge is mostly imaginary. A strategy showing 50 bps gross with 25 bps in real costs is profitable; ignored, the same strategy looks 2× better than reality. The moment live money starts trading, the gap reveals itself, usually right when you’ve sized up. -
A backtest reports Sharpe 4 over a year of data. What’s your first reaction?
Suspicious. A real Sharpe over 3 in retail markets is rare; 4 is so rare it’s almost always a bug, survivorship bias, look-ahead leakage, untested costs, or a curve fit to the in-sample window. Drop the strategy on a fresh out-of-sample slice with realistic costs before you believe it. If the number holds, you have something. If it collapses, you saved yourself a live test. -
Quarter-Kelly vs full-Kelly. Why almost always quarter, even when full looks better in the math?
Kelly assumes you know your edge perfectly. You don’t. Your edge estimate has variance, and full-Kelly on an over-estimated edge gives you a 50/50 shot at a 50% drawdown that you may not survive emotionally or mechanically (margin call, panic-flatten, regulator alert). Quarter-Kelly halves your growth rate but cuts the drawdown by ~75%, a survivable shape for an estimate that might be wrong. -
Your strategy shows Sharpe 1.6 in-sample but 0.4 out-of-sample. What probably happened, and what do you check first?
Overfitting. The in-sample number is what your strategy memorised, not what it learned. First check: parameter count vs sample size, if you tuned more knobs thansqrt(N_trades), you’re fitting noise. Then: did you re-tune on the OOS window? If yes, OOS is now IS and you have no holdout. The fix is fewer parameters and a fresh OOS slice, not more data.
Next up: the Strategies & Production tier, market making, arbitrage, signal-driven strategies, and the monitoring that keeps a real bot alive overnight.
Complete the checklist above to unlock