Portfolio-Aware Strategies
Strategies that read ctx.portfolio to adapt to their own state
Normally Strategy.evaluate(f, universe) is pure: it depends only on features and universe. But some strategies need to read their own portfolio state: adapting to drawdown, avoiding correlation with existing positions, using current equity for sizing.
Horizon supports this via an optional third argument: ctx.
The pattern
from horizon import Strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import Zscore
class PortfolioAware(Strategy):
asset_classes = [Equity]
features = {"z": Zscore(window=20)}
def evaluate(self, f, universe, ctx): # 3 args, ctx last
# Read portfolio state
dd = ctx.portfolio.drawdown_pct
equity = ctx.portfolio.equity
# Drawdown-aware. pause on big drawdown
if dd > 0.05:
return []
# Scale size by recent performance (imaginary field. see state.mdx)
size_mult = 1.0 if dd < 0.02 else 0.5
return [
Signal.from_score(
m,
score=-f.z[m.id],
edge_per_stdev=20 * size_mult,
preferred_notional_usd=equity * 0.02 * size_mult,
)
for m in universe
if abs(f.z[m.id]) > 2
]
Auto-detection
The engine detects whether your evaluate wants ctx by introspecting its signature:
def evaluate(self, f, universe): # 2 args → ctx NOT injected
def evaluate(self, f, universe, ctx): # 3 args → ctx injected
No flag, no configuration. Just add the third parameter.
What’s on ctx.portfolio
See PortfolioState for the full list. Key fields:
equity: current total equitycash: current cash balancedrawdown_pct: current drawdown as decimalrealized_pnl_total: cumulative realized P&Lunrealized_pnl: current MTM P&L on open positionsgross_notional,net_notional,gross_leveragepositions: list of current LedgerPositionposition_by_market[market_id]: quick lookupn_positions: count of open positionsstrategy_pnl[name]: per-strategy P&L breakdown (when multiple strategies are running)
Use cases
if ctx.portfolio.drawdown_pct > 0.05:
return []
held_sectors = {p.market.sector for p in ctx.portfolio.positions}
if m.sector in held_sectors:
continue # skip
preferred_notional_usd=ctx.portfolio.equity * 0.02
my_pnl = ctx.portfolio.strategy_pnl.get(self.name, 0)
if my_pnl < -1000:
return []
Determinism
Reading ctx.portfolio doesn’t break determinism. The state is computed from the ledger, which is a deterministic function of the fills so far. Same seed → same ledger → same ctx.portfolio → same signals.