Lifecycle Manager
The slow clock: scheduled events and watchdogs
Trading systems need to handle events that don’t fit the tick loop:
- Options expire on specific dates
- Prediction markets resolve at fixed times
- Positions need rolling before expiry
- Stop losses can fire between ticks
- Margin watchdog runs continuously
- Portfolios need scheduled rebalancing
These all live in the Lifecycle Manager: the slow clock alongside the fast tick loop.
Two clocks
Fast clock (every tick) Slow clock (event-driven + schedule)
───────────────────── ──────────────────────────────────
Data → Features → Signal ←→ Lifecycle Manager
→ Portfolio → Execution - Scheduled: expiry, resolution, rebalance
→ Risk → Venue → Fills - Watchdog: stops, drawdown, margin
- Triggered: kill switch, stop events
The two clocks share state (the ledger, the signal store) but run on their own timelines. The tick loop doesn’t wait for the watchdog; the watchdog doesn’t wait for the tick.
LifecycleEvent
Events are discriminated-union-style dataclasses:
from horizon.lifecycle import LifecycleEvent, LifecycleEventKind
event = LifecycleEvent(
kind=LifecycleEventKind.StopLoss,
market_id="AAPL",
fires_at=now,
payload={"position_qty": 10, "last_price": 95.0},
reason="stop loss breached",
)
Event kinds
| Kind | Fired by | Handled by |
|---|---|---|
StopLoss | Watchdog | Emit forced-close market order |
DrawdownGuardFire | Watchdog | Set active drawdown action |
MarginWarning / MarginReduce / MarginEmergency | Watchdog | Alert / reduce / flatten |
KillSwitchFire | Watchdog | Flatten + halt |
OptionExpiry | Scheduler | Settle contract (Phase 6 follow-up) |
PredictionResolution | Scheduler | Credit cash on 0/1 payout (Phase 6 follow-up) |
Rebalance | Scheduler | Re-run portfolio optimizer |
OptionRoll | Scheduler + watchdog | Close current, open new |
DividendAccrual | Scheduler | Credit cash |
FundingSettlement | Scheduler | Perp funding P&L |
Compounding | Scheduler | Equity-relative sizing update |
LifecycleConfig
User-facing configuration for the manager:
from datetime import timedelta
from horizon.lifecycle import LifecycleConfig
lifecycle = LifecycleConfig(
rebalance_schedule="weekly", # daily | weekly | monthly | never
option_roll_dte=7, # roll options with < 7 DTE
option_roll_delta=0.5, # roll when delta crosses 0.5
option_max_abs_delta=0.8, # force close if |delta| > 0.8
auto_close_before_resolution=timedelta(hours=4),
compound_profits=True,
)
Watchdog integration
The watchdog is the part of the lifecycle manager that runs on every tick (not on a schedule). The backtest loop calls it after portfolio optimization but before execution:
# run.py:
watchdog_events = risk_engine.watchdog(risk_state)
for event in watchdog_events:
if event.kind.value == "stop_loss":
# Forced close
pos = ledger.position(event.market_id)
forced_close_actions.append(
OrderAction.place(
market_id=event.market_id,
side="sell" if pos.quantity > 0 else "buy",
quantity=abs(pos.quantity),
order_type="market",
urgency=Urgency.Immediate,
)
)
Stop-loss events become market orders added to the planned-actions queue. They go through the normal pre-order risk pipeline (which always passes them because the kill switch isn’t engaged and they’re reducing, not opening).
Drawdown guards as lifecycle events
A drawdown breach doesn’t directly place orders; it sets a mode on the engine that the pre-order checker reads:
# Risk engine:
if dd_pct >= threshold and not is_active:
self._active_guards[guard_id] = state.now
events.append(LifecycleEvent(
kind=LifecycleEventKind.DrawdownGuardFire,
payload={"action": guard.action.value, ...},
))
Then in check_order:
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")
New opens get blocked; reduces are allowed.
Scheduled events
The manager holds a priority queue of future events:
from horizon.lifecycle import LifecycleManager
lcm = LifecycleManager(config=lifecycle_config)
# Schedule a specific event
lcm.schedule_option_expiry(
market_id="AAPL250117C00180000",
fires_at=datetime(2025, 1, 17, 16, 0),
)
# Per tick, advance the clock
events = lcm.advance_clock_to(now, state)
for event in events:
lcm.handle(event, state)
When the loop advances past the scheduled time, the event is popped and its handler runs.
Handler registration
Custom handlers:
def my_handler(event: LifecycleEvent, state) -> list:
# Do something, return follow-up OrderActions
return []
lcm.register_handler(LifecycleEventKind.OptionRoll, my_handler)
The built-in handlers for stop losses and drawdown guards live in the run loop itself; custom handlers can override them or add new behavior.
Backtest parity
A critical promise: the lifecycle manager runs in backtest mode exactly as it does in live.
- Scheduled events in backtest fire when simulated time passes them (priority queue)
- Watchdogs run on every tick in both modes
- Stop losses fire at the tick price that breached the threshold, not at some averaged price
This means: if your backtest shows the strategy got stopped out at 12:45 on day 37, that’s when it would have been stopped out in live trading with the same data.
What’s real today
| Component | Status |
|---|---|
| Scheduler + priority queue | ✅ Real |
| Stop-loss watchdog | ✅ Real, fires in backtest |
| Drawdown-guard watchdog | ✅ Real, sets halt mode |
| Margin watchdog | ✅ Real (requires buying-power snapshot) |
| Kill switch auto-trigger | ✅ Real |
| Option expiry handler | ⏸️ Scaffold. awaits options module integration |
| Prediction resolution handler | ⏸️ Scaffold. awaits prediction module integration |
| Rebalance scheduler | ⏸️ Scaffold. infrastructure ready, handler placeholder |
| Dividend / funding / compounding | ⏸️ Scaffolds |
The infrastructure is stable; the remaining pieces are fill-in work following the phase plan.