Code Along: Mixed-Asset Portfolio

Equities, prediction markets, and perps in one portfolio with per-asset-class rules

This example builds a portfolio that trades three asset classes simultaneously, each with its own strategy and its own rules, but with a single portfolio optimizer allocating capital across all of them.

The setup

We’ll trade:

  • 3 equities (AAPL, MSFT, NVDA) with a mean-reversion strategy
  • 2 prediction markets (TRUMP-2028, FED-CUT-JULY) with a probability-based strategy
  • 1 perpetual (BTC-PERP) with a momentum strategy

Each strategy only sees its own asset class. The portfolio optimizer sees all signals together.

Step 1: The universe

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

universe = StaticUniverse([
    # Equities
    Market(id="AAPL", asset_class=AssetClass.Equity),
    Market(id="MSFT", asset_class=AssetClass.Equity),
    Market(id="NVDA", asset_class=AssetClass.Equity),
    # Prediction markets
    Market(id="TRUMP-2028", asset_class=AssetClass.Prediction),
    Market(id="FED-CUT-JULY", asset_class=AssetClass.Prediction),
    # Perpetual
    Market(id="BTC-PERP", asset_class=AssetClass.Crypto),
])

Step 2: Three strategies, one per asset class

Equity strategy (z-score mean reversion)

python
from horizon import Strategy, Signal
from horizon.features import Zscore, RealizedVol
import math

class EquityMR(Strategy):
    name = "equity_mr"
    asset_classes = [Equity]
    features = {"z": Zscore(window=20), "vol": RealizedVol(window=20)}

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            z, vol = f.z[m.id], f.vol[m.id]
            if math.isnan(z) or math.isnan(vol):
                continue
            if abs(z) > 2.0:
                signals.append(Signal.from_score(
                    m, score=-z, edge_per_stdev=20,
                    horizon="3d", expected_expected_vol_bps=max(vol * 100, 50),
                ))
        return signals

This strategy only sees AAPL, MSFT, NVDA because asset_classes = [Equity].

Prediction market strategy (probability edge)

python
from horizon.features import Price

class PredictionTrader(Strategy):
    name = "pred_trader"
    asset_classes = [Prediction]
    features = {"price": Price()}

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            price = f.price[m.id]
            if math.isnan(price):
                continue

            # Simple model: if price is far from 0.5, there's probably
            # an overreaction we can fade
            if price < 0.35:
                # Market says unlikely, we think it's underpriced
                signals.append(Signal.increase(
                    m, confidence=0.6,
                    edge_bps=(0.50 - price) * 10000,  # edge = distance from 0.5
                    expected_vol_bps=200,
                    horizon="7d",
                    reason=f"underpriced at {price:.2f}",
                ))
            elif price > 0.65:
                # Market says likely, we think it's overpriced
                signals.append(Signal.decrease(
                    m, confidence=0.6,
                    edge_bps=(price - 0.50) * 10000,
                    expected_vol_bps=200,
                    horizon="7d",
                    reason=f"overpriced at {price:.2f}",
                ))
        return signals

This strategy only sees TRUMP-2028, FED-CUT-JULY.

Perps strategy (momentum)

python
from horizon.features import Return

class PerpMomentum(Strategy):
    name = "perp_momentum"
    asset_classes = [Crypto]
    features = {"ret": Return(window=10), "vol": RealizedVol(window=20)}

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            ret, vol = f.ret[m.id], f.vol[m.id]
            if math.isnan(ret) or math.isnan(vol):
                continue
            if abs(ret) > 0.03:  # 3% move over 10 bars
                direction = Signal.increase if ret > 0 else Signal.decrease
                signals.append(direction(
                    m, confidence=min(abs(ret) * 10, 0.8),
                    edge_bps=abs(ret) * 5000,
                    expected_vol_bps=max(vol * 100, 80),
                    horizon="2d",
                    reason=f"momentum ret={ret:+.2%}",
                ))
        return signals

This strategy only sees BTC-PERP.

Step 3: Data source

We need data for all 6 markets. SyntheticGBM works for any market ID:

python
from horizon.data import SyntheticGBM

all_ids = ["AAPL", "MSFT", "NVDA", "TRUMP-2028", "FED-CUT-JULY", "BTC-PERP"]
data = SyntheticGBM(
    market_ids=all_ids,
    n_bars=252,
    seed=42,
)

In production you’d have real data feeds per asset class. For this example, synthetic data works to demonstrate the portfolio structure.

Step 4: Portfolio with per-asset-class limits

python
from horizon.portfolio import KellyOptimizer
from horizon.risk import RiskConfig, StopLoss

result = hz.run(
    mode="backtest",
    strategies=[EquityMR(), PredictionTrader(), PerpMomentum()],
    asset_classes=[Equity, Prediction, Crypto],
    universe=universe,
    portfolio=KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.5),
    risk=RiskConfig(
        max_gross_leverage=1.5,
        stop_loss=StopLoss(per_position_pct=0.05),
    ),
    data_source=data,
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)

The Kelly optimizer sees all signals from all three strategies and allocates capital across them based on edge and vol. A strong equity signal and a strong prediction signal compete for the same capital pool.

Step 5: Read the results

python
print(f"Total trades: {result.n_trades}")
print(f"Sharpe:       {result.sharpe:+.3f}")
print(f"Max DD:       {result.max_drawdown:.2%}")
print(f"Return:       {result.total_return:+.2%}")

# Per-strategy breakdown (from trade attribution)
from collections import Counter
strategy_trades = Counter()
for trade in result.trades:
    sid = getattr(trade, 'strategy_id', 'unknown')
    strategy_trades[sid] += 1

print("\nTrades per strategy:")
for name, count in strategy_trades.most_common():
    print(f"  {name}: {count}")

How asset-class filtering works

This is the key concept:

Universe: [AAPL, MSFT, NVDA, TRUMP-2028, FED-CUT-JULY, BTC-PERP]

EquityMR          sees: [AAPL, MSFT, NVDA]           → equity signals
PredictionTrader  sees: [TRUMP-2028, FED-CUT-JULY]   → prediction signals
PerpMomentum      sees: [BTC-PERP]                    → perp signals

All signals → KellyOptimizer → unified target positions → executors

Each strategy only processes the markets it understands. The optimizer handles cross-asset capital allocation. You never write if m.asset_class == Equity inside your strategy - the engine filters for you.

Building a custom multi-asset sizer

If you want per-asset-class allocation rules (e.g., max 50% in equities, max 20% in prediction markets), build a custom sizer:

python
from horizon.types import TargetPosition

class BudgetSizer:
    name = "budget"

    def __init__(self, budgets: dict):
        """budgets: {AssetClass: fraction_of_equity}"""
        self.budgets = budgets  # e.g., {Equity: 0.5, Prediction: 0.2, Crypto: 0.1}

    def optimize(self, signals, current_positions, cash, cov, constraints):
        if not signals:
            return []

        equity = cash.total_equity_usd
        targets = []

        # Group signals by asset class (from the market metadata)
        by_class = {}
        for sig in signals:
            # You'd look up the asset class from the market;
            # for now, infer from the signal's strategy_id or market_id
            ac = self._infer_class(sig.market_id)
            by_class.setdefault(ac, []).append(sig)

        for ac, sigs in by_class.items():
            budget = equity * self.budgets.get(ac, 0.1)
            per_signal = budget / len(sigs)
            for sig in sigs:
                targets.append(TargetPosition(
                    market_id=sig.market_id,
                    target_notional_usd=per_signal * sig.direction.sign * sig.confidence,
                ))

        return targets

    def _infer_class(self, market_id):
        if market_id in ("BTC-PERP",):
            return "perp"
        if market_id in ("TRUMP-2028", "FED-CUT-JULY"):
            return "pred"
        return "equity"

Use it: portfolio=BudgetSizer({Equity: 0.5, Prediction: 0.3, Crypto: 0.2})

Key takeaways

  • One universe, multiple strategies: each strategy declares its asset classes and only sees those markets
  • One optimizer: all signals compete for the same capital pool
  • Per-asset-class rules: either through PortfolioConstraints or a custom sizer
  • Direction is neutral: Signal.increase means “go long” regardless of asset class - the executor translates

Next