Code Along: Adaptive Portfolio

A sizer that reads its own performance and adjusts allocation dynamically

Most sizers are static: they see signals, they allocate. This example builds a sizer that reads its own portfolio state - equity, drawdown, recent performance - and adapts on the fly.

What we’re building

A sizer that:

  • Sizes normally when things are going well
  • Cuts size in half during drawdown
  • Stops opening new positions when on a losing streak
  • Scales up when recent Sharpe is strong

Step 1: The adaptive sizer

python
import horizon as hz
from horizon.types import TargetPosition, Direction

class AdaptiveSizer:
    name = "adaptive"

    def __init__(self, base_fraction=0.05, max_per_position_pct=0.10):
        self.base_fraction = base_fraction
        self.max_pct = max_per_position_pct
        # Internal state: tracks portfolio health
        self._recent_equity = []
        self._peak = 0.0

    def optimize(self, signals, current_positions, cash, cov, constraints):
        if not signals:
            return []

        equity = cash.total_equity_usd
        self._peak = max(self._peak, equity)

        # --- Regime detection from portfolio state ---

        # 1. Drawdown check
        drawdown = (self._peak - equity) / self._peak if self._peak > 0 else 0
        if drawdown > 0.15:
            # Deep drawdown: close everything, no new positions
            return [
                TargetPosition(market_id=sig.market_id, target_notional_usd=0.0)
                for sig in signals
            ]

        # 2. Scale factor based on drawdown
        if drawdown > 0.08:
            scale = 0.25       # quarter size
        elif drawdown > 0.03:
            scale = 0.5        # half size
        else:
            scale = 1.0        # full size

        # 3. Track equity history for momentum
        self._recent_equity.append(equity)
        if len(self._recent_equity) > 30:
            self._recent_equity = self._recent_equity[-30:]

        # 4. If equity is trending down over last 20 ticks, reduce further
        if len(self._recent_equity) >= 20:
            recent_return = (self._recent_equity[-1] / self._recent_equity[-20]) - 1
            if recent_return < -0.02:
                scale *= 0.5  # halve again if losing 2%+ over 20 ticks

        # --- Size each signal ---
        targets = []
        for sig in signals:
            if sig.direction == Direction.Flatten:
                targets.append(TargetPosition(market_id=sig.market_id, target_notional_usd=0.0))
                continue

            # Base size: fraction of equity, scaled by confidence
            notional = equity * self.base_fraction * sig.confidence * scale * sig.direction.sign

            # Cap per position
            cap = equity * self.max_pct
            notional = max(-cap, min(notional, cap))

            targets.append(TargetPosition(
                market_id=sig.market_id,
                target_notional_usd=notional,
                urgency=sig.urgency,
            ))

        return targets

What’s happening here

The sizer remembers previous equity values (via self._recent_equity). Each tick it:

  1. Checks drawdown from peak - at 15%+ it flattens everything
  2. Scales position size down during moderate drawdown (3-8%: half, 8-15%: quarter)
  3. Tracks 20-tick equity momentum - if equity is falling, it reduces further
  4. Still respects signal direction, confidence, and per-position caps

This is different from the risk engine’s drawdown guards. The risk engine blocks orders after the fact. This sizer reduces order size proactively before they even reach risk checks. Both work together.

Step 2: Run it

python
from horizon import Strategy, Signal
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticRegimes
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.features import Zscore, RealizedVol
from horizon.risk import RiskProfile
import math

class SimpleZScore(Strategy):
    name = "zscore_mr"
    asset_classes = [Equity]
    features = {"z": Zscore(window=20), "vol": RealizedVol(window=20)}

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            z, vol = f.z[m.id], f.vol[m.id]
            if math.isnan(z) or math.isnan(vol):
                continue
            if abs(z) > 2.0:
                signals.append(Signal.from_score(
                    m, score=-z, edge_per_stdev=20,
                    horizon="3d", expected_expected_vol_bps=max(vol * 100, 50),
                ))
        return signals

tickers = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
universe = StaticUniverse([Market(id=t, asset_class=AssetClass.Equity) for t in tickers])

# Use regime data so the sizer actually encounters drawdowns
data = SyntheticRegimes(
    market_ids=tickers, n_bars=500,
    regimes=[(0.4, 0.15, 0.12), (0.3, 0.0, 0.25), (0.3, -0.25, 0.40)],
    seed=7,
)

result = hz.run(
    mode="backtest",
    strategies=[SimpleZScore()],
    asset_classes=[Equity],
    universe=universe,
    portfolio=AdaptiveSizer(base_fraction=0.05, max_per_position_pct=0.10),
    risk=RiskProfile.moderate(),
    data_source=data,
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)

print(f"Sharpe:  {result.sharpe:+.3f}")
print(f"Return:  {result.total_return:+.2%}")
print(f"Max DD:  {result.max_drawdown:.2%}")
print(f"Trades:  {result.n_trades}")

Compare this against the same strategy with KellyOptimizer or EqualWeight to see how adaptive sizing changes drawdown behavior.

When to use this pattern

  • You want the sizer itself to be risk-aware (not just the risk engine)
  • You’re trading through volatile regimes and want automatic position reduction
  • You want “conviction scaling” - bet bigger when you’re winning, smaller when you’re losing

Next