Multi-assetCross-sectional

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.

>>>.signal("rank", col("mom").cs_rank())

Every symbol gets its place in the cross-section, on every bar.

Cross-sectional
every instrument ranked against the others, on the same bar
Cross-asset
any other instrument's data, inside any expression
One account
positions compete for the same capital and margin
Any OHLCV
equities, futures, FX or crypto: the same simulation

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)
mbt.plot.correlation_matrix(symbols, matrix)
Correlation matrix of daily returns across asset classes from 2018 to 2024: equities, emerging markets, treasuries, gold, silver, energy, oil, natural gas, broad commodities and bitcoin, with values from -0.25 between treasuries and energy up to 0.85 between oil and broad commodities
Ten instruments across seven asset classes, daily returns from 2018 to 2024. Oil and broad commodities move together at 0.85, equities and emerging markets at 0.76, gold and silver at 0.78, while treasuries run negative against everything equity-like and natural gas is uncorrelated with the lot. A universe is a structure, not a list, and the structure is what a cross-sectional rule reads.

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.

mbt.plot.summary(res)
Three-panel summary of the nine-symbol basket: time-weighted return against a volatility-adjusted buy and hold, trades per day, and used margin
The basket, panel by panel: return against a volatility-adjusted buy and hold, the trades it took, and the margin it actually used. Note the benchmark line: for most of 2023 and 2024 simply holding the universe beat the ranking, and the strategy only pulls ahead at the end. A figure that did not show you that would be a worse figure.
 Basket, 9 symbolsOne instrument
Sharpe0.660.24
Total return+20.6%+2.5%
Max drawdown-24.7%-6.9%
Annualised volatility16.1%5.8%
Trades1,25582
Wall time17 ms0.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

ranking

cs_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

reference

symbol_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

portfolio

Capital, 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.