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:
- Checks drawdown from peak - at 15%+ it flattens everything
- Scales position size down during moderate drawdown (3-8%: half, 8-15%: quarter)
- Tracks 20-tick equity momentum - if equity is falling, it reduces further
- 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