Portfolio Sizing Overview
Kelly, Carver, EqualWeight: pluggable sizers converting signals to target positions
The portfolio layer is where signals become target positions in USD notional. It sits between signal generation and execution.
How do I control the portfolio?
You have three levels of control:
- Pick a sizer in
hz.run()- the simplest approach. The sizer reads your signals and decides position sizes automatically.
result = hz.run(
strategies=[MyStrategy()],
portfolio=KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.0),
...
)
- Influence sizing from your strategy - set
preferred_notional_usdormax_notional_usdon your signals to give hints or hard caps to the sizer.
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
)
- Bypass the pipeline entirely - use
hz.connect()to place orders manually with full control over every parameter.
with hz.connect("paper", initial_cash_usd=100_000) as ex:
ex.buy("AAPL", qty=10, limit=185.0)
ex.flatten("MSFT")
Most users start with option 1. See the Portfolio Control code-along for a hands-on comparison of all three sizers.
Does the portfolio rebalance automatically?
Yes. Every tick, the sizer runs again on all active signals and produces new target positions. If a signal changes (e.g., z-score drops from -2.5 to -1.0, below your threshold), the signal expires and the sizer no longer targets that position. The executor will close or reduce it on the next tick.
You don’t call “rebalance” manually. The pipeline does it every tick as part of the normal loop.
Market-neutral and delta-neutral portfolios
Horizon supports neutral portfolios through constraints and strategy logic:
Net-exposure constraint
Cap the net dollar exposure to force approximate market neutrality:
from horizon.portfolio import PortfolioConstraints
constraints = PortfolioConstraints(
max_net_notional_usd=5000, # net long/short exposure stays within ±$5k
max_gross_leverage=2.0, # can be 2× levered, but balanced long/short
)
Strategy-level neutrality
Your strategy can read the current book and emit balancing signals:
def evaluate(self, f, universe, ctx):
signals = []
# ... your normal signal logic ...
# Check net exposure and emit offsetting signal if needed
if ctx.portfolio.net_notional > 10_000:
# too long - find something to short
...
elif ctx.portfolio.net_notional < -10_000:
# too short - find something to buy
...
return signals
Delta-neutral (options)
For options portfolios, use the portfolio Greeks available via ctx:
def evaluate(self, f, universe, ctx):
# Read portfolio Greeks
delta = ctx.portfolio.portfolio_delta # total delta
dollar_delta = ctx.portfolio.portfolio_dollar_delta # dollar delta
gamma = ctx.portfolio.portfolio_gamma
vega = ctx.portfolio.portfolio_vega
theta = ctx.portfolio.portfolio_theta
# If delta is too large, hedge it
if dollar_delta is not None and abs(dollar_delta) > 5000:
# emit a signal to reduce delta exposure
...
Correlation awareness
Check whether a new position would increase portfolio concentration:
def evaluate(self, f, universe, ctx):
for m in universe:
# Skip if highly correlated with existing book
corr = ctx.portfolio.correlation_with(m.id)
if corr > 0.8:
continue
# Skip if sector is overweight
if ctx.portfolio.exposure_to_sector("tech") > 0.3:
continue
# ... signal logic ...
Available sizers
Plus:
The Sizer protocol
from typing import Protocol, runtime_checkable
@runtime_checkable
class Sizer(Protocol):
name: str
def optimize(
self,
signals: list[Signal],
current_positions: dict,
cash: CashSnapshot,
cov: CovarianceModel | None,
constraints: PortfolioConstraints,
) -> list[TargetPosition]:
...
Every sizer has one method that takes all the context and returns a list of TargetPosition objects.
Quick comparison
| Property | EqualWeight | Kelly | Carver |
|---|---|---|---|
| Uses edge estimate | No | Yes | Yes (via forecast) |
| Uses vol estimate | No | Yes | Yes (for sizing) |
| Uses correlation | No | Yes | Via FDM |
| Vol targeting | No | No | Yes |
| Buffering | No | No | Yes |
| Complexity | Trivial | Medium | High |
| When to use | Baseline | Research + live | Multi-instrument vol-targeted |
Swapping sizers
Change the portfolio= argument to try a different sizer:
# Start with EqualWeight
result1 = hz.run(portfolio=EqualWeight(), ...)
# Switch to Kelly
result2 = hz.run(portfolio=KellyOptimizer(kelly_fraction=0.25), ...)
# Switch to Carver
result3 = hz.run(portfolio=CarverSystematic(annual_vol_target=0.15), ...)
Same strategy, same data, same risk: only the sizer changes. Compare the results.