Why risk management matters
Algorithmic strategies live or die by how they size positions, cap losses, and survive volatility spikes. This guide shows practical Python patterns to control risk: volatility targeting, stop-losses, leverage caps, and drawdown kill switches—implemented in a minimal backtest you can extend.
Quickstart
- Define a risk budget (e.g., 10% annualized vol, 1.5× max leverage, 20% max drawdown).
- Choose simple entry/exit logic (e.g., trend via moving averages).
- Volatility-target your position size to meet the risk budget.
- Add execution realism: turnover-based costs and slippage.
- Add stop-loss and a drawdown kill switch.
- Evaluate: annual return, vol, Sharpe, max drawdown, turnover.
- Iterate: stress test, parameter sensitivity, and scenario analysis.
Minimal working example (single-asset, daily)
The example below simulates prices, trades a trend-following signal, and applies:
- Volatility targeting to meet a 10% annual vol budget
- Leverage cap (±1.5×)
- 5% trailing stop-loss
- 20% equity drawdown kill switch
- Turnover-based transaction costs
import numpy as np
import pandas as pd
def backtest(
n_days=1500,
seed=0,
mu_annual=0.08,
sigma_annual=0.20,
target_vol=0.10,
lookback_vol=20,
max_leverage=1.5,
stop_threshold=0.05, # 5% trailing stop
max_drawdown_kill=0.20,
cost_per_turnover=0.0002,
):
np.random.seed(seed)
ann_factor = 252
mu = mu_annual / ann_factor
sigma = sigma_annual / np.sqrt(ann_factor)
# Simulated daily returns and prices
rets = np.random.normal(mu, sigma, size=n_days)
price = 100 * np.exp(np.cumsum(rets))
idx = pd.date_range("2018-01-01", periods=n_days, freq="B")
df = pd.DataFrame({"price": price, "ret": rets}, index=idx)
# Simple signal: 50-day momentum (sign) with 1-day delay
mom = df.price.pct_change(50)
raw_signal = np.sign(mom).shift(1).fillna(0.0).to_numpy()
# Realized vol for scaling (annualized)
realized_vol = (
df.ret.rolling(lookback_vol).std() * np.sqrt(ann_factor)
).fillna(method="bfill").to_numpy()
# Arrays for simulation
pos = np.zeros(n_days)
daily_pnl = np.zeros(n_days)
equity = np.ones(n_days)
stopped = False
kill = False
peak = price[0]
trough = price[0]
highwater = 1.0
for t in range(1, n_days):
if kill:
pos[t] = 0.0
daily_pnl[t] = 0.0
equity[t] = equity[t - 1]
continue
# Volatility targeting (clip leverage)
vol = realized_vol[t]
desired = 0.0 if vol == 0 else raw_signal[t] * (target_vol / vol)
desired = float(np.clip(desired, -max_leverage, max_leverage))
# If previously stopped, wait for signal flip to re-enable
if stopped and raw_signal[t] == raw_signal[t - 1] and raw_signal[t] != 0:
desired = 0.0
# Apply trailing stop logic
prev_pos = pos[t - 1]
curr_pos = desired
# Opening a new position resets peaks/troughs
if prev_pos == 0.0 and curr_pos != 0.0:
peak = price[t]
trough = price[t]
stopped = False
# Update trailing levels
if prev_pos > 0:
peak = max(peak, price[t])
if price[t] < peak * (1 - stop_threshold):
curr_pos = 0.0
stopped = True
elif prev_pos < 0:
trough = min(trough, price[t])
if price[t] > trough * (1 + stop_threshold):
curr_pos = 0.0
stopped = True
# Transaction cost on turnover
turnover = abs(curr_pos - prev_pos)
daily_cost = cost_per_turnover * turnover
pos[t] = curr_pos
# PnL uses previous position exposure on today's return
strat_ret = prev_pos * df.ret.iloc[t] - daily_cost
daily_pnl[t] = strat_ret
equity[t] = equity[t - 1] * (1 + strat_ret)
# Drawdown kill switch
highwater = max(highwater, equity[t])
dd = (equity[t] / highwater) - 1
if dd < -max_drawdown_kill:
kill = True
pos[t] = 0.0
# Metrics
sr = daily_pnl.std()
ann_vol = float(sr * np.sqrt(ann_factor)) if sr > 0 else 0.0
mean = daily_pnl.mean()
ann_ret = float(mean * ann_factor)
sharpe = float((mean / sr) * np.sqrt(ann_factor)) if sr > 0 else 0.0
# Max drawdown
run_max = np.maximum.accumulate(equity)
dd = equity / run_max - 1
max_dd = float(dd.min())
avg_turnover = float(np.mean(np.abs(np.diff(pos))))
print("Annual return: %.2f%%" % (ann_ret * 100))
print("Annual vol: %.2f%%" % (ann_vol * 100))
print("Sharpe: %.2f" % sharpe)
print("Max drawdown: %.2f%%" % (max_dd * 100))
print("Avg daily turnover: %.4f" % avg_turnover)
return pd.DataFrame({"price": price, "pos": pos, "equity": equity}, index=idx)
if __name__ == "__main__":
backtest()
What to look for:
- Lower variance of equity if volatility targeting is effective
- Drawdown clamped near the kill switch threshold
- Realistic turnover and nonzero cost impact
Step-by-step implementation
- Set a risk budget
- Example: 10% annual vol, ±1.5× leverage, 5% trailing stop, 20% max drawdown.
- Build signal
- Keep it simple (trend, mean-reversion). Shift to avoid lookahead.
- Volatility targeting
- Weight = signal × target_vol / realized_vol, then clip to max leverage.
- Add stops
- Trailing or ATR-based; enforce a cool-off until signal flips.
- Add drawdown kill switch
- Flatten exposure after breaching portfolio-level drawdown.
- Model costs and slippage
- Use turnover × cost per unit turnover; stress test higher costs.
- Evaluate and iterate
- Track Sharpe, max drawdown, hit rate, exposure, turnover.
Common risk controls (what and why)
- Volatility targeting: stabilizes risk across regimes; prevents over-sizing in calm markets that later turn volatile.
- Leverage cap: hard bound on exposure; protects from vol estimation errors.
- Stop-loss (trailing or ATR): cuts tail risk and limits adverse excursions.
- Drawdown kill switch: protects portfolio survival; forces re-evaluation.
- Position/risk per trade: cap exposure per asset (e.g., ≤2% of equity at risk).
- Diversification limits: cap concentration and account for correlation spikes.
- Liquidity and size limits: cap notional/ADV and enforce participation caps.
Performance notes
- Vectorize indicator and volatility calculations with pandas/numpy; keep the state machine loop tight and on numpy arrays.
- Precompute rolling stats once; avoid redundant DataFrame operations inside loops.
- Use float32 where acceptable to reduce memory; profile with realistic horizons.
- For larger universes, batch operations, avoid Python loops per asset; consider numba for stateful logic.
- Cache signals and only update when the bar closes; avoid tick-level backtests unless necessary.
Pitfalls to avoid
- Lookahead bias: shift signals and use only past data for decisions.
- Survivorship bias: include delisted assets in historical tests when using real data.
- Overfitting: too many parameters or stop layers tuned to history.
- Ignoring correlation: portfolio risk can spike when assets move together.
- Underestimating costs: slippage and fees can erase edge; stress test.
- Volatility regime shifts: realized vol estimates can lag; use caps and decay.
Extensions
- Replace trailing stop with ATR-based stop: threshold = k × ATR.
- Add Value-at-Risk/Expected Shortfall caps to size positions under fat tails.
- Expand to multi-asset with risk-parity or volatility-bucket allocation.
- Add execution model: partial fills, delay, and market impact.
Tiny FAQ
- How do I pick target volatility?
- Start with a portfolio-level number you can stomach (e.g., 8–12% annual). Tune via backtests and stress tests.
- Vol targeting vs. leverage?
- Vol targeting determines desired leverage from recent volatility; a leverage cap bounds the result.
- Are stop-losses always helpful?
- They limit tail losses but can reduce returns in choppy markets. Test both on and off.
- Should I use Kelly sizing?
- Kelly is aggressive and sensitive to estimation error. Use a fraction (e.g., 0.25–0.5 Kelly) or prefer vol targeting.
- What metric best summarizes risk?
- Use several: max drawdown, Sharpe, volatility, and tail metrics (ES/VAR).