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 Per-position pct / USD loss + trailing + cooldown
TrailingStop Pure trailing stop (no fixed cut)
GreekStop Options-specific: fires on delta / gamma / vega / DTE thresholds
TimeStop Close after a max hold duration, or before earnings / resolution

StopLoss

python
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 | None
Unrealized loss as a fraction of cost basis. Close when breached.
per_position_usdfloat | None
Absolute USD loss threshold. Close when breached.
trailing_pctfloat | None
Trailing stop width as a fraction of peak unrealized P&L.
cooldowntimedelta
Time the market stays in cooldown after a stop fires. New orders on that market are rejected during the cooldown.
applies_tolist[AssetClass]
Which asset classes the stop applies to. Empty = all.
exclude_marketslist[str]
Specific markets to exclude from this stop.

Multiple conditions

Set more than one field; whichever triggers first fires the stop:

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

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

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

python
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

python
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&L
  • close_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

  1. Watchdog detects breach
  2. Emits LifecycleEvent(kind=StopLoss, market_id=...)
  3. Run loop converts to OrderAction.place(side=opposite, order_type="market", urgency=Immediate)
  4. Pre-order check allows it (reducing is always allowed)
  5. Venue fills at market

Cooldown behavior

After a stop fires on market "AAPL":

python
self._stop_loss_cooldown["AAPL"] = state.now + stop.cooldown

Any subsequent order on "AAPL" is rejected until state.now > cooldown_until:

python
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

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

Next