Drawdown Guards
Portfolio-wide halts based on rolling drawdown thresholds
Drawdown guards are the portfolio-level risk layer. They watch total equity vs running peak and fire when drawdown breaches a threshold.
Signature
from datetime import timedelta
from horizon.risk import DrawdownGuard
from horizon.risk.drawdown import DrawdownAction
guard = DrawdownGuard(
daily_pct=None, # or one of daily/weekly/monthly/total
weekly_pct=None,
monthly_pct=None,
total_pct=None,
from_peak=True, # measure from equity peak (default)
action=DrawdownAction.HaltNew,
recovery_threshold=0.02,
cooldown=timedelta(hours=24),
)
Exactly one of daily_pct, weekly_pct, monthly_pct, or total_pct must be set. Setting none or more than one raises ValueError.
Actions
class DrawdownAction(str, Enum):
HaltNew = "halt_new" # block new opens, allow reductions
ReduceHalf = "reduce_half" # cut every position by 50%
Flatten = "flatten" # close everything
Stacked guards
A classic setup uses three stacked guards with progressively stronger actions:
drawdown=[
DrawdownGuard(daily_pct=0.05, action=DrawdownAction.HaltNew),
DrawdownGuard(weekly_pct=0.10, action=DrawdownAction.ReduceHalf),
DrawdownGuard(monthly_pct=0.20, action=DrawdownAction.Flatten),
]
- 5% daily drawdown → stop taking new positions
- 10% weekly drawdown → cut the book in half
- 20% monthly drawdown → flatten everything
All three run simultaneously. The strongest active action wins.
How they work
def _check_drawdown(self, state: RiskEngineState) -> list[LifecycleEvent]:
events = []
dd_pct = state.drawdown_pct # (peak - equity) / peak
for guard in self.config.drawdown or []:
threshold = self._guard_threshold(guard) # extracts the single set threshold
guard_id = id(guard)
is_active = guard_id in self._active_guards
if dd_pct >= threshold and not is_active:
self._active_guards[guard_id] = state.now
events.append(LifecycleEvent(
kind=LifecycleEventKind.DrawdownGuardFire,
fires_at=state.now,
payload={"action": guard.action.value, ...},
))
elif dd_pct < threshold - guard.recovery_threshold and is_active:
del self._active_guards[guard_id]
events.append(LifecycleEvent(
kind=LifecycleEventKind.DrawdownGuardFire,
payload={"action": "recovered"},
))
return events
Key properties:
- Fires once per breach.
_active_guardstracks which guards are currently in the fired state - No double-fire: if a guard is already active and drawdown stays above threshold, no new event
- Recovery: when drawdown drops below
threshold - recovery_threshold, the guard deactivates
Recovery behavior
DrawdownGuard(
daily_pct=0.05,
recovery_threshold=0.02,
)
- Guard fires when drawdown first hits 5%
- Guard deactivates when drawdown falls back to 3% or better
- The 2% gap prevents oscillation around the threshold
from_peak vs period start
# from_peak=True (default)
dd = (running_max_equity - current_equity) / running_max_equity
# from_peak=False
dd = (start_of_period_equity - current_equity) / start_of_period_equity
from_peak=True is stricter: it measures from the highest equity ever reached, not from an arbitrary period start. Most practitioners use this.
Which threshold picks
def _guard_threshold(self, guard: DrawdownGuard) -> float | None:
for attr in ("daily_pct", "weekly_pct", "monthly_pct", "total_pct"):
val = getattr(guard, attr, None)
if val is not None:
return float(val)
return None
Currently all four field names point at the same drawdown value (state.drawdown_pct). Future a future release will distinguish them: daily_pct will compare against the daily drawdown, weekly_pct against the weekly, etc. For now, the labels are semantic hints for the user.
Interaction with pre-order checks
When a guard is active, the per-order check reads state.drawdown_halt_active:
if state.drawdown_halt_active == "halt_new":
if self._is_opening(action, state):
return Decision.reject("drawdown halt_new active")
elif state.drawdown_halt_active == "flatten":
return Decision.reject("drawdown flatten active")
halt_new: rejects opening orders, allows reductionsflatten: rejects everything except forced closes from the watchdog itself
Tests
# tests/test_risk_engine.py::TestDrawdownWatchdog
def test_fire_on_breach(self) -> None:
cfg = RiskConfig(drawdown=[DrawdownGuard(daily_pct=0.05, action=DrawdownAction.HaltNew)])
engine = RiskEngine(cfg)
state = _state(equity=94_000, peak=100_000) # 6% drawdown
events = engine.watchdog(state)
assert any(e.kind == LifecycleEventKind.DrawdownGuardFire for e in events)
def test_no_double_fire(self) -> None:
cfg = RiskConfig(drawdown=[DrawdownGuard(daily_pct=0.05)])
engine = RiskEngine(cfg)
state = _state(equity=94_000, peak=100_000)
events1 = engine.watchdog(state)
events2 = engine.watchdog(state)
assert any(e.kind == LifecycleEventKind.DrawdownGuardFire for e in events1)
# Already active. doesn't re-fire
assert not any(e.kind == LifecycleEventKind.DrawdownGuardFire for e in events2)