Stop Losses
Per-position exits on P&L, price, Greek, or time thresholds
Stop losses close positions automatically when they breach a threshold. Horizon provides four types, all declarative, all enforced by the watchdog that runs on every tick.
Types
StopLoss
from datetime import timedelta
from horizon.risk import StopLoss
stop = StopLoss(
per_position_pct=0.10, # 10% unrealized loss
per_position_usd=5_000, # or $5,000 absolute
trailing_pct=0.05, # trail 5% below peak unrealized
cooldown=timedelta(hours=1), # no re-entry for 1 hour
applies_to=[Equity],
exclude_markets=["SPY"],
)
per_position_pctfloat | Noneper_position_usdfloat | Nonetrailing_pctfloat | Nonecooldowntimedeltaapplies_tolist[AssetClass]exclude_marketslist[str]Multiple conditions
Set more than one field; whichever triggers first fires the stop:
stop = StopLoss(
per_position_pct=0.10, # 10% loss OR
per_position_usd=5_000, # $5,000 loss OR
trailing_pct=0.05, # 5% below peak
)
TrailingStop
Pure trailing, no fixed cut:
from horizon.risk import TrailingStop
stop = TrailingStop(
trailing_pct=0.05,
cooldown=timedelta(0),
)
When P&L hits a new peak, the stop level moves up to peak × (1 - 0.05). When P&L falls below that level, the stop fires.
GreekStop (options)
from horizon.risk import GreekStop
stop = GreekStop(
close_if_delta_over=0.70, # short call going deep ITM
close_if_gamma_over=0.08, # gamma explosion
close_if_vega_over=500, # vega exposure too large
close_if_dte_under=2, # close within 2 days of expiry
)
Fires when any threshold is breached. For short options with rising delta, this is the primary defensive mechanism: the position becomes increasingly correlated with the underlying and needs to be rolled or closed.
Attach via per_asset_class:
from horizon.risk import AssetRisk, RiskConfig
from horizon.asset_classes import Option
risk = RiskConfig(
per_asset_class={
Option: AssetRisk(stop_loss=GreekStop(close_if_delta_over=0.70)),
},
)
TimeStop
from horizon.risk import TimeStop
stop = TimeStop(
max_hold_time=timedelta(days=10),
close_before_earnings=True,
close_before_resolution=timedelta(hours=4),
)
max_hold_time: force-close after N days regardless of P&Lclose_before_earnings: close equity positions before the next earnings date (requires a calendar feed)close_before_resolution: close prediction-market positions N hours before resolution
How stops fire
- Watchdog detects breach
- Emits
LifecycleEvent(kind=StopLoss, market_id=...) - Run loop converts to
OrderAction.place(side=opposite, order_type="market", urgency=Immediate) - Pre-order check allows it (reducing is always allowed)
- Venue fills at market
Cooldown behavior
After a stop fires on market "AAPL":
self._stop_loss_cooldown["AAPL"] = state.now + stop.cooldown
Any subsequent order on "AAPL" is rejected until state.now > cooldown_until:
cooldown_until = self._stop_loss_cooldown.get(action.market_id)
if cooldown_until is not None and state.now < cooldown_until:
return Decision.reject(f"market in stop-loss cooldown until {cooldown_until}")
This prevents immediate re-entry, a “bounce back” into the losing position. Typical cooldown: 1 hour for intraday, 1 day for daily strategies.
Tests
# tests/test_risk_engine.py::TestStopLossWatchdog
def test_stop_hits_on_pct_loss(self) -> None:
cfg = RiskConfig(stop_loss=StopLoss(per_position_pct=0.10))
engine = RiskEngine(cfg)
ledger = PositionLedger()
ledger.apply_fill("AAPL", "buy", 10, 100.0)
ledger.mark("AAPL", 85.0) # 15% loss
events = engine.watchdog(_state(ledger=ledger))
stops = [e for e in events if e.kind == LifecycleEventKind.StopLoss]
assert len(stops) == 1
def test_stop_cooldown_blocks_reentry(self) -> None:
cfg = RiskConfig(
stop_loss=StopLoss(per_position_pct=0.10, cooldown=timedelta(hours=1))
)
engine = RiskEngine(cfg)
ledger = PositionLedger()
ledger.apply_fill("AAPL", "buy", 10, 100.0)
ledger.mark("AAPL", 80.0)
state = _state(ledger=ledger)
engine.watchdog(state)
# Attempt new order. blocked by cooldown
d = engine.check_order(_action(market="AAPL"), state)
assert not d.passed
assert "cooldown" in d.reason