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
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)
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)
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)
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:
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
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
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:
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.increasemeans “go long” regardless of asset class - the executor translates