Custom Portfolio Sizers
Build your own sizing logic in plain Python
A custom sizer is a class with one method: optimize(). It receives the signals your strategies produced and decides how many dollars to put behind each one. That’s it.
What you’re building
Your sizer sits here in the pipeline:
Strategy → [Signal, Signal, ...] → YOUR SIZER → [TargetPosition, TargetPosition, ...] → Executor
Input: a list of signals (opinions about markets - direction, confidence, expected edge, expected vol).
Output: a list of target positions (how many dollars to allocate to each market).
You don’t touch orders, prices, or venues. You just decide: given these opinions, how should I split my money?
Simplest possible sizer
Allocate $1,000 to every signal, regardless of quality:
from horizon.types import TargetPosition
class FixedSize:
name = "fixed_1k"
def optimize(self, signals, current_positions, cash, cov, constraints):
return [
TargetPosition(
market_id=sig.market_id,
target_notional_usd=1000.0 * sig.direction.sign, # +1000 long, -1000 short
)
for sig in signals
]
Use it:
result = hz.run(
strategies=[MyStrategy()],
portfolio=FixedSize(),
...
)
That’s a working sizer. Every signal gets $1,000. Simple, but ignores everything interesting about the signal.
What your method receives
def optimize(self, signals, current_positions, cash, cov, constraints):
| Argument | What it is | When you’d use it |
|---|---|---|
signals | List of Signal objects from your strategies | Always - this is your main input |
current_positions | What you currently hold (dict of market_id → position) | When you want to avoid churning or check existing exposure |
cash | How much money you have (cash.total_equity_usd) | To size relative to your portfolio |
cov | Covariance model between markets (can be None) | For risk parity, mean-variance, or correlation-aware sizing |
constraints | Limits like max_gross_leverage, max_notional_per_market_usd | To respect portfolio-wide rules |
You don’t need to use all of them. The simplest sizers only look at signals and cash.
What each signal tells you
Inside your optimize(), each signal has:
for sig in signals:
sig.market_id # "AAPL"
sig.direction # Direction.Increase or Direction.Decrease
sig.direction.sign # +1 or -1
sig.confidence # 0.0 to 1.0 - how sure the strategy is
sig.expected_edge_bps # expected return in basis points
sig.expected_vol_bps # expected volatility in basis points
sig.urgency # Immediate, Patient, or Passive
These are the inputs you use to decide how much capital each signal deserves.
What you return
A list of TargetPosition objects:
TargetPosition(
market_id="AAPL",
target_notional_usd=5000.0, # positive = long, negative = short
urgency=Urgency.Patient, # optional
)
The executor handles the rest - converting dollars to shares, placing orders, etc.
Example: size by confidence
Higher confidence → bigger position. This is one step above equal-weight:
class ConfidenceSizer:
name = "confidence"
def optimize(self, signals, current_positions, cash, cov, constraints):
if not signals:
return []
equity = cash.total_equity_usd
total_confidence = sum(s.confidence for s in signals)
if total_confidence == 0:
return []
return [
TargetPosition(
market_id=sig.market_id,
target_notional_usd=(sig.confidence / total_confidence) * equity * sig.direction.sign,
)
for sig in signals
]
A signal with 80% confidence gets twice the allocation of one with 40%.
Example: inverse volatility
Low-vol markets get bigger positions. High-vol markets get smaller ones. This is a common risk-balancing approach:
class InverseVol:
name = "inverse_vol"
def optimize(self, signals, current_positions, cash, cov, constraints):
if not signals:
return []
equity = cash.total_equity_usd
# Weight each signal by 1 / volatility
weights = [1.0 / max(sig.expected_vol_bps, 10) for sig in signals]
total_weight = sum(weights)
return [
TargetPosition(
market_id=sig.market_id,
target_notional_usd=(w / total_weight) * equity * sig.direction.sign,
)
for sig, w in zip(signals, weights)
]
Example: Kelly with a twist
Use the standard Kelly formula but cap individual positions at 10% of equity:
class CappedKelly:
name = "capped_kelly"
def __init__(self, kelly_fraction=0.25, max_per_position_pct=0.10):
self.kelly_fraction = kelly_fraction
self.max_pct = max_per_position_pct
def optimize(self, signals, current_positions, cash, cov, constraints):
if not signals:
return []
equity = cash.total_equity_usd
targets = []
for sig in signals:
# Kelly: f* = edge / vol²
if sig.expected_vol_bps <= 0:
continue
kelly_raw = sig.expected_edge_bps / (sig.expected_vol_bps ** 2)
kelly_sized = kelly_raw * self.kelly_fraction * sig.confidence
notional = kelly_sized * equity * sig.direction.sign
# Cap at max_per_position_pct of equity
cap = self.max_pct * equity
notional = max(-cap, min(notional, cap))
targets.append(TargetPosition(
market_id=sig.market_id,
target_notional_usd=notional,
))
return targets
Example: risk parity (equal risk contribution)
Each position contributes equal risk to the portfolio. Needs the covariance model:
import numpy as np
class RiskParity:
name = "risk_parity"
def optimize(self, signals, current_positions, cash, cov, constraints):
if not signals or cov is None:
# Fall back to equal weight
equity = cash.total_equity_usd
n = len(signals) or 1
return [
TargetPosition(market_id=s.market_id,
target_notional_usd=(equity / n) * s.direction.sign)
for s in signals
]
ids = [s.market_id for s in signals]
C = np.array(cov.cov(ids))
n = len(signals)
w = np.ones(n) / n
# Iterative equal-risk-contribution
for _ in range(50):
port_var = w @ C @ w
mc = C @ w
rc = w * mc / max(np.sqrt(port_var), 1e-10)
target_rc = np.mean(rc)
w -= 0.01 * (rc - target_rc)
w = np.maximum(w, 0)
s = w.sum()
if s > 0:
w /= s
equity = cash.total_equity_usd
return [
TargetPosition(
market_id=sig.market_id,
target_notional_usd=float(weight) * equity * sig.direction.sign,
)
for sig, weight in zip(signals, w)
]
Using the quantitative toolbox
The quant toolbox has functions you can call inside your sizer:
import horizon as hz
class HRPSizer:
name = "hrp"
def optimize(self, signals, current_positions, cash, cov, constraints):
if not signals:
return []
# Use HRP from the toolbox
returns_matrix = ... # build from historical data
weights = hz.hrp_weights(returns_matrix)
equity = cash.total_equity_usd
return [
TargetPosition(
market_id=sig.market_id,
target_notional_usd=w * equity * sig.direction.sign,
)
for sig, w in zip(signals, weights)
]
Available toolbox functions for portfolio construction: hz.hrp_weights(), hz.robust_optimize(), hz.entropy_pool(), hz.robust_efficient_frontier().
Testing
from horizon.portfolio.base import CashSnapshot, PortfolioConstraints
from horizon.types import Signal, Direction
from datetime import timedelta
def test_my_sizer():
sizer = ConfidenceSizer()
signals = [
Signal(market_id="AAPL", direction=Direction.Increase,
confidence=0.8, expected_edge_bps=50,
expected_expected_vol_bps=200, horizon=timedelta(days=1)),
Signal(market_id="MSFT", direction=Direction.Decrease,
confidence=0.4, expected_edge_bps=30,
expected_expected_vol_bps=150, horizon=timedelta(days=1)),
]
result = sizer.optimize(
signals=signals,
current_positions={},
cash=CashSnapshot(total_equity_usd=100_000, net_liquidation_usd=100_000),
cov=None,
constraints=PortfolioConstraints(),
)
assert len(result) == 2
# AAPL should get 2x the allocation of MSFT (0.8 vs 0.4 confidence)
assert abs(result[0].target_notional_usd) > abs(result[1].target_notional_usd)