Portfolio
Sizer protocol + KellyOptimizer + CarverSystematic + EqualWeight
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[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
@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
@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.
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:
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:
- Gross leverage cap: if
Σ |weight| > max_gross_leverage, scale proportionally - Convert to USD:
notional = weight × equity - Apply per-market cap:
|notional| ≤ constraints.max_notional_per_market_usd - Apply per-signal cap:
|notional| ≤ signal.max_notional_usd - Venue budget routing: if
constraints.venue_budgetsset 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
KellyOptimizer(kelly_fraction=0) # ✗ ValueError
KellyOptimizer(kelly_fraction=1.5) # ✗ ValueError
KellyOptimizer(max_gross_leverage=0) # ✗ ValueError
CarverSystematic
Robert Carver’s vol-targeted sizer.
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:
- If
signal.carver_forecastis set, use it (capped at ±forecast_cap) - Otherwise synthesize from
signal.sharpe × direction.sign, scale by rolling mean of absolute forecasts
Position formula
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.
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
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:
hz.run(portfolio=MySizer(), ...)