KellyOptimizer
Real Kelly math with cost drag, leverage cap, and fund-module delegation
KellyOptimizer is Horizon’s default sizer. It implements the classical Kelly formula with realistic extensions: transaction cost drag, gross leverage cap, per-market and per-signal notional caps, per-venue budget routing.
When the existing v1 horizon.fund._portfolio_optimizer.PortfolioOptimizer is importable (i.e., when running alongside the v1 codebase), KellyOptimizer delegates to it. using Ledoit-Wolf shrinkage covariance and projected gradient descent. Otherwise the standalone fallback runs.
Import
from horizon.portfolio import KellyOptimizer
Signature
KellyOptimizer(
kelly_fraction: float = 0.25,
max_gross_leverage: float = 2.0,
transaction_cost_bps: float = 5.0,
covariance_model: str = "ledoit_wolf",
)
kelly_fractionfloatmax_gross_leveragefloattransaction_cost_bpsfloatcovariance_modelstrValidation
The constructor raises ValueError on invalid arguments:
KellyOptimizer(kelly_fraction=0) # ValueError
KellyOptimizer(kelly_fraction=1.5) # ValueError
KellyOptimizer(max_gross_leverage=0) # ValueError
The math
For each signal:
edge_net = (expected_edge_bps - transaction_cost_bps) / 10000
vol = max(expected_vol_bps, 10) / 10000
kelly_full = edge_net / vol²
kelly_cap = min(kelly_full, 1.0) (hard-capped at 100% of equity)
weight = kelly_cap × kelly_fraction × confidence × direction.sign
Then across all signals:
- Gross cap: if
Σ |weights| > max_gross_leverage, scale all weights proportionally - Convert to USD:
notional_usd = weight × total_equity - Per-market cap: apply
constraints.max_notional_per_market_usd - Per-signal cap: apply
signal.max_notional_usdif set - Venue routing: apply per-venue budgets for signals prefixed by a venue key (e.g.,
polymarket:...)
Use it
import horizon as hz
from horizon.portfolio import KellyOptimizer
result = hz.run(
portfolio=KellyOptimizer(
kelly_fraction=0.25,
max_gross_leverage=1.5,
transaction_cost_bps=5,
),
...
)
Delegation to fund module
try:
from horizon.fund._portfolio_optimizer import PortfolioOptimizer
self._fund_optimizer = PortfolioOptimizer(
kelly_fraction=kelly_fraction,
max_gross_leverage=max_gross_leverage,
covariance_model=covariance_model,
)
except Exception:
self._fund_optimizer = None
When self._fund_optimizer is not None, the v1 math runs with:
- Ledoit-Wolf covariance shrinkage for robust correlation estimation
- Projected gradient descent for constraint-aware optimization
- Rebalance trigger tracking (drift, VaR, correlation change)
When unavailable, the standalone fallback runs the simpler formula above.
See v1 PortfolioOptimizer for the full math of the fund module.
Verified behavior
Hand-calculated cases in tests/test_portfolio.py::TestKellyMath:
| Input | Expected |
|---|---|
edge=100, vol=500, conf=1.0, kelly_fraction=1.0 | Weight = 1.0 → 100% of equity |
Same, kelly_fraction=0.5 | 50% of equity |
Same, confidence=0.5 | 50% of equity (half weight) |
edge=5, cost=10 bps | Weight = 0 (net edge negative) |
Two strong signals, max_gross_leverage=1.5 | Each scaled so sum = 1.5 × equity |
max_notional_per_market_usd=5000 | Capped at $5k regardless of edge |
signal.max_notional_usd=2000 | Capped at $2k |
8 tests, all passing.
Pitfalls
Fractional Kelly selection
Full Kelly is theoretically optimal but practically too aggressive:
- Full Kelly (1.0): maximizes expected log wealth but has ~40% probability of 50% drawdown
- Half Kelly (0.5). 75% of expected growth, much lower drawdown probability
- Quarter Kelly (0.25). 44% of expected growth, very conservative
- Eighth Kelly (0.125): very low risk, but growth is only 22% of full
Most practitioners use 0.25 to 0.5. The default is 0.25.