Cross-cutting

The Risk Layer

Four small, pure components — a sizer, a vol-targeter, an exposure gatekeeper, and a kill-switch — composed inside every strategy and consulted on every bar. Signal never reaches the broker without passing through them.

In plain terms

Even a good idea can blow up if you bet too big or too often. The risk layer decides how much to trade, refuses trades that would over-concentrate the book, and pulls a master switch that flattens everything if losses get too deep.

Technically

Each Strategy owns one PositionSizer, RiskGatekeeper, and KillSwitch (momentum also a vol_target). They are plain Python, unit-tested in isolation — there is no separate Nautilus "risk actor"; the layer is composed into the strategy so backtest and live behave identically.

Four guards

ComponentRoleKey callDefault limits
PositionSizerfixed-fractional size from the stop distancecalculate(equity, entry, stop)risk 1% / trade
vol_targetscale size toward a target volatilityvol_scale(realized, target)0.25×–2.0×
RiskGatekeeperhard exposure / concentration / loss capscheck_order(...)gross 80% · net 60% · conc 25%
KillSwitchportfolio drawdown halt (latches)update_equity(equity)25% drawdown

The order path

When a strategy decides to enter, sizing and risk run in a fixed order: size from the stop, scale by volatility, cap by concentration and budget, then submit to the gatekeeper. Only an allowed check reaches the OMS.

sequenceDiagram autonumber participant S as _enter_long participant P as PositionSizer participant V as VolTarget participant G as RiskGatekeeper participant O as OMS S->>S: entry = close, stop = max(entry*(1-stop_pct), 0.01) S->>P: calculate(equity, entry, stop) P-->>S: qty (fixed-fractional) S->>V: vol_scale(realized_vol, target_vol) V-->>S: scale in [min, max] S->>S: cap by concentration and net-exposure budget S->>G: check_order(equity, notional, gross, net, instr_exp, trade_risk) alt allowed G-->>S: RiskCheck(allowed=true) S->>O: submit_order(MARKET BUY, qty) else blocked G-->>S: RiskCheck(allowed=false, reason) S->>S: log and skip end
The _enter_long order path. The gatekeeper is a hard backstop wider than the sizer's own budget — belt and suspenders. A blocked check is logged and the order is skipped, never forced.
src/risk/position_sizer.py & gatekeeper.py
# Fixed-fractional: risk a fixed % of equity on the distance to the stop
qty = int( equity * risk_per_trade / (entry - stop) )

# Gatekeeper caps checked in order (all must pass):
#   trade_risk / equity  <=  max_loss_per_trade   (2%)
#   |gross|              <=  max_gross_exposure   (80%)
#   |net|                <=  max_net_exposure     (60%)
#   instrument           <=  max_concentration    (25%)

The kill-switch

Every bar updates the high-water mark and current drawdown. Cross the threshold and the switch latches — it flattens the book and stays triggered until a human resets it. In live trading its state is persisted atomically across restarts.

stateDiagram-v2 [*] --> Normal Normal --> Normal: update_equity (dd under max) Normal --> Triggered: drawdown >= max_drawdown Triggered --> Triggered: latched (no auto-resume) Triggered --> Normal: reset() by human note right of Triggered flatten_all: cancel + close state persisted atomically (live) end note
Kill-switch lifecycle. It fails closed: if the persisted state file is unreadable at startup it assumes triggered. Backtests leave the state path None (clean, in-memory per run).
Every strategy defines a non-null stop

Hard Rule 5: a strategy may not enter without a defined stop, and the risk layer is obeyed before any signal. The per-bar ordering is always kill-switch → manage open position (hard stop first) → then evaluate a new entry.

References