# Multidimensional research (/features/tooling/multidimensional-research)

How does VBT test thousands of strategy variants in one pass, and how do you find your way around
the result? Every asset, parameter value, and strategy becomes a labeled column. Inputs broadcast
against each other, computation runs on NumPy arrays, and results come back as pandas objects you
can select, group, and stack like any DataFrame.

```python title="Run 3 assets × 4 fast windows × 4 slow windows as 48 columns"
>>> from itertools import product

>>> data = vbt.YFData.pull(
...     ["BTC-USD", "ETH-USD", "SOL-USD"],
...     start="2022-01-01",
...     end="2025-01-01",
... )
>>> fast_windows, slow_windows = zip(*product([5, 10, 15, 20], [30, 50, 70, 90]))
>>> fast = vbt.MA.run(data.close, window=list(fast_windows), short_name="fast")
>>> slow = vbt.MA.run(data.close, window=list(slow_windows), short_name="slow")
>>> entries = fast.ma_crossed_above(slow)
>>> exits = fast.ma_crossed_below(slow)
>>> entries.shape
(1096, 48)
>>> entries.columns[:4]
MultiIndex([(5, 30, 'BTC-USD'),
            (5, 30, 'ETH-USD'),
            (5, 30, 'SOL-USD'),
            (5, 50, 'BTC-USD')],
           names=['fast_window', 'slow_window', 'symbol'])

>>> pf = vbt.PF.from_signals(data, entries, exits, fees=0.001)  # (1)
>>> pf.total_return.groupby("symbol").describe()[["mean", "min", "max"]].round(3)
          mean    min    max
symbol
BTC-USD  0.758  0.174  1.481
ETH-USD  0.051 -0.195  0.474
SOL-USD  1.394  0.104  4.285

>>> print(round(pf[(10, 50, "ETH-USD")].total_return, 4))  # (2)
0.1389
>>> pf.xs("ETH-USD", level="symbol", axis=1).wrapper.shape  # (3)
(1096, 16)
```

1.  Three columns of prices broadcast against 48 columns of signals. Each price column is reused for
    its 16 parameter pairs.
2.  One backtest, selected by its labels.
3.  All 16 parameter pairs of one asset.

Each column is a complete backtest with its own orders, trades, and metrics, and the column labels
carry every parameter, so results can be summarized with ordinary pandas.

That lets you ask several questions of the same experiment: which assets behave consistently, which
parameter ranges hold up, and how much the result changes under different cost assumptions. You keep
the labels from the inputs through to the final trades and performance tables.

## The research model \[#the-research-model]

A VBT object is a NumPy array plus a wrapper that remembers the index, the columns, the frequency,
and any grouping. Computation runs on the raw arrays in compiled code, and results are wrapped back
into pandas with the right labels, whether the result is a time series, one value per column, or one
value per group. Series and DataFrames go through the same code, so a function written for one asset
works for a thousand.

For market data, each feature has its own array: close prices in one table, highs in another, and
entry signals in another. Columns identify symbols and experiments. A VBT data object can supply its
price features directly, as in the opening example, or you can pass separate arrays from your own
data pipeline.

## Broadcasting \[#broadcasting]

Inputs of different shapes are combined by broadcasting: a scalar applies everywhere, a Series
applies to every column, a one-row DataFrame applies to every bar, and a full DataFrame applies
element by element. Labeled columns are matched by their levels, so three price columns line up with
48 signal columns that share the `symbol` level, as above. Small inputs stay small: inside the
simulation, a scalar fee is read as a scalar rather than expanded into a full array.

A one-dimensional NumPy array is a time series, so it must have one value per bar. Per-column values
go in a one-row array such as `np.array([[0.001, 0.002, 0.003]])`, or a labeled object. Rows are
matched by their dates: inputs with different dates are joined on all dates and filled with NaN, so
data on different timeframes must be resampled to one index first, as the
[Multi-timeframe analysis](/features/data/multi-timeframe-analysis/) page shows.

Plain pandas operators match labels exactly, so comparing two indicator outputs with different
parameter levels fails. Putting `.vbt` in front of the left operand applies VBT's broadcasting
instead, as the [Trading signals](/features/indicators/trading-signals/#combining-conditions) page
shows.

Match price and signal columns for one backtest per column, or expand your experiment with the
DataFrame product below. It lets you compare combinations while keeping each symbol attached to its
own prices.

The [DataFrame product](#dataframe-product) highlight below combines every column of one DataFrame
with every column of another, and [Index dictionaries](#index-dictionaries) set values at chosen
dates and columns without building an array first.

### Compare assumptions as labeled scenarios \[#compare-assumptions-as-labeled-scenarios]

Parameters can describe execution assumptions as well as indicators. Here, two made-up price series
are tested with no fees and a 1% fee. Each fee becomes another column level, so the returns can be
reshaped into a table without collecting results from separate runs:

```python title="Compare two fee assumptions across two assets"
>>> close = pd.DataFrame(
...     {"A": [100.0, 105.0, 110.0], "B": [100.0, 98.0, 95.0]},
...     index=pd.date_range("2025-01-01", periods=3),
... ).rename_axis(columns="symbol")
>>> scenarios = vbt.PF.from_holding(close, fees=vbt.Param([0.0, 0.01], name="fee"))
>>> scenarios.total_return.unstack("symbol").round(4)
symbol       A       B
fee
0.00    0.1000 -0.0500
0.01    0.0891 -0.0594
```

These are total returns as fractions, with the positions still open at the end. The fee reduces how
much each backtest can buy at entry. The same approach lets you compare slippage or sizing
assumptions. [Parameter optimization](/features/optimization/strategy-optimization/) covers larger
searches, conditional combinations, and random samples.

## Grouping \[#grouping]

Columns can be grouped for reporting or for simulation. `group_by` combines columns into groups by a
level, a list of labels, or `True` for everything, and `cash_sharing=True` turns a group into one
account. Grouping set after the fact changes only how results are reported, so the same backtests
can be read per asset, per parameter value, or as a whole, as long as each group's columns are next
to each other.

In the opening example, the pandas summary groups the 16 returns for each symbol to compare
parameter choices. That is different from grouping the portfolio itself, which can report the
performance of several columns held together. Use
[Portfolio accounting](/features/backtesting/portfolio-accounting/) for shared-capital portfolios
and [Multi-strategy portfolios](/features/strategies/multi-strategy-portfolios/) for allocating
between separate strategies.

## Stacking runs \[#stacking-runs]

Separate runs can be joined into one object. Column stacking puts different experiments side by
side, for example a strategy grid next to buy-and-hold:

```python title="Put buy-and-hold next to the 48 crossover backtests"
>>> hold_pf = vbt.PF.from_holding(data)
>>> both = vbt.PF.column_stack(
...     pf,
...     hold_pf,
...     wrapper_kwargs=dict(keys=pd.Index(["crossover", "hold"], name="strategy")),
... )
>>> both.wrapper.shape
(1096, 51)
>>> both.total_return["hold"].round(3)
BTC-USD    0.959
ETH-USD   -0.116
SOL-USD    0.060
Name: total_return, dtype: float64
```

Row stacking joins consecutive periods, such as the test windows of a walk-forward run, into one
history. Stacked portfolios keep their records and recompute cash and value from them. Each window
simulated on its own starts with fresh capital: the first window's initial cash becomes the stacked
portfolio's, and each later window's initial cash is added as a deposit at its start, so returns
stay correct while the value jumps. `combine_init_cash=True` counts all of it as initial cash
instead, which leaves the later windows' cash idle during the earlier ones and dilutes their
returns. To carry one account through every window, continue each window from the previous one's
final state and stack them with `chained=True`, as on the
[Live simulation](/features/backtesting/live-simulation/) page. The
[Column stacking](#column-stacking) and [Row stacking](#row-stacking) highlights below show both on
full portfolios.

This also lets you work with a long history in smaller segments, or simulate data windows around
groups of entry and exit events. Keep the intended cash and position state at the boundaries, then
row-stack the results for analysis. For large sets of independent experiments,
[Parallel execution and caching](/features/performance/parallel-execution-and-caching/) covers
splitting the work into batches.

## Indexing \[#indexing]

Every VBT object can be indexed like pandas: by column labels, by `.loc` and `.iloc` on dates and
positions, by parameter level, or with `xs`. Slicing a portfolio by date gives a new portfolio for
that period without rerunning the simulation, with records and metrics adjusted to the window.
Setters work the same way in reverse: assign values by date, time of day, label, or mask with the
data's wrapper. The [Slicing](#slicing) and [Index alignment](#index-alignment) highlights below
show both.

Select a symbol or parameter combination before inspecting its orders, plotting its value, or
comparing its drawdowns. The labels let you move from a summary table back to the backtest that
produced an interesting result. You can also rename, reorder, or drop index levels to keep reports
readable when an experiment has many dimensions.

## Pandas accessors \[#pandas-accessors]

Importing VBT adds a `.vbt` accessor to every Series, DataFrame, and Index, with specialized
namespaces such as `.vbt.returns`, `.vbt.signals`, and `.vbt.ohlcv`. Data stays a regular pandas
object, so VBT operations and pandas operations can be mixed in one line. Because the accessors are
registered at runtime, some editors do not complete them. Notebooks do. `vbt.df_acc(df)` and
`vbt.sr_acc(sr)` return the same accessors as explicit objects, which editors can introspect.

## DataFrame product \[#dataframe-product]

New in 1.13.0

✅ Several parameterized indicators can produce DataFrames with different shapes and columns, which
makes creating a Cartesian product tricky because they often share common column levels (such as
"symbol") that should not be combined. There is now a method to cross-join multiple DataFrames
block-wise.

```python title="Enter when SMA goes above WMA, exit when EMA goes below WMA"
>>> data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], missing_index="drop")
>>> sma = data.run("sma", timeperiod=[10, 20], unpack=True)
>>> ema = data.run("ema", timeperiod=[30, 40], unpack=True)
>>> wma = data.run("wma", timeperiod=[50, 60], unpack=True)
>>> sma, ema, wma = sma.vbt.x(ema, wma)  # (1)
>>> entries = sma.vbt.crossed_above(wma)
>>> exits = ema.vbt.crossed_below(wma)

>>> entries.columns
MultiIndex([(10, 30, 50, 'BTC-USD'),
            (10, 30, 50, 'ETH-USD'),
            (10, 30, 60, 'BTC-USD'),
            (10, 30, 60, 'ETH-USD'),
            (10, 40, 50, 'BTC-USD'),
            (10, 40, 50, 'ETH-USD'),
            (10, 40, 60, 'BTC-USD'),
            (10, 40, 60, 'ETH-USD'),
            (20, 30, 50, 'BTC-USD'),
            (20, 30, 50, 'ETH-USD'),
            (20, 30, 60, 'BTC-USD'),
            (20, 30, 60, 'ETH-USD'),
            (20, 40, 50, 'BTC-USD'),
            (20, 40, 50, 'ETH-USD'),
            (20, 40, 60, 'BTC-USD'),
            (20, 40, 60, 'ETH-USD')],
           names=['sma_timeperiod', 'ema_timeperiod', 'wma_timeperiod', 'symbol'])
```

1.  Build a Cartesian product of three DataFrames while keeping the column level "symbol" untouched.
    This can also be done with `vbt.pd_acc.cross(sma, ema, wma)`.

## Index dictionaries \[#index-dictionaries]

New in 1.3.0

Manually creating arrays and setting their data with Pandas can often be challenging. Luckily, there
is now a feature that offers much-needed assistance! Any broadcastable argument can become an index
dictionary, which contains instructions on where to set values in the array and fills them in for
you. It knows exactly which axis needs to be updated and does not create a full array unless
necessary, saving RAM ❤️

```python title="1) Accumulate daily and exit on Sunday vs 2) accumulate weekly and exit on month end"
>>> data = vbt.YFData.pull(["BTC-USD", "ETH-USD"])
>>> tile = pd.Index(["daily", "weekly"], name="strategy")  # (1)
>>> pf = vbt.PF.from_orders(
...     data.close,
...     size=vbt.index_dict({  # (2)
...         vbt.idx(
...             vbt.pointidx(every="day"),
...             vbt.colidx("daily", level="strategy")): 100,  # (3)
...         vbt.idx(
...             vbt.pointidx(every="sunday"),
...             vbt.colidx("daily", level="strategy")): -np.inf,  # (4)
...         vbt.idx(
...             vbt.pointidx(every="monday"),
...             vbt.colidx("weekly", level="strategy")): 100,
...         vbt.idx(
...             vbt.pointidx(every="monthend"),
...             vbt.colidx("weekly", level="strategy")): -np.inf,
...     }),
...     size_type="value",
...     direction="longonly",
...     init_cash="auto",
...     broadcast_kwargs=dict(tile=tile)
... )
>>> pf.sharpe_ratio
strategy  symbol
daily     BTC-USD    0.702259
          ETH-USD    0.782296
weekly    BTC-USD    0.838895
          ETH-USD    0.524215
Name: sharpe_ratio, dtype: float64
```

1.  To represent two strategies, you need to tile the same data twice. Create a parameter with
    strategy names and pass it as `tile` to the broadcaster so it tiles the columns of each array
    (such as price) twice.
2.  The index dictionary includes index instructions as keys and data as values to set. Keys can be
    row indices, labels, or custom indexer classes such as `PointIdxr`.
3.  Find the indices of the rows for the start of each day and the column index of "daily", then set
    each element at those indices to 100 (= accumulate).
4.  Find the indices of the rows that correspond to Sunday. If any value at those indices has already
    been set by a previous instruction, it will be overridden.

## Slicing \[#slicing]

New in 1.3.0

✅ Similar to selecting columns, each VBT object can now slice rows using the same mechanism as in
Pandas 🔪 This makes it easy to analyze and plot any subset of simulated data, without needing to
re-simulate!

```python title="Analyze multiple date ranges of the same portfolio"
>>> data = vbt.YFData.pull("BTC-USD")
>>> pf = vbt.PF.from_holding(data, freq="d")

>>> pf.sharpe_ratio
1.116727709477293

>>> pf.loc[:"2020"].sharpe_ratio  # (1)
1.2699801554196481

>>> pf.loc["2021": "2021"].sharpe_ratio  # (2)
0.9825161170278687

>>> pf.loc["2022":].sharpe_ratio  # (3)
-1.0423271337174647
```

1.  Get the Sharpe ratio during the year 2020 and before.
2.  Get the Sharpe ratio during the year 2021.
3.  Get the Sharpe ratio during the year 2022 and after.

## Column stacking \[#column-stacking]

New in 1.3.0

✅ Complex VBT objects of the same type can be easily stacked along columns. For example, you can
combine multiple unrelated trading strategies into one portfolio for analysis. Under the hood, the
final object is still represented as a monolithic multi-dimensional structure that can be processed
even faster than separate merged objects 🫁

```python title="Analyze two trading strategies separately and then jointly"
>>> def strategy1(data):
...     fast_ma = vbt.MA.run(data.close, 50, short_name="fast_ma")
...     slow_ma = vbt.MA.run(data.close, 200, short_name="slow_ma")
...     entries = fast_ma.ma_crossed_above(slow_ma)
...     exits = fast_ma.ma_crossed_below(slow_ma)
...     return vbt.PF.from_signals(
...         data.close,
...         entries,
...         exits,
...         size=100,
...         size_type="value",
...         init_cash="auto"
...     )

>>> def strategy2(data):
...     bbands = vbt.BBANDS.run(data.close, window=14)
...     entries = bbands.close_crossed_below(bbands.lower)
...     exits = bbands.close_crossed_above(bbands.upper)
...     return vbt.PF.from_signals(
...         data.close,
...         entries,
...         exits,
...         init_cash=200
...     )

>>> data1 = vbt.BinanceData.pull("BTCUSDT")
>>> pf1 = strategy1(data1)  # (1)
>>> pf1.sharpe_ratio
0.9100317671866922

>>> data2 = vbt.BinanceData.pull("ETHUSDT")
>>> pf2 = strategy2(data2)  # (2)
>>> pf2.sharpe_ratio
-0.11596286232734827

>>> pf_sep = vbt.PF.column_stack((pf1, pf2))  # (3)
>>> pf_sep.sharpe_ratio
0    0.910032
1   -0.115963
Name: sharpe_ratio, dtype: float64

>>> pf_join = vbt.PF.column_stack((pf1, pf2), group_by=True)  # (4)
>>> pf_join.sharpe_ratio
0.42820898354646514
```

1.  Analyze the first strategy in its own portfolio.
2.  Analyze the second strategy in its own portfolio.
3.  Analyze both strategies separately in the same portfolio.
4.  Analyze both strategies jointly in the same portfolio.

## Row stacking \[#row-stacking]

New in 1.3.0

✅ Complex VBT objects of the same type can be easily stacked along rows. For example, you can append
new data to an existing portfolio, or concatenate in-sample portfolios with their out-of-sample
counterparts 🧬

```python title="Analyze two date ranges separately and then jointly"
>>> def strategy(data, start=None, end=None):
...     fast_ma = vbt.MA.run(data.close, 50, short_name="fast_ma")
...     slow_ma = vbt.MA.run(data.close, 200, short_name="slow_ma")
...     entries = fast_ma.ma_crossed_above(slow_ma)
...     exits = fast_ma.ma_crossed_below(slow_ma)
...     return vbt.PF.from_signals(
...         data.close[start:end],
...         entries[start:end],
...         exits[start:end],
...         size=100,
...         size_type="value",
...         init_cash="auto"
...     )

>>> data = vbt.BinanceData.pull("BTCUSDT")

>>> pf_whole = strategy(data)  # (1)
>>> pf_whole.sharpe_ratio
0.9100317671866922

>>> pf_sub1 = strategy(data, end="2019-12-31")  # (2)
>>> pf_sub1.sharpe_ratio
0.7810397448678937

>>> pf_sub2 = strategy(data, start="2020-01-01")  # (3)
>>> pf_sub2.sharpe_ratio
1.070339534746574

>>> pf_join = vbt.PF.row_stack((pf_sub1, pf_sub2))  # (4)
>>> pf_join.sharpe_ratio
0.9100317671866922
```

1.  Analyze the entire range.
2.  Analyze the first date range.
3.  Analyze the second date range.
4.  Combine both date ranges and analyze them together.

## Index alignment \[#index-alignment]

New in 1.3.0

✅ There is no longer a limitation requiring each Pandas array to have the same index. Indexes of all
arrays that should broadcast against each other are automatically aligned, as long as they have the
same data type.

```python title="Predict ETH price with BTC price using linear regression"
>>> btc_data = vbt.YFData.pull("BTC-USD")
>>> btc_data.wrapper.shape
(2817, 7)

>>> eth_data = vbt.YFData.pull("ETH-USD")  # (1)
>>> eth_data.wrapper.shape
(1668, 7)

>>> ols = vbt.OLS.run(  # (2)
...     btc_data.close,
...     eth_data.close
... )
>>> ols.pred
Date
2014-09-17 00:00:00+00:00            NaN
2014-09-18 00:00:00+00:00            NaN
2014-09-19 00:00:00+00:00            NaN
2014-09-20 00:00:00+00:00            NaN
2014-09-21 00:00:00+00:00            NaN
...                                  ...
2022-05-30 00:00:00+00:00    2109.769242
2022-05-31 00:00:00+00:00    2028.856767
2022-06-01 00:00:00+00:00    1911.555689
2022-06-02 00:00:00+00:00    1930.169725
2022-06-03 00:00:00+00:00    1882.573170
Freq: D, Name: Close, Length: 2817, dtype: float64
```

1.  ETH-USD history is shorter than BTC-USD history.
2.  This now works! Make sure all arrays share the same timeframe and timezone.


## Related pages

*   [Fundamentals](/documentation/fundamentals/) (Recommended): Understand the core concepts behind VBT's vectorized architecture
*   [Backtesting engine](/features/backtesting/backtesting-engine/): Simulate orders, signals, and callbacks across many assets and parameters at once
*   [Data pipelines](/features/data/financial-data-pipelines/): Align, update, transform, and analyze multi-symbol market data in one object
*   [Parameter optimization](/features/optimization/strategy-optimization/): Sweep millions of parameter combinations with grids, conditions, and random search