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.

python
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.

python
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.

python
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.

python
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.

python
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:

  1. 3% daily drawdownHaltNew: stop opening new positions, but let existing ones ride. A cooling-off period.
  2. 8% weekly drawdownReduceHalf: cut all position sizes in half. Reduce exposure without fully exiting.
  3. 15% monthly drawdownFlatten: close everything. Full stop. The circuit breaker.
python
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

python
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:

python
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.

python
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

python
# 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

bash
PYTHONPATH=. python3 codealong_risk_layers.py

Next