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.

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

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

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

python
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

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

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

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

Next