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:

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

python
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

python
def optimize(self, signals, current_positions, cash, cov, constraints):
ArgumentWhat it isWhen you’d use it
signalsList of Signal objects from your strategiesAlways - this is your main input
current_positionsWhat you currently hold (dict of market_id → position)When you want to avoid churning or check existing exposure
cashHow much money you have (cash.total_equity_usd)To size relative to your portfolio
covCovariance model between markets (can be None)For risk parity, mean-variance, or correlation-aware sizing
constraintsLimits like max_gross_leverage, max_notional_per_market_usdTo 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:

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

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

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

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

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

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

python
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

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

Next