Portfolio

Sizer protocol + KellyOptimizer + CarverSystematic + EqualWeight

The Sizer protocol

python
from typing import Protocol, runtime_checkable

@runtime_checkable
class Sizer(Protocol):
    name: str

    def optimize(
        self,
        signals: list[Signal],
        current_positions: dict[str, Any],
        cash: CashSnapshot,
        cov: CovarianceModel | None,
        constraints: PortfolioConstraints,
    ) -> list[TargetPosition]:
        ...

Every sizer implements exactly this method. Inputs and outputs are standardized so sizers are swappable.

PortfolioConstraints

python
@dataclass
class PortfolioConstraints:
    max_gross_notional_usd: float = 0.0
    max_net_notional_usd: float = 0.0
    max_gross_leverage: float = 2.0
    venue_budgets: dict[str, float] = field(default_factory=dict)
    max_venue_utilization: float = 0.9
    per_asset_class: dict[AssetClass, AssetClassLimits] = field(default_factory=dict)
    max_notional_per_market_usd: float = 0.0
    max_capital_per_market_usd: float = 0.0
    max_sector_exposure: dict[str, float] = field(default_factory=dict)
    max_cluster_exposure: float | None = None

The run loop builds this from the RiskConfig and passes it to every sizer call.

CashSnapshot / VenueCapitalSnapshot

python
@dataclass
class VenueCapitalSnapshot:
    venue_name: str
    total_equity_usd: float
    cash_usd: float
    buying_power_usd: float
    used_buying_power_usd: float
    free_buying_power_usd: float

@dataclass
class CashSnapshot:
    venues: dict[str, VenueCapitalSnapshot]
    total_equity_usd: float
    net_liquidation_usd: float

KellyOptimizer

Default sizer. Real Kelly math with cost drag and leverage cap.

python
from horizon.portfolio import KellyOptimizer

sizer = KellyOptimizer(
    kelly_fraction=0.25,              # 0 < kf ≤ 1.0
    max_gross_leverage=2.0,           # > 0
    transaction_cost_bps=5.0,
    covariance_model="ledoit_wolf",
)

Math

For each signal:

text
edge_net    = expected_edge_bps - transaction_cost_bps  (bps → decimal)
vol         = max(expected_vol_bps, 10) / 10000
kelly_full  = edge_net / vol²
kelly_cap   = min(kelly_full, 1.0)
weight      = kelly_cap × kelly_fraction × confidence × direction.sign

Then:

  1. Gross leverage cap: if Σ |weight| > max_gross_leverage, scale proportionally
  2. Convert to USD: notional = weight × equity
  3. Apply per-market cap: |notional| ≤ constraints.max_notional_per_market_usd
  4. Apply per-signal cap: |notional| ≤ signal.max_notional_usd
  5. Venue budget routing: if constraints.venue_budgets set and the signal routes to a venue, cap by budget

Delegation

When horizon.fund._portfolio_optimizer.PortfolioOptimizer is importable, KellyOptimizer tries to delegate to it. Falls through to the standalone path on any error.

Validation

python
KellyOptimizer(kelly_fraction=0)     # ✗ ValueError
KellyOptimizer(kelly_fraction=1.5)   # ✗ ValueError
KellyOptimizer(max_gross_leverage=0) # ✗ ValueError

CarverSystematic

Robert Carver’s vol-targeted sizer.

python
from horizon.portfolio import CarverSystematic

sizer = CarverSystematic(
    annual_vol_target=0.20,           # 20% annualized portfolio vol
    forecast_scalar_method="historical",
    forecast_cap=20.0,
    diversification_multiplier=1.0,    # or "bootstrap"
    fdm_bootstrap_samples=1000,
    buffering=True,
    buffer_width=0.10,
    vol_estimator="ewma",
    vol_lookback=90,
    vol_floor=0.05,
)

Forecast resolution

For each signal:

  1. If signal.carver_forecast is set, use it (capped at ±forecast_cap)
  2. Otherwise synthesize from signal.sharpe × direction.sign, scale by rolling mean of absolute forecasts

Position formula

text
position_notional = (forecast / 10) × (target_vol × equity) / instrument_vol × FDM

Then per-market cap, buffering (don’t trade if |delta| is under buffer_width × target), and gross-leverage cap.

EqualWeight

Baseline sizer. Equal USD notional per signal, up to max_positions.

python
from horizon.portfolio import EqualWeight

sizer = EqualWeight(
    max_positions=20,
    gross_fraction_of_equity=1.0,
)

Signals are ranked by confidence and the top N are selected. Each gets gross_budget / len(selected).

Tests

16 portfolio tests in tests/test_portfolio.py covering hand-calculated Kelly cases, constraint enforcement, and flip handling.

Writing a custom sizer

python
from horizon.portfolio.base import (
    CashSnapshot,
    CovarianceModel,
    PortfolioConstraints,
)
from horizon.types import Signal, TargetPosition


class MySizer:
    name = "my_sizer"

    def optimize(
        self,
        signals: list[Signal],
        current_positions,
        cash: CashSnapshot,
        cov: CovarianceModel | None,
        constraints: PortfolioConstraints,
    ) -> list[TargetPosition]:
        return [
            TargetPosition(
                market_id=s.market_id,
                target_notional_usd=my_math(s, cash.total_equity_usd),
                urgency=s.urgency,
                contributing_signal_ids=(s.strategy_id,),
            )
            for s in signals
        ]

Use it:

python
hz.run(portfolio=MySizer(), ...)

Next