Code Along: Multi-Asset Fund
12 markets, 4 strategies, 3 asset classes, custom sizer, layered risk, and validation - the full picture
This is the most complete example in the documentation. It builds a multi-asset quantitative fund that trades equities, prediction markets, and crypto perps simultaneously, with:
- 12 markets across 3 asset classes
- 4 strategies (equity mean reversion, equity momentum, prediction contrarian, crypto trend)
- 1 custom feature (multi-timeframe momentum score)
- 1 custom sizer with per-asset-class budgets and drawdown-adaptive scaling
- Strategy-level protection (drawdown pause via
ctx) - Portfolio-level risk (stops + 3-tier drawdown guards)
- Bootstrap validation at the end
Every line is explained. The full runnable file is at the bottom.
Step 1: The universe
12 markets across 3 asset classes. Each will be filtered to the right strategy automatically.
import horizon as hz
from horizon.asset_classes import AssetClass, Equity, Prediction, Crypto
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
markets = [
# 7 equities - two sectors (tech + finance)
Market(id="AAPL", asset_class=AssetClass.Equity),
Market(id="MSFT", asset_class=AssetClass.Equity),
Market(id="NVDA", asset_class=AssetClass.Equity),
Market(id="GOOGL", asset_class=AssetClass.Equity),
Market(id="AMZN", asset_class=AssetClass.Equity),
Market(id="JPM", asset_class=AssetClass.Equity),
Market(id="GS", asset_class=AssetClass.Equity),
# 3 prediction markets - political + macro
Market(id="TRUMP-2028", asset_class=AssetClass.Prediction),
Market(id="FED-CUT", asset_class=AssetClass.Prediction),
Market(id="RECESSION", asset_class=AssetClass.Prediction),
# 2 crypto perps
Market(id="BTC-PERP", asset_class=AssetClass.Crypto),
Market(id="ETH-PERP", asset_class=AssetClass.Crypto),
]
universe = StaticUniverse(markets)
Step 2: A custom feature
Built-in features cover most cases, but sometimes you need your own. This one combines short-term and long-term momentum into a single score:
import math
from horizon.features.base import Feature
class MomentumScore(Feature):
name = "momentum_score"
def __init__(self, short_window=5, long_window=20):
self.short_window = short_window
self.long_window = long_window
self.min_history = long_window + 1
def compute(self, history):
if len(history) < self.min_history:
return float('nan')
prices = [h.close for h in history]
short_ret = (prices[-1] / prices[-self.short_window]) - 1
long_ret = (prices[-1] / prices[-self.long_window]) - 1
# Average of short and long momentum
return (short_ret + long_ret) / 2
Use it in any strategy just like a built-in: features = {"mom": MomentumScore(5, 20)}.
Step 3: Four strategies
Equity mean reversion (z-score + RSI confirmation)
Uses ctx for self-protection: pauses trading when portfolio drawdown exceeds 8%.
from horizon import Strategy, Signal
from horizon.features import Zscore, RSI, RealizedVol, SMA, Price
class EquityMeanRev(Strategy):
name = "eq_mr"
asset_classes = [Equity]
features = {"z": Zscore(20), "rsi": RSI(14), "vol": RealizedVol(20)}
def evaluate(self, f, universe, ctx):
# Strategy-level protection: pause during drawdown
if ctx.portfolio.drawdown_pct > 0.08:
return []
signals = []
for m in universe:
z, rsi, vol = f.z[m.id], f.rsi[m.id], f.vol[m.id]
if any(math.isnan(v) for v in [z, rsi, vol]):
continue
# Z-score extreme + RSI confirmation
if z < -2.0 and rsi < 35:
signals.append(Signal.from_score(
m, score=-z, edge_per_stdev=20,
horizon="3d", expected_expected_vol_bps=max(vol * 100, 50),
reason=f"MR z={z:.1f} rsi={rsi:.0f}",
))
elif z > 2.0 and rsi > 65:
signals.append(Signal.from_score(
m, score=-z, edge_per_stdev=20,
horizon="3d", expected_expected_vol_bps=max(vol * 100, 50),
reason=f"MR z={z:.1f} rsi={rsi:.0f}",
))
return signals
Only sees: AAPL, MSFT, NVDA, GOOGL, AMZN, JPM, GS (the 7 equities).
Equity momentum (custom feature)
Uses our MomentumScore to catch trending stocks.
class EquityMomentum(Strategy):
name = "eq_mom"
asset_classes = [Equity]
features = {"mom": MomentumScore(5, 20), "vol": RealizedVol(20)}
def evaluate(self, f, universe):
signals = []
for m in universe:
mom, vol = f.mom[m.id], f.vol[m.id]
if math.isnan(mom) or math.isnan(vol):
continue
# Emit signal when momentum is strong (>2% over combined window)
if abs(mom) > 0.02:
direction = Signal.increase if mom > 0 else Signal.decrease
signals.append(direction(
m,
confidence=min(abs(mom) * 10, 0.8),
edge_bps=abs(mom) * 5000,
expected_vol_bps=max(vol * 100, 60),
horizon="5d",
reason=f"MOM={mom:+.2%}",
))
return signals
Also only sees equities. Both eq_mr and eq_mom run on the same universe, but one is mean-reversion and the other is momentum. When they disagree on a stock, the signals partially cancel in the optimizer.
Prediction market contrarian
Fades extreme probabilities. When a prediction market is priced below 0.30 or above 0.70, bets on reversion toward 0.50.
class PredictionContrarian(Strategy):
name = "pred_fade"
asset_classes = [Prediction]
features = {"price": Price()}
def evaluate(self, f, universe):
signals = []
for m in universe:
p = f.price[m.id]
if math.isnan(p):
continue
if p < 0.30:
signals.append(Signal.increase(
m, confidence=0.55,
edge_bps=(0.45 - p) * 10000,
expected_vol_bps=300,
horizon="14d",
reason=f"cheap at {p:.2f}",
))
elif p > 0.70:
signals.append(Signal.decrease(
m, confidence=0.55,
edge_bps=(p - 0.55) * 10000,
expected_vol_bps=300,
horizon="14d",
reason=f"rich at {p:.2f}",
))
return signals
Only sees: TRUMP-2028, FED-CUT, RECESSION.
Crypto trend following
Simple MA crossover tuned for higher volatility.
class CryptoTrend(Strategy):
name = "crypto_trend"
asset_classes = [Crypto]
features = {"fast": SMA(5), "slow": SMA(20), "vol": RealizedVol(10)}
def evaluate(self, f, universe):
signals = []
for m in universe:
fast, slow, vol = f.fast[m.id], f.slow[m.id], f.vol[m.id]
if any(math.isnan(v) for v in [fast, slow, vol]) or slow == 0:
continue
cross = (fast - slow) / slow
if abs(cross) > 0.015:
direction = Signal.increase if cross > 0 else Signal.decrease
signals.append(direction(
m,
confidence=min(abs(cross) * 15, 0.85),
edge_bps=abs(cross) * 8000,
expected_vol_bps=max(vol * 100, 80),
horizon="2d",
reason=f"trend={cross:+.3f}",
))
return signals
Only sees: BTC-PERP, ETH-PERP.
Step 4: Custom multi-fund sizer
This is the interesting part. Instead of one flat Kelly allocation, we split the portfolio into three books with separate budgets and apply Kelly within each. The sizer also scales down all positions during drawdown.
from horizon.types import TargetPosition
class MultiFundSizer:
name = "multi_fund"
def __init__(self, budgets, kelly_frac=0.25, max_per_pos=0.08):
self.budgets = budgets # {"equity": 0.50, "pred": 0.25, "crypto": 0.25}
self.kelly_frac = kelly_frac
self.max_per_pos = max_per_pos
self._peak = 0.0
def optimize(self, signals, current_positions, cash, cov, constraints):
if not signals:
return []
equity = cash.total_equity_usd
self._peak = max(self._peak, equity)
# --- Drawdown-adaptive scaling ---
dd = (self._peak - equity) / self._peak if self._peak > 0 else 0
if dd > 0.12:
scale = 0.0 # flatten: stop all new positions
elif dd > 0.06:
scale = 0.3 # severe: 30% of normal size
elif dd > 0.03:
scale = 0.6 # moderate: 60%
else:
scale = 1.0 # normal
# --- Group signals by asset class ---
by_class = {}
for sig in signals:
ac = self._classify(sig.market_id)
by_class.setdefault(ac, []).append(sig)
# --- Kelly within each book ---
targets = []
for ac, sigs in by_class.items():
# Budget for this asset class, scaled by drawdown
budget = equity * self.budgets.get(ac, 0.05) * scale
for sig in sigs:
if sig.expected_vol_bps <= 0:
continue
# Kelly: f* = edge / vol²
kelly = sig.expected_edge_bps / (sig.expected_vol_bps ** 2)
sized = kelly * self.kelly_frac * sig.confidence
notional = sized * budget * sig.direction.sign
# Per-position cap
cap = equity * self.max_per_pos
notional = max(-cap, min(notional, cap))
targets.append(TargetPosition(
market_id=sig.market_id,
target_notional_usd=notional,
urgency=sig.urgency,
))
return targets
def _classify(self, market_id):
if market_id.endswith("-PERP"):
return "crypto"
if market_id in ("TRUMP-2028", "FED-CUT", "RECESSION"):
return "pred"
return "equity"
What this does differently from plain Kelly:
| Feature | Plain KellyOptimizer | MultiFundSizer |
|---|---|---|
| Asset-class budgets | No - all signals compete equally | Yes - 50% equity, 25% pred, 25% crypto |
| Drawdown scaling | No - constant sizing | Yes - reduces to 0% at 12% DD |
| Per-book Kelly | One pool | Kelly runs within each book’s budget |
| Position cap | Global only | 8% of equity per position |
Step 5: Layered risk
Three levels of defense stacked on top of the sizer’s built-in drawdown scaling:
from horizon.risk import RiskConfig, StopLoss, DrawdownGuard
from horizon.risk.drawdown import DrawdownAction
risk = RiskConfig(
max_gross_leverage=1.5,
# Per-position: close at -6%
stop_loss=StopLoss(per_position_pct=0.06),
# Portfolio-wide: three tiers
drawdown=[
DrawdownGuard(daily_pct=0.03, action=DrawdownAction.HaltNew),
DrawdownGuard(weekly_pct=0.07, action=DrawdownAction.ReduceHalf),
DrawdownGuard(monthly_pct=0.12, action=DrawdownAction.Flatten),
],
)
Protection stack (inner to outer):
- Strategy level (eq_mr): pauses at 8% drawdown via
ctx - Sizer level: scales positions down at 3%/6%/12% drawdown
- Risk engine: halts/reduces/flattens at 3%/7%/12% drawdown
- Stop loss: closes individual positions at -6%
Each layer catches things the others might miss.
Step 6: Stress-test data
Four regimes to test behavior under different market conditions:
from horizon.data import SyntheticRegimes
all_ids = [m.id for m in markets]
data = SyntheticRegimes(
market_ids=all_ids,
n_bars=500,
regimes=[
(0.35, 0.12, 0.10), # 35% of time: uptrend, low vol
(0.25, 0.00, 0.22), # 25%: chop, medium vol
(0.25, -0.20, 0.35), # 25%: crash, high vol
(0.15, 0.08, 0.15), # 15%: recovery
],
seed=17,
)
Step 7: Run the fund
result = hz.run(
mode="backtest",
strategies=[
EquityMeanRev(),
EquityMomentum(),
PredictionContrarian(),
CryptoTrend(),
],
asset_classes=[Equity, Prediction, Crypto],
universe=universe,
portfolio=MultiFundSizer(
budgets={"equity": 0.50, "pred": 0.25, "crypto": 0.25},
kelly_frac=0.25,
max_per_pos=0.08,
),
risk=risk,
data_source=data,
backtest=hz.BacktestConfig(initial_cash_usd=500_000),
)
print(f"Markets: {len(markets)}")
print(f"Strats: 4 (eq_mr, eq_mom, pred_fade, crypto_trend)")
print(f"Trades: {result.n_trades}")
print(f"Sharpe: {result.sharpe:+.3f}")
print(f"Return: {result.total_return:+.2%}")
print(f"Max DD: {result.max_drawdown:.2%}")
print(f"Equity: ${result.equity_curve[0][1]:,.0f} → ${result.equity_curve[-1][1]:,.0f}")
Step 8: Validate
A point estimate doesn’t tell you if the result is real. Bootstrap gives you a confidence interval.
from horizon.validate import Bootstrap
equities = [e for _, e in result.equity_curve]
returns = [(equities[i] / equities[i-1]) - 1 for i in range(1, len(equities))]
bs = Bootstrap(metrics=["sharpe"], n_samples=300, seed=42)
bs_result = bs.run(returns=returns)
median = bs_result.median("sharpe")
lo, hi = bs_result.ci("sharpe", conf=0.95)
print(f"\nBootstrap Sharpe 95% CI: [{lo:+.3f}, {hi:+.3f}]")
if lo > 0:
print("Lower bound is positive - statistical evidence of edge")
else:
print("CI includes zero - result could be noise")
How the pieces connect
Universe (12 markets)
├── EquityMeanRev sees 7 equities → signals (z-score + RSI)
├── EquityMomentum sees 7 equities → signals (custom momentum)
├── PredictionFade sees 3 pred mkts → signals (probability edge)
└── CryptoTrend sees 2 perps → signals (MA cross)
│
ALL SIGNALS
│
MultiFundSizer
├── equity book (50% budget) → Kelly within book
├── pred book (25% budget) → Kelly within book
└── crypto book (25% budget) → Kelly within book
│ (drawdown-scaled)
TargetPositions
│
Risk Engine
├── Stop loss: -6% per position
├── Daily DD: 3% → halt new
├── Weekly DD: 7% → reduce half
└── Monthly DD: 12% → flatten all
│
Venue (paper)
│
Fills → Ledger → Metrics