One universe, one account, one call
Most engines backtest a symbol. A relative-value strategy is not about a symbol: it is about where each instrument stands against the others right now. Here the universe is part of the config, ranking across it is part of the language, and every position competes for one account with one margin balance, which is the only version of the question worth answering. Equities, futures, FX or crypto: the engine takes any market that arrives as OHLCV bars.
Every symbol gets its place in the cross-section, on every bar.
The cross-section is the signal
A momentum number on its own says almost nothing once you know the universe moves together, and blocks of a universe usually do. What carries information is the ordering: cs_rank compares each symbol against the rest of the universe on the same bar and returns its place, from 0 for the weakest to 1 for the strongest.
import manifoldbt as mbt
from manifoldbt.expr import col
from manifoldbt.indicators import close, ema, roc
mom = ema(roc(close, 14), 6)
strategy = (
mbt.Strategy.create("cs_momentum")
.signal("mom", mom)
.signal("rank", col("mom").cs_rank()) # 0 = weakest, 1 = strongest
.size(mbt.when(col("rank") > 0.8, 1.0,
mbt.when(col("rank") < 0.2, -1.0, 0.0)) * 0.12)
)
# Bring the instruments in however you like: an exchange connector, Databento
# or Massive for equities, futures and options, or your own bars.
for i, ticker in enumerate(["SPY", "EEM", "TLT", "GLD", "SLV",
"XLE", "USO", "UNG", "DBC"], start=1):
store = mbt.import_csv(f"{ticker}_1d.csv", symbol=ticker, symbol_id=i,
interval="1d", asset_class="equity")
config = mbt.BacktestConfig(
universe=list(range(1, 10)), # or {"provider": [...]} for a connector
initial_capital=100_000,
execution=mbt.ExecutionConfig(allow_short=True, max_position_pct=0.15,
signal_delay=1),
# ... time range, bars, fees, slippage
)
res = mbt.run(strategy, config, store)
What the basket does that one symbol cannot
The same momentum rule, run twice: once on a single instrument, once as a ranked long-short basket over a universe of nine. Same engine, same fees, same two years of 12-hour bars, one shared account of 100,000. The demo store behind these two runs holds crypto perpetuals, which is incidental: no part of the rule, the ranking or the accounting knows what the instruments are.

| Basket, 9 symbols | One instrument | |
|---|---|---|
| Sharpe | 0.66 | 0.24 |
| Total return | +20.6% | +2.5% |
| Max drawdown | -24.7% | -6.9% |
| Annualised volatility | 16.1% | 5.8% |
| Trades | 1,255 | 82 |
| Wall time | 17 ms | 0.6 ms |
Read the volatility line before the return line. The basket makes eight times the return of the single-symbol version, and runs at nearly three times the volatility and three and a half times the drawdown, because nine correlated positions sized against one account is a larger bet, not a diversified one.
That is the honest reason to backtest a universe rather than a symbol: the portfolio effects, the competition for capital and the correlation you cannot see from one instrument are the things that decide whether the idea survives, and they only exist in a run that models all of it at once.
Cost is not the thing stopping you. On hourly bars over the same two years, one market takes 0.56 ms, eight take 7.7 ms and twenty-two take 24 ms: about a millisecond per market, flat, because what the engine pays for is bars rather than names. A two-hundred-point parameter sweep over an eight-market universe finishes in 0.11 s, which is roughly 1,800 portfolio backtests a second.
Three things that make it a universe rather than a loop
Running the same strategy nine times and adding up the equity curves is not a portfolio backtest. These are the parts that cannot be reconstructed afterwards.
Cross-sectional, not per symbol
rankingcs_rank and cs_mean look sideways across the universe on each bar rather than backwards through one symbol's history. The rank a symbol gets depends on what the other eight are doing at that moment, which is what makes a relative-value rule expressible at all.
Any symbol from any expression
referencesymbol_ref pulls a column from another market in the universe, so a spread, a ratio or a regime filter driven by BTC is ordinary arithmetic inside the strategy rather than a second pipeline you assemble beforehand.
One account, not nine backtests
portfolioCapital, margin, exposure and fees are computed at the portfolio level. Nine symbols competing for the same capital is the thing being simulated, not nine separate runs added up afterwards, which is the only way position limits and margin mean anything.
Pairs are the two-symbol case
Nothing about a pairs trade needs a different API. Reference the other leg with symbol_ref, build the spread with arithmetic, z-score it, and size on the result. The same machinery that ranks nine markets holds two of them against each other.
For the full version, with a Kalman equilibrium instead of a plain ratio, the expression DSL page walks through the composition.
# A pair is the same idea with two names instead of a ranking:
# gold against silver, an index against its sector, two currencies.
other = mbt.symbol_ref("SLV", "close")
ratio = close / (other + mbt.lit(1e-12))
strategy = (
mbt.Strategy.create("gld_slv_pair")
.signal("spread_z", ratio.zscore(48))
.size(mbt.when(col("spread_z") > 1.5, -0.2,
mbt.when(col("spread_z") < -1.5, 0.2, 0.0)))
)Four rules worth knowing first
A cross-sectional op takes a name
col("mom").cs_rank() works, ema(roc(close, 14), 6).cs_rank() does not. Name the series with .signal() first, then rank the name: the engine has to evaluate that column for every symbol before it can compare them.
Qualify the symbol you reference
symbol_ref("provider:INSTRUMENT", "close") wants the provider-qualified name, and the instrument has to be in the universe. A reference to something you did not load is an error, not an empty series.
Position limits are portfolio limits
max_position_pct is a share of the account, not of a per-symbol allocation. On nine symbols with a 15% cap, a rule that wants everything at once is capped by the account rather than by the instrument.
Some multi-asset shapes stay on the CPU
The GPU sweep covers multi-asset universes, but a few combinations, exit orders on a multi-asset universe among them, fall back to the CPU path. The fallback names itself rather than running quietly at a twentieth of the speed.
FAQ
Backtest the portfolio you would actually run
Multi-asset universes, cross-sectional operations and cross-asset references are in the free version, whatever the instruments are. Mixing venues in one account, and the Databento and Massive connectors, are the parts that need Pro.