Strict Risk Configuration

Tight stops, stacked drawdown guards, proof that risk enforcement works

The clearest demonstration that Horizon’s risk layer actively bounds losses: run the same strategy twice, once with loose risk and once with strict risk, and compare max drawdown.

The file

python
# strict_risk.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,
    OrderRisk,
    RiskConfig,
    StopLoss,
)
from horizon.risk.drawdown import DrawdownAction


SEED = 20250412
BASE_STRATEGIES = [
    BollingerMeanRev(window=20, entry_z=2.0, edge_bps=60, horizon_days=3),
    TSMomentum(lookback=20, edge_bps=50, horizon_days=5),
]

UNIVERSE = StaticUniverse([
    Market(id=t, asset_class=AssetClass.Equity)
    for t in ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
])

DATA = SyntheticGBM(
    market_ids=["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"],
    n_bars=504,
    mu=0.08,
    sigma=0.22,
    seed=SEED,
)


def run_loose():
    risk = RiskConfig(
        per_order=OrderRisk(max_order_notional_usd=100_000),
        max_gross_leverage=2.0,
    )
    return hz.run(
        mode="backtest",
        strategies=BASE_STRATEGIES,
        asset_classes=[Equity],
        universe=UNIVERSE,
        portfolio=KellyOptimizer(kelly_fraction=0.30, max_gross_leverage=2.0),
        risk=risk,
        data_source=DATA,
        backtest=hz.BacktestConfig(initial_cash_usd=100_000),
    )


def run_strict():
    risk = RiskConfig(
        per_order=OrderRisk(max_order_notional_usd=25_000),
        max_gross_leverage=1.0,
        stop_loss=StopLoss(per_position_pct=0.04, trailing_pct=0.03),
        drawdown=[
            DrawdownGuard(daily_pct=0.03, action=DrawdownAction.HaltNew),
            DrawdownGuard(weekly_pct=0.06, action=DrawdownAction.ReduceHalf),
            DrawdownGuard(monthly_pct=0.12, action=DrawdownAction.Flatten),
        ],
    )
    return hz.run(
        mode="backtest",
        strategies=BASE_STRATEGIES,
        asset_classes=[Equity],
        universe=UNIVERSE,
        portfolio=KellyOptimizer(kelly_fraction=0.20, max_gross_leverage=1.0),
        risk=risk,
        data_source=DATA,
        backtest=hz.BacktestConfig(initial_cash_usd=100_000),
    )


def main():
    loose = run_loose()
    strict = run_strict()

    print("=" * 60)
    print("STRICT vs LOOSE RISK. same strategies, same data")
    print("=" * 60)
    print(f"{'':20} {'Loose':>15} {'Strict':>15}")
    print("-" * 60)
    print(f"{'Trades':20} {loose.n_trades:>15} {strict.n_trades:>15}")
    print(f"{'Total return':20} {loose.total_return:>14.2%} {strict.total_return:>14.2%}")
    print(f"{'Sharpe':20} {loose.sharpe:>14.3f} {strict.sharpe:>14.3f}")
    print(f"{'Max drawdown':20} {loose.max_drawdown:>14.2%} {strict.max_drawdown:>14.2%}")

    assert strict.max_drawdown < loose.max_drawdown, (
        "Strict risk did NOT reduce max drawdown. risk layer is broken"
    )
    reduction_pct = (loose.max_drawdown - strict.max_drawdown) / loose.max_drawdown
    print()
    print(f"✓ Strict config reduced max DD by {reduction_pct:.1%}")


if __name__ == "__main__":
    main()

Expected output

============================================================
STRICT vs LOOSE RISK. same strategies, same data
============================================================
                          Loose          Strict
------------------------------------------------------------
Trades                     1464             777
Total return             -35.94%         -34.73%
Sharpe                    -0.458          -3.848
Max drawdown               53.48%          34.73%

✓ Strict config reduced max DD by 35.1%

(Numbers may vary slightly; the key invariant is strict.max_drawdown < loose.max_drawdown.)

Why the strict config wins on drawdown

Smaller positions

kelly_fraction=0.20 and max_gross_leverage=1.0 instead of 0.30 and 2.0. Every position is smaller, so every loss is smaller.

Tighter stops

4% per-position stop vs default. Losing trades are cut sooner.

Stacked drawdown guards

At 3% daily DD, new entries stop. At 6% weekly, positions halve. At 12% monthly, the book flattens.

Smaller per-order notional

$25k max per order vs $100k. Prevents a single bad trade from cratering the account.

The assertion

python
assert strict.max_drawdown < loose.max_drawdown

This is the invariant: stricter risk config must produce a lower max drawdown on the same data. If it doesn’t, the risk engine has a bug. This is tested explicitly in tests/test_behavioral_audit.py.

Why Sharpe gets worse

Stricter risk often lowers Sharpe because:

  • Stops close winning trades that would have recovered
  • Drawdown guards halt entries during productive periods
  • Smaller sizes mean smaller absolute gains

This is the risk/return tradeoff in action. Strict risk gives you lower drawdown and lower Sharpe. Loose risk gives you higher Sharpe and higher drawdown. Pick based on your risk tolerance.

For production, the typical answer is: use moderate risk, not either extreme. RiskProfile.moderate() is a reasonable default.

Run it

bash
PYTHONPATH=. python3 strict_risk.py

Next