Code Along: Custom Risk Setup
Configure stop losses, drawdown guards, and circuit breakers step by step
Risk configuration is the difference between a strategy that blows up and one that survives. In this walkthrough you’ll build a risk config from scratch, adding layers one at a time and comparing the results at each stage.
The setup
We’ll use the same strategy, data, and universe across all runs. The only thing that changes is the risk config.
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticGBM
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.portfolio import KellyOptimizer
from horizon.quant import BollingerMeanRev, TSMomentum
from horizon.risk import (
DrawdownGuard,
RiskConfig,
RiskProfile,
StopLoss,
)
from horizon.risk.drawdown import DrawdownAction
TICKERS = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
UNIVERSE = StaticUniverse([
Market(id=t, asset_class=AssetClass.Equity) for t in TICKERS
])
DATA = SyntheticGBM(
market_ids=TICKERS, n_bars=504, mu=0.06, sigma=0.25, seed=314,
)
STRATEGIES = [
TSMomentum(lookback=20, edge_bps=60, horizon_days=5),
BollingerMeanRev(window=20, entry_z=2.0, edge_bps=50, horizon_days=3),
]
PORTFOLIO = KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.5)
504 bars (two years of daily data), 5 tickers, moderately volatile. A realistic-ish setup.
Stage 1: No risk protection
Start with a bare RiskConfig(). No stops, no drawdown guards, no limits.
result_bare = hz.run(
mode="backtest",
strategies=STRATEGIES,
asset_classes=[Equity],
universe=UNIVERSE,
portfolio=PORTFOLIO,
risk=RiskConfig(), # empty -- no protection
data_source=DATA,
backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)
print(f"[No risk] Sharpe={result_bare.sharpe:+.3f} "
f"MaxDD={result_bare.max_drawdown:.2%} "
f"Trades={result_bare.n_trades}")
With no protection, the strategy runs wild. During bad stretches it keeps trading into losses. You’ll see large drawdowns — often 30%+ on volatile synthetic data.
Stage 2: Add a stop loss
A stop loss closes a position when it drops below a threshold. This is the single most important risk control.
risk_stops = RiskConfig(
stop_loss=StopLoss(per_position_pct=0.05),
)
per_position_pct=0.05 means: if any position loses 5% from its entry price, close it. That’s it. No trailing, no fancy logic.
result_stops = hz.run(
mode="backtest",
strategies=STRATEGIES,
asset_classes=[Equity],
universe=UNIVERSE,
portfolio=PORTFOLIO,
risk=risk_stops,
data_source=DATA,
backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)
print(f"[Stops] Sharpe={result_stops.sharpe:+.3f} "
f"MaxDD={result_stops.max_drawdown:.2%} "
f"Trades={result_stops.n_trades}")
You’ll notice more trades (stops generate closing orders) and lower max drawdown. Sharpe might get worse because some stopped-out positions would have recovered. That’s the tradeoff.
Stage 3: Add drawdown guards
Stops protect individual positions. Drawdown guards protect the whole portfolio. They trigger when total portfolio drawdown exceeds a threshold.
risk_full = RiskConfig(
stop_loss=StopLoss(per_position_pct=0.05),
drawdown=[
DrawdownGuard(daily_pct=0.03, action=DrawdownAction.HaltNew),
DrawdownGuard(weekly_pct=0.08, action=DrawdownAction.ReduceHalf),
DrawdownGuard(monthly_pct=0.15, action=DrawdownAction.Flatten),
],
)
Three tiers, escalating severity:
- 3% daily drawdown —
HaltNew: stop opening new positions, but let existing ones ride. A cooling-off period. - 8% weekly drawdown —
ReduceHalf: cut all position sizes in half. Reduce exposure without fully exiting. - 15% monthly drawdown —
Flatten: close everything. Full stop. The circuit breaker.
result_full = hz.run(
mode="backtest",
strategies=STRATEGIES,
asset_classes=[Equity],
universe=UNIVERSE,
portfolio=PORTFOLIO,
risk=risk_full,
data_source=DATA,
backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)
print(f"[Full] Sharpe={result_full.sharpe:+.3f} "
f"MaxDD={result_full.max_drawdown:.2%} "
f"Trades={result_full.n_trades}")
Comparing the three stages
print("\n" + "=" * 60)
print("RISK LAYER COMPARISON")
print("=" * 60)
print(f"{'Config':15} {'Sharpe':>8} {'MaxDD':>8} {'Trades':>8}")
print("-" * 60)
print(f"{'No risk':15} {result_bare.sharpe:>+7.3f} {result_bare.max_drawdown:>7.2%} {result_bare.n_trades:>8}")
print(f"{'Stops only':15} {result_stops.sharpe:>+7.3f} {result_stops.max_drawdown:>7.2%} {result_stops.n_trades:>8}")
print(f"{'Stops + DD':15} {result_full.sharpe:>+7.3f} {result_full.max_drawdown:>7.2%} {result_full.n_trades:>8}")
The pattern you’ll see: max drawdown decreases with each layer. Sharpe may or may not improve — that depends on whether the risk events were followed by recovery (stops hurt) or further losses (stops help).
Shortcut: RiskProfile presets
Don’t want to configure every knob? Use the presets: RiskProfile.conservative(), RiskProfile.moderate(), or RiskProfile.aggressive(). Override individual fields as needed:
risk = RiskProfile.moderate().override(
stop_loss=StopLoss(per_position_pct=0.04, trailing_pct=0.03),
)
Strategy-level protection with ctx
Everything above is portfolio-level, automatic risk enforcement. But you can also add risk checks inside your strategy’s evaluate() method using the context object.
import math
from horizon import Signal, Strategy
from horizon.features import Zscore
class CarefulMeanReversion(Strategy):
name = "careful_mr"
asset_classes = [Equity]
features = {"z": Zscore(window=20)}
def evaluate(self, f, universe, ctx):
# Strategy-level drawdown check
if ctx.portfolio.drawdown_pct > 0.03:
return [] # stop trading when portfolio is down 3%
signals = []
for m in universe:
z = f.z[m.id]
if math.isnan(z) or abs(z) < 2.0:
continue
# Check existing exposure before adding more
position = ctx.portfolio.position(m.id)
if position and abs(position.notional_usd) > 20_000:
continue # already have a big position here
signals.append(Signal.from_score(
market=m,
score=-z,
edge_per_stdev=40,
horizon="3d",
reason=f"z={z:.2f}",
))
return signals
The two layers work together:
- RiskConfig (portfolio-level): automatic, always-on, catches everything. Stops fire, drawdown guards trigger, leverage limits enforce.
- ctx checks (strategy-level): custom, per-strategy logic. Pause when the portfolio is stressed, skip markets where you already have big positions, scale down in high-vol environments.
The RiskConfig is the safety net. The ctx checks are the judgment calls.
Full file
# codealong_risk_layers.py
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticGBM
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.portfolio import KellyOptimizer
from horizon.quant import BollingerMeanRev, TSMomentum
from horizon.risk import DrawdownGuard, RiskConfig, StopLoss
from horizon.risk.drawdown import DrawdownAction
TICKERS = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
UNIVERSE = StaticUniverse([Market(id=t, asset_class=AssetClass.Equity) for t in TICKERS])
DATA = SyntheticGBM(market_ids=TICKERS, n_bars=504, mu=0.06, sigma=0.25, seed=314)
STRATEGIES = [TSMomentum(lookback=20, edge_bps=60, horizon_days=5),
BollingerMeanRev(window=20, entry_z=2.0, edge_bps=50, horizon_days=3)]
PORTFOLIO = KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.5)
def run_with_risk(label, risk):
r = hz.run(mode="backtest", strategies=STRATEGIES, asset_classes=[Equity],
universe=UNIVERSE, portfolio=PORTFOLIO, risk=risk,
data_source=DATA, backtest=hz.BacktestConfig(initial_cash_usd=100_000))
print(f"[{label:12s}] Sharpe={r.sharpe:+.3f} MaxDD={r.max_drawdown:.2%} Trades={r.n_trades}")
return r
def main():
r1 = run_with_risk("No risk", RiskConfig())
r2 = run_with_risk("Stops only", RiskConfig(stop_loss=StopLoss(per_position_pct=0.05)))
r3 = run_with_risk("Stops + DD", RiskConfig(
stop_loss=StopLoss(per_position_pct=0.05),
drawdown=[DrawdownGuard(daily_pct=0.03, action=DrawdownAction.HaltNew),
DrawdownGuard(weekly_pct=0.08, action=DrawdownAction.ReduceHalf),
DrawdownGuard(monthly_pct=0.15, action=DrawdownAction.Flatten)],
))
assert r3.max_drawdown <= r1.max_drawdown, "Risk layers should reduce max drawdown"
print("Risk enforcement verified.")
if __name__ == "__main__":
main()
Run it
PYTHONPATH=. python3 codealong_risk_layers.py