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

python
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

python
class DrawdownAction(str, Enum):
    HaltNew    = "halt_new"         # block new opens, allow reductions
    ReduceHalf = "reduce_half"      # cut every position by 50%
    Flatten    = "flatten"          # close everything
HaltNew **Least aggressive.** Stops new position opens. Existing positions keep trading; their stops still apply.
ReduceHalf **Middle.** Cuts every position's size by 50%. Good for de-risking without fully exiting.
Flatten **Most aggressive.** Closes the entire book. Only use for extreme drawdowns.

Stacked guards

A classic setup uses three stacked guards with progressively stronger actions:

python
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

python
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_guards tracks 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

python
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

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

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

python
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 reductions
  • flatten: rejects everything except forced closes from the watchdog itself

Tests

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

Next