Code Along: Portfolio Control
Compare sizers, set constraints, and control how signals become positions
Your strategy returns signals. The portfolio sizer decides how much capital each signal gets. This tutorial shows you all three sizers side by side, how to set constraints, and how signal quality affects sizing.
Setup
We’ll use the same strategy and data for all three sizers so the only variable is the sizer itself.
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticGBM
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.quant import BollingerMeanRev
from horizon.portfolio import EqualWeight, KellyOptimizer, CarverSystematic
from horizon.risk import RiskProfile
tickers = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
universe = StaticUniverse([Market(id=t, asset_class=AssetClass.Equity) for t in tickers])
data_source = SyntheticGBM(tickers, n_bars=252, seed=42)
strategy = BollingerMeanRev(window=20, entry_z=2.0)
risk = RiskProfile.moderate()
backtest = hz.BacktestConfig(initial_cash_usd=100_000)
Sizer 1: EqualWeight
The simplest sizer. Every signal gets the same dollar allocation regardless of edge or confidence.
result_ew = hz.run(
mode="backtest",
strategies=[strategy],
asset_classes=[Equity],
universe=universe,
portfolio=EqualWeight(max_positions=10),
risk=risk,
data_source=data_source,
backtest=backtest,
)
print(f"EqualWeight - Sharpe: {result_ew.sharpe:+.3f}, DD: {result_ew.max_drawdown:.2%}, Trades: {result_ew.n_trades}")
EqualWeight ignores your signal’s edge_bps and vol_bps. It just divides capital evenly. Use it as a baseline: if Kelly can’t beat EqualWeight, your edge estimates aren’t adding value.
Sizer 2: KellyOptimizer
Sizes each position using f* = edge / vol², scaled by kelly_fraction and confidence.
result_kelly = hz.run(
mode="backtest",
strategies=[strategy],
asset_classes=[Equity],
universe=universe,
portfolio=KellyOptimizer(
kelly_fraction=0.25, # quarter Kelly (safer)
max_gross_leverage=1.0, # no leverage
transaction_cost_bps=5.0, # subtract 5 bps from edge before sizing
),
risk=risk,
data_source=data_source,
backtest=backtest,
)
print(f"Kelly - Sharpe: {result_kelly.sharpe:+.3f}, DD: {result_kelly.max_drawdown:.2%}, Trades: {result_kelly.n_trades}")
Kelly gives bigger positions to signals with high edge and low vol. A signal with edge_bps=100, expected_vol_bps=50 gets 4× the allocation of one with edge_bps=50, expected_vol_bps=100.
Sizer 3: CarverSystematic
Vol-targets the portfolio to a specific annual volatility. Uses Carver’s forecast scaling framework.
result_carver = hz.run(
mode="backtest",
strategies=[strategy],
asset_classes=[Equity],
universe=universe,
portfolio=CarverSystematic(
annual_vol_target=0.15, # target 15% annual portfolio vol
buffering=True, # don't rebalance unless delta > 10% of target
),
risk=risk,
data_source=data_source,
backtest=backtest,
)
print(f"Carver - Sharpe: {result_carver.sharpe:+.3f}, DD: {result_carver.max_drawdown:.2%}, Trades: {result_carver.n_trades}")
Carver typically trades less than Kelly because of buffering (it won’t rebalance for small changes). It also normalizes position sizes by instrument volatility, so a volatile stock gets fewer shares than a stable one.
Compare results
print(f"\n{'Sizer':<15} {'Sharpe':>8} {'Max DD':>8} {'Trades':>8} {'Return':>10}")
print("-" * 52)
for name, r in [("EqualWeight", result_ew), ("Kelly", result_kelly), ("Carver", result_carver)]:
print(f"{name:<15} {r.sharpe:>+8.3f} {r.max_drawdown:>8.2%} {r.n_trades:>8} {r.total_return:>+10.2%}")
Adding constraints
Regardless of which sizer you use, you can add portfolio-wide constraints:
from horizon.portfolio import PortfolioConstraints
result = hz.run(
strategies=[strategy],
portfolio=KellyOptimizer(kelly_fraction=0.25),
# constraints are applied after the sizer runs
# (currently passed via the sizer or risk config)
...
)
Constraints available: max_gross_leverage, max_notional_per_market_usd, max_gross_notional_usd, per-venue budgets, per-asset-class limits.
Influencing size from your strategy
You don’t have to leave sizing entirely to the optimizer. Set hints on your signals:
Signal.increase(market,
confidence=0.8,
edge_bps=50,
expected_vol_bps=100,
horizon="3d",
preferred_notional_usd=5000, # soft hint: I'd like ~$5k
max_notional_usd=10000, # hard cap: never more than $10k
)
The sizer will try to honor preferred_notional_usd and will never exceed max_notional_usd.
Key takeaway
The strategy decides what to trade and how confident it is. The sizer decides how much. You can swap sizers without touching your strategy code.