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

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

python
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 equity
  • cash: current cash balance
  • drawdown_pct: current drawdown as decimal
  • realized_pnl_total: cumulative realized P&L
  • unrealized_pnl: current MTM P&L on open positions
  • gross_notional, net_notional, gross_leverage
  • positions: list of current LedgerPosition
  • position_by_market[market_id]: quick lookup
  • n_positions: count of open positions
  • strategy_pnl[name]: per-strategy P&L breakdown (when multiple strategies are running)

Use cases

Drawdown pause Stop taking new entries when drawdown exceeds a threshold.
python
if ctx.portfolio.drawdown_pct > 0.05:
    return []
Correlation-aware entry Avoid opening positions that are highly correlated with existing ones.
python
held_sectors = {p.market.sector for p in ctx.portfolio.positions}
if m.sector in held_sectors:
    continue   # skip
Equity-relative sizing Size positions as a fraction of current equity. automatic compounding.
python
preferred_notional_usd=ctx.portfolio.equity * 0.02
Per-strategy performance gating Pause a strategy when its own P&L is poor (requires per-strategy attribution).
python
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.

Next