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.

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

python
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%.

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

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

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

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

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

FeaturePlain KellyOptimizerMultiFundSizer
Asset-class budgetsNo - all signals compete equallyYes - 50% equity, 25% pred, 25% crypto
Drawdown scalingNo - constant sizingYes - reduces to 0% at 12% DD
Per-book KellyOne poolKelly runs within each book’s budget
Position capGlobal only8% of equity per position

Step 5: Layered risk

Three levels of defense stacked on top of the sizer’s built-in drawdown scaling:

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

  1. Strategy level (eq_mr): pauses at 8% drawdown via ctx
  2. Sizer level: scales positions down at 3%/6%/12% drawdown
  3. Risk engine: halts/reduces/flattens at 3%/7%/12% drawdown
  4. 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:

python
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

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

python
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

What to experiment with

Full runnable file

Next