diff --git a/backtesting/_plotting.py b/backtesting/_plotting.py
index fc3a88b1..2a70b6c4 100644
--- a/backtesting/_plotting.py
+++ b/backtesting/_plotting.py
@@ -5,11 +5,10 @@
 from colorsys import hls_to_rgb, rgb_to_hls
 from itertools import cycle, combinations
 from functools import partial
-from typing import Callable, List, Union
+from typing import Callable, List, Union, Optional
 
 import numpy as np
 import pandas as pd
-
 from bokeh.colors import RGB
 from bokeh.colors.named import (
     lime as BULL_COLOR,
@@ -109,11 +108,11 @@ def _maybe_resample_data(resample_rule, df, indicators, equity_data, trades):
             "15T": 15,
             "30T": 30,
             "1H": 60,
-            "2H": 60*2,
-            "4H": 60*4,
-            "8H": 60*8,
-            "1D": 60*24,
-            "1W": 60*24*7,
+            "2H": 60 * 2,
+            "4H": 60 * 4,
+            "8H": 60 * 8,
+            "1D": 60 * 24,
+            "1W": 60 * 24 * 7,
             "1M": np.inf,
         })
         timespan = df.index[-1] - df.index[0]
@@ -147,6 +146,7 @@ def f(s, new_index=pd.Index(df.index.view(int)), bars=trades[column]):
                 mean_time = int(bars.loc[s.index].view(int).mean())
                 new_bar_idx = new_index.get_loc(mean_time, method='nearest')
                 return new_bar_idx
+
         return f
 
     if len(trades):  # Avoid pandas "resampling on Int64 index" error
@@ -162,7 +162,8 @@ def f(s, new_index=pd.Index(df.index.view(int)), bars=trades[column]):
 
 
 def plot(*, results: pd.Series,
-         df: pd.DataFrame,
+         df: Union[pd.DataFrame, dict[str, pd.DataFrame]],
+         instruments: Optional[list[str]] = None,
          indicators: List[_Indicator],
          filename='', plot_width=None,
          plot_equity=True, plot_return=False, plot_pl=True,
@@ -174,6 +175,7 @@ def plot(*, results: pd.Series,
     """
     Like much of GUI code everywhere, this is a mess.
     """
+    # fixme
     # We need to reset global Bokeh state, otherwise subsequent runs of
     # plot() contain some previous run's cruft data (was noticed when
     # TestPlot.test_file_size() test was failing).
diff --git a/backtesting/_stats.py b/backtesting/_stats.py
index 8435605c..646a23e9 100644
--- a/backtesting/_stats.py
+++ b/backtesting/_stats.py
@@ -33,15 +33,16 @@ def geometric_mean(returns: pd.Series) -> float:
 
 
 def compute_stats(
-        trades: Union[List['Trade'], pd.DataFrame],
+        trades: Union[List[Trade], pd.DataFrame],
         equity: np.ndarray,
-        ohlc_data: pd.DataFrame,
-        strategy_instance: 'Strategy',
+        ohlc_data: dict[str, pd.DataFrame],
+        index: pd.Index,
+        strategy_instance: Strategy,
         risk_free_rate: float = 0,
 ) -> pd.Series:
     assert -1 < risk_free_rate < 1
+    is_single_instrument = len(ohlc_data) == 1
 
-    index = ohlc_data.index
     dd = 1 - equity / np.maximum.accumulate(equity)
     dd_dur, dd_peaks = compute_drawdown_duration_peaks(pd.Series(dd, index=index))
 
@@ -92,8 +93,10 @@ def _round_timedelta(value, _period=_data_period(index)):
     s.loc['Equity Final [$]'] = equity[-1]
     s.loc['Equity Peak [$]'] = equity.max()
     s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
-    c = ohlc_data.Close.values
-    s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100  # long-only return
+
+    if is_single_instrument:
+        c = ohlc_data['default_instrument'].Close.values
+        s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100  # long-only return
 
     gmean_day_return: float = 0
     day_returns = np.array(np.nan)
diff --git a/backtesting/_util.py b/backtesting/_util.py
index 38078be1..321863a7 100644
--- a/backtesting/_util.py
+++ b/backtesting/_util.py
@@ -1,6 +1,6 @@
 import warnings
-from typing import Dict, List, Optional, Sequence, Union, cast
 from numbers import Number
+from typing import Dict, List, Optional, Sequence, Union, cast
 
 import numpy as np
 import pandas as pd
@@ -45,6 +45,7 @@ class _Array(np.ndarray):
     ndarray extended to supply .name and other arbitrary properties
     in ._opts dict.
     """
+
     def __new__(cls, array, *, name=None, **kwargs):
         obj = np.asarray(array).view(cls)
         obj.name = name or array.name
@@ -107,14 +108,93 @@ class _Data:
     and the returned "series" are _not_ `pd.Series` but `np.ndarray`
     for performance reasons.
     """
-    def __init__(self, df: pd.DataFrame):
-        self.__df = df
-        self.__i = len(df)
+
+    def __init__(self, data: Union[pd.DataFrame, dict[str, pd.DataFrame]]):
+        self._is_single_instrument = isinstance(data, pd.DataFrame)
+        if self._is_single_instrument:
+            # internally, we will always store
+            data = {'default_instrument': data}
+        index = pd.Index()
+        for instrument, instrument_data in data.items():
+            data_name = "`data`" if self._is_single_instrument else f"`data[{instrument}]`"
+            if not isinstance(instrument_data, pd.DataFrame):
+                raise TypeError(' '.join([
+                    f"{data_name} must be a pandas.DataFrame",
+                    "or a dictionary containing instrument names (`str`) and corresponding instrument data (`pd.DataFrame`)"
+                    if self.___is_single_instrument else "",
+                    "with columns"
+                ]))
+
+            instrument_data = instrument_data.copy(deep=False)
+
+            # Convert index to datetime index
+            if (not isinstance(instrument_data.index, pd.DatetimeIndex) and
+                    not isinstance(instrument_data.index, pd.RangeIndex) and
+                    # Numeric index with most large numbers
+                    (instrument_data.index.is_numeric() and
+                     (instrument_data.index > pd.Timestamp('1975').timestamp()).mean() > .8)):
+                try:
+                    instrument_data.index = pd.to_datetime(instrument_data.index, infer_datetime_format=True)
+                except ValueError:
+                    pass
+
+            if 'Volume' not in instrument_data:
+                instrument_data['Volume'] = np.nan
+
+            if len(instrument_data) == 0:
+                raise ValueError(f'{instrument_data} OHLC is empty')
+            if len(instrument_data.columns.intersection({'Open', 'High', 'Low', 'Close', 'Volume'})) != 5:
+                raise ValueError(f"{data_name} must be a pandas.DataFrame with columns "
+                                 "'Open', 'High', 'Low', 'Close', and (optionally) 'Volume'")
+            if instrument_data[['Open', 'High', 'Low', 'Close']].isnull().values.any():
+                raise ValueError('Some OHLC values are missing (NaN). '
+                                 'Please strip those lines with `df.dropna()` or '
+                                 'fill them in with `df.interpolate()` or whatever.')
+            if not instrument_data.index.is_monotonic_increasing:
+                warnings.warn(f'{data_name} index is not sorted in ascending order. Sorting.',
+                              stacklevel=2)
+                instrument_data = instrument_data.sort_index()
+            if not isinstance(instrument_data.index, pd.DatetimeIndex):
+                if self.__is_single_instrument:
+                    warnings.warn(f'{data_name} index is not datetime. Assuming simple periods, '
+                                  'but `pd.DateTimeIndex` is advised.',
+                                  stacklevel=2)
+                else:
+                    raise ValueError(f'{data_name} index is not datetime')
+            index = self._index.union(instrument_data.index)
+            data[instrument] = instrument_data.copy(deep=False)
+
+        df = pd.DataFrame()
+        instrument_data: pd.DataFrame
+        for instrument, instrument_data in data.items():
+            instrument_data.index = index
+            # if data for some instruments is available from an earlier date than others
+            # fill 0s for the other instruments' data.
+            instrument_data = instrument_data.fillna(value=0)
+            # rename columns before join
+            instrument_data.rename(
+                columns={col: col if self._is_single_instrument else f'{instrument}-{col}'
+                         for col in 'Open Low High Close Volume'.split()},
+                inplace=True
+            )
+            df = df.join(instrument_data)
+
+        self.__instruments = set(data.keys())
+        self.__df: pd.DataFrame = df
+        self.__i = len(index)
         self.__pip: Optional[float] = None
         self.__cache: Dict[str, _Array] = {}
         self.__arrays: Dict[str, _Array] = {}
         self._update()
 
+    @property
+    def is_single_instrument(self) -> bool:
+        return self._is_single_instrument
+
+    @property
+    def instruments(self) -> set[str]:
+        return self.__instruments
+
     def __getitem__(self, item):
         return self.__get_array(item)
 
@@ -138,7 +218,8 @@ def _update(self):
     def __repr__(self):
         i = min(self.__i, len(self.__df) - 1)
         index = self.__arrays['__index'][i]
-        items = ', '.join(f'{k}={v}' for k, v in self.__df.iloc[i].items())
+        items = ', '.join(f'{k}={v}'
+                          for k, v in self.__df.iloc[i].items())
         return f'<Data i={i} ({index}) {items}>'
 
     def __len__(self):
@@ -146,16 +227,21 @@ def __len__(self):
 
     @property
     def df(self) -> pd.DataFrame:
-        return (self.__df.iloc[:self.__i]
-                if self.__i < len(self.__df)
-                else self.__df)
+        return (self.__data.iloc[:self.__i]
+                if self.__i < len(self.__data)
+                else self.__data)
 
     @property
-    def pip(self) -> float:
+    def pip(self) -> Union[dict[str, float], float]:
         if self.__pip is None:
-            self.__pip = float(10**-np.median([len(s.partition('.')[-1])
-                                               for s in self.__arrays['Close'].astype(str)]))
-        return self.__pip
+            self.__pip = {instrument: float(10 ** -np.median([len(s.partition('.')[-1])
+                                                              for s in
+                                                              self.__arrays[f'{instrument}-Close'].astype(str)]))
+                          for instrument in self.instruments}
+        if self.is_single_instrument:
+            return self.__pip['default_instrument']
+        else:
+            return self.__pip
 
     def __get_array(self, key) -> _Array:
         arr = self.__cache.get(key)
@@ -164,24 +250,39 @@ def __get_array(self, key) -> _Array:
         return arr
 
     @property
-    def Open(self) -> _Array:
-        return self.__get_array('Open')
+    def Open(self) -> Union[_Array, dict[str, _Array]]:
+        if self.is_single_instrument:
+            return self.__get_array(f'default_instrument-Open')
+        else:
+            return {instrument: self.__get_array(f'{instrument}-Open') for instrument in self.instruments}
 
     @property
-    def High(self) -> _Array:
-        return self.__get_array('High')
+    def High(self) -> Union[_Array, dict[str, _Array]]:
+        if self.is_single_instrument:
+            return self.__get_array(f'default_instrument-High')
+        else:
+            return {instrument: self.__get_array(f'{instrument}-High') for instrument in self.instruments}
 
     @property
-    def Low(self) -> _Array:
-        return self.__get_array('Low')
+    def Low(self) -> Union[_Array, dict[str, _Array]]:
+        if self.is_single_instrument:
+            return self.__get_array(f'default_instrument-Low')
+        else:
+            return {instrument: self.__get_array(f'{instrument}-Low') for instrument in self.instruments}
 
     @property
-    def Close(self) -> _Array:
-        return self.__get_array('Close')
+    def Close(self) -> Union[_Array, dict[str, _Array]]:
+        if self.is_single_instrument:
+            return self.__get_array('default_instrument-Close')
+        else:
+            return {instrument: self.__get_array(f'{instrument}-Close') for instrument in self.instruments}
 
     @property
-    def Volume(self) -> _Array:
-        return self.__get_array('Volume')
+    def Volume(self) -> Union[_Array, dict[str, _Array]]:
+        if self.is_single_instrument:
+            return self.__get_array('default_instrument-Volume')
+        else:
+            return {instrument: self.__get_array(f'{instrument}-Volume') for instrument in self.instruments}
 
     @property
     def index(self) -> pd.DatetimeIndex:
diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py
index 742a40f2..3546837c 100644
--- a/backtesting/backtesting.py
+++ b/backtesting/backtesting.py
@@ -5,6 +5,8 @@
 
     from backtesting import Backtest, Strategy
 """
+from __future__ import annotations
+
 import multiprocessing as mp
 import os
 import sys
@@ -24,6 +26,7 @@
 
 try:
     from tqdm.auto import tqdm as _tqdm
+
     _tqdm = partial(_tqdm, leave=False)
 except ImportError:
     def _tqdm(seq, **_):
@@ -49,12 +52,21 @@ class Strategy(metaclass=ABCMeta):
     `backtesting.backtesting.Strategy.next` to define
     your own strategy.
     """
-    def __init__(self, broker, data, params):
-        self._indicators = []
+
+    def __init__(self, broker: _Broker, data: _Data, params):
+        self._indicators: list[_Indicator] = []
         self._broker: _Broker = broker
         self._data: _Data = data
         self._params = self._check_params(params)
 
+    @property
+    def is_single_instrument(self) -> bool:
+        return self._data.is_single_instrument
+
+    @property
+    def instruments(self) -> set[str]:
+        return self._data.instruments
+
     def __repr__(self):
         return '<Strategy ' + str(self) + '>'
 
@@ -139,14 +151,18 @@ def init():
         if is_arraylike and np.argmax(value.shape) == 0:
             value = value.T
 
-        if not is_arraylike or not 1 <= value.ndim <= 2 or value.shape[-1] != len(self._data.Close):
+        if not is_arraylike or not 1 <= value.ndim <= 2 or value.shape[-1] != len(self._data.index):
             raise ValueError(
                 'Indicators must return (optionally a tuple of) numpy.arrays of same '
-                f'length as `data` (data shape: {self._data.Close.shape}; indicator "{name}"'
-                f'shape: {getattr(value, "shape" , "")}, returned value: {value})')
+                f'length as `data` (data shape: {self._data.index.shape}; indicator "{name}"'
+                f'shape: {getattr(value, "shape", "")}, returned value: {value})')
 
         if plot and overlay is None and np.issubdtype(value.dtype, np.number):
-            x = value / self._data.Close
+            if self._data.is_single_instrument:
+                x = value / self._data['default_instrument'].Close
+            else:
+                # fixme: check if it is okay omit normalizing
+                x = value  # x = value / self._data.Close
             # By default, overlay if strong majority of indicator values
             # is within 30% of Close
             with np.errstate(invalid='ignore'):
@@ -192,9 +208,11 @@ def next(self):
 
     class __FULL_EQUITY(float):
         def __repr__(self): return '.9999'
+
     _FULL_EQUITY = __FULL_EQUITY(1 - sys.float_info.epsilon)
 
     def buy(self, *,
+            instrument: str = 'default_instrument',
             size: float = _FULL_EQUITY,
             limit: float = None,
             stop: float = None,
@@ -207,9 +225,10 @@ def buy(self, *,
         """
         assert 0 < size < 1 or round(size) == size, \
             "size must be a positive fraction of equity, or a positive whole number of units"
-        return self._broker.new_order(size, limit, stop, sl, tp)
+        return self._broker.new_order(size, limit, stop, sl, tp, instrument=instrument)
 
     def sell(self, *,
+             instrument: str = 'default_instrument',
              size: float = _FULL_EQUITY,
              limit: float = None,
              stop: float = None,
@@ -220,15 +239,21 @@ def sell(self, *,
 
         See also `Strategy.buy()`.
         """
-        assert 0 < size < 1 or round(size) == size, \
+        assert (
+            0 < size < 1 or round(size) == size,
             "size must be a positive fraction of equity, or a positive whole number of units"
-        return self._broker.new_order(-size, limit, stop, sl, tp)
+        )
+        return self._broker.new_order(-size, limit, stop, sl, tp, instrument=instrument)
 
     @property
-    def equity(self) -> float:
+    def equity(self) -> Union[float, dict[str, float]]:
         """Current account equity (cash plus assets)."""
         return self._broker.equity
 
+    @property
+    def equity_total(self) -> float:
+        return self._broker.equity_total
+
     @property
     def data(self) -> _Data:
         """
@@ -264,25 +289,44 @@ def position(self) -> 'Position':
         return self._broker.position
 
     @property
-    def orders(self) -> 'Tuple[Order, ...]':
+    def orders(self) -> Tuple[Order, ...]:
         """List of orders (see `Order`) waiting for execution."""
         return _Orders(self._broker.orders)
 
     @property
-    def trades(self) -> 'Tuple[Trade, ...]':
+    def orders_by_instrument(self) -> dict[str, Tuple[Order, ...]]:
+        """List of orders (see `Order`) waiting for execution."""
+        return {instrument: _Orders([order for order in self._broker.orders if order.instrument == instrument])
+                for instrument in self.instruments}
+
+    @property
+    def trades(self) -> Tuple[Trade, ...]:
         """List of active trades (see `Trade`)."""
         return tuple(self._broker.trades)
 
     @property
-    def closed_trades(self) -> 'Tuple[Trade, ...]':
+    def trades_by_instrument(self) -> dict[str, Tuple[Trade, ...]]:
+        """List of active trades (see `Trade`)."""
+        return {instrument: tuple(trade for trade in self._broker.trades if trade.instrument == instrument)
+                for instrument in self.instruments}
+
+    @property
+    def closed_trades(self) -> Tuple[Trade, ...]:
         """List of settled trades (see `Trade`)."""
         return tuple(self._broker.closed_trades)
 
+    @property
+    def closed_trades_by_instrument(self) -> dict[str, Tuple[Trade, ...]]:
+        """List of settled trades (see `Trade`)."""
+        return {instrument: tuple(trade for trade in self._broker.closed_trades if trade.instrument == instrument)
+                for instrument in self.instruments}
+
 
 class _Orders(tuple):
     """
     TODO: remove this class. Only for deprecation.
     """
+
     def cancel(self):
         """Cancel all non-contingent (i.e. SL/TP) orders."""
         for order in self:
@@ -310,21 +354,39 @@ class Position:
         if self.position:
             ...  # we have a position, either long or short
     """
-    def __init__(self, broker: '_Broker'):
+
+    def __init__(self, broker: _Broker):
         self.__broker = broker
 
+    @property
+    def instruments(self) -> set[str]:
+        return self.__broker.instruments
+
+    @property
+    def is_single_instrument(self) -> bool:
+        return self.__broker.is_single_instrument
+
     def __bool__(self):
         return self.size != 0
 
     @property
-    def size(self) -> float:
+    def size(self) -> Union[float, dict[str, float]]:
         """Position size in units of asset. Negative if position is short."""
-        return sum(trade.size for trade in self.__broker.trades)
+        if self.is_single_instrument:
+            return sum(trade.size for trade in self.__broker.trades)
+        else:
+            return {instrument: sum(trade.size for trade in self.__broker.trades if trade.instrument == instrument)
+                    for instrument in self.instruments}
 
     @property
     def pl(self) -> float:
         """Profit (positive) or loss (negative) of the current position in cash units."""
-        return sum(trade.pl for trade in self.__broker.trades)
+        return sum(self.pl_by_instrument.values())
+
+    @property
+    def pl_by_instrument(self) -> dict[str, float]:
+        return {instrument: sum(trade.pl for trade in self.__broker.trades if trade.instrument == instrument)
+                for instrument in self.instruments}
 
     @property
     def pl_pct(self) -> float:
@@ -335,14 +397,32 @@ def pl_pct(self) -> float:
         return (pl_pcts * weights).sum()
 
     @property
-    def is_long(self) -> bool:
+    def pl_pct_by_instrument(self) -> dict[str, float]:
+        result = {}
+        for instrument in self.instruments:
+            weights = np.abs([trade.size for trade in self.__broker.trades if trade.instrument == instrument])
+            weights = weights / weights.sum()
+            pl_pcts = np.array([trade.pl_pct for trade in self.__broker.trades if trade.instrument == instrument])
+            result[instrument] = (pl_pcts * weights).sum()
+        return result
+
+    @property
+    def is_long(self) -> Union[bool, dict[str, bool]]:
         """True if the position is long (position size is positive)."""
-        return self.size > 0
+        if self.is_single_instrument:
+            return self.size > 0
+        else:
+            return {instrument: instrument_size > 0
+                    for instrument, instrument_size in self.size.items()}
 
     @property
-    def is_short(self) -> bool:
+    def is_short(self) -> Union[bool, dict[str, bool]]:
         """True if the position is short (position size is negative)."""
-        return self.size < 0
+        if self.is_single_instrument:
+            return self.size < 0
+        else:
+            return {instrument: instrument_size < 0
+                    for instrument, instrument_size in self.size.items()}
 
     def close(self, portion: float = 1.):
         """
@@ -351,6 +431,11 @@ def close(self, portion: float = 1.):
         for trade in self.__broker.trades:
             trade.close(portion)
 
+    def close_instrument(self, instrument: str, portion: float = 1.):
+        for trade in self.__broker.trades:
+            if trade.instrument == instrument:
+                trade.close(portion)
+
     def __repr__(self):
         return f'<Position: {self.size} ({len(self.__broker.trades)} trades)>'
 
@@ -374,21 +459,30 @@ class Order:
     [filled]: https://www.investopedia.com/terms/f/fill.asp
     [Good 'Til Canceled]: https://www.investopedia.com/terms/g/gtc.asp
     """
-    def __init__(self, broker: '_Broker',
+
+    def __init__(self, broker: _Broker,
                  size: float,
+                 instrument: str = 'default_instrument',
                  limit_price: float = None,
                  stop_price: float = None,
                  sl_price: float = None,
                  tp_price: float = None,
-                 parent_trade: 'Trade' = None):
-        self.__broker = broker
+                 parent_trade: Trade = None):
+        if not broker.is_single_instrument and instrument == 'default_instrument':
+            raise ValueError('instrument must be specified for order, when backtesting with multiple instruments')
+        self.__instrument: Optional[str] = instrument
+        self.__broker: _Broker = broker
         assert size != 0
-        self.__size = size
-        self.__limit_price = limit_price
-        self.__stop_price = stop_price
-        self.__sl_price = sl_price
-        self.__tp_price = tp_price
-        self.__parent_trade = parent_trade
+        self.__size: float = size
+        self.__limit_price: float = limit_price
+        self.__stop_price: float = stop_price
+        self.__sl_price: float = sl_price
+        self.__tp_price: float = tp_price
+        self.__parent_trade: Trade = parent_trade
+
+    @property
+    def instrument(self) -> Optional[str]:
+        return self.__instrument
 
     def _replace(self, **kwargs):
         for k, v in kwargs.items():
@@ -404,6 +498,8 @@ def __repr__(self):
                                                  ('sl', self.__sl_price),
                                                  ('tp', self.__tp_price),
                                                  ('contingent', self.is_contingent),
+                                                 ('instrument',
+                                                  None if self.instrument == 'default_instrument' else self.instrument),
                                              ) if value is not None))
 
     def cancel(self):
@@ -509,7 +605,11 @@ class Trade:
     When an `Order` is filled, it results in an active `Trade`.
     Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`.
     """
-    def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar):
+
+    def __init__(self, broker: _Broker, size: int, entry_price: float, entry_bar, *, instrument: str = 'default_instrument'):
+        if not broker.is_single_instrument and instrument == 'default_instrument':
+            raise ValueError('instrument must be specified for trade, when backtesting with multiple instruments')
+        self.__instrument = instrument
         self.__broker = broker
         self.__size = size
         self.__entry_price = entry_price
@@ -519,9 +619,16 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar):
         self.__sl_order: Optional[Order] = None
         self.__tp_order: Optional[Order] = None
 
+    @property
+    def instrument(self) -> Optional[str]:
+        return self.__instrument
+
     def __repr__(self):
-        return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
-               f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}>'
+        return (
+            f'<Trade {f"instrument={self.instrument}" if self.instrument != "default_instrument" else ""} '
+            f'size={self.size} time={self.entry_bar}-{self.exit_bar or ""} '
+            f'price={self.entry_price}-{self.exit_price or ""} pl={self.pl:.0f}>'
+        )
 
     def _replace(self, **kwargs):
         for k, v in kwargs.items():
@@ -688,6 +795,14 @@ def __init__(self, *, data, cash, commission, margin,
     def __repr__(self):
         return f'<Broker: {self._cash:.0f}{self.position.pl:+.1f} ({len(self.trades)} trades)>'
 
+    @property
+    def is_single_instrument(self) -> bool:
+        return self._data.is_single_instrument
+
+    @property
+    def instruments(self) -> set[str]:
+        return self._data.instruments
+
     def new_order(self,
                   size: float,
                   limit: float = None,
@@ -695,10 +810,12 @@ def new_order(self,
                   sl: float = None,
                   tp: float = None,
                   *,
+                  instrument: str = 'default_instrument',
                   trade: Trade = None):
         """
         Argument size indicates whether the order is long or short
         """
+        # fixme
         size = float(size)
         stop = stop and float(stop)
         limit = limit and float(limit)
@@ -739,9 +856,51 @@ def new_order(self,
         return order
 
     @property
-    def last_price(self) -> float:
+    def last_price(self) -> Union[float, dict[str, float]]:
         """ Price at the last (current) close. """
-        return self._data.Close[-1]
+        if self.is_single_instrument:
+            return self.last_close['default_instrument']
+        else:
+            return self.last_close
+
+    @property
+    def last_open(self) -> Union[float, dict[str, float]]:
+        """ Price at the last (current) open. """
+        return {instrument: instrument_open[-1]
+                for instrument, instrument_open in self._data.Open.items()}
+
+    @property
+    def last_high(self) -> Union[float, dict[str, float]]:
+        """ Price at the last (current) high. """
+        return {instrument: instrument_high[-1]
+                for instrument, instrument_high in self._data.High.items()}
+
+    @property
+    def last_low(self) -> Union[float, dict[str, float]]:
+        """ Price at the last (current) low. """
+        if self.is_single_instrument:
+            return self._data.Low[-1]
+        else:
+            return {instrument: instrument_low[-1]
+                    for instrument, instrument_low in self._data.Low.items()}
+
+    @property
+    def last_close(self) -> Union[float, dict[str, float]]:
+        """ Price at the last (current) close. """
+        if self.is_single_instrument:
+            return self._data.Close[-1]
+        else:
+            return {instrument: instrument_close[-1]
+                    for instrument, instrument_close in self._data.Close.items()}
+
+    @property
+    def prev_close(self) -> Union[float, dict[str, float]]:
+        """ Price at the prev (current) close. """
+        if self.is_single_instrument:
+            return self._data.Close[-2]
+        else:
+            return {instrument: instrument_close[-2]
+                    for instrument, instrument_close in self._data.Close.items()}
 
     def _adjusted_price(self, size=None, price=None) -> float:
         """
@@ -751,8 +910,21 @@ def _adjusted_price(self, size=None, price=None) -> float:
         return (price or self.last_price) * (1 + copysign(self._commission, size))
 
     @property
-    def equity(self) -> float:
-        return self._cash + sum(trade.pl for trade in self.trades)
+    def equity(self) -> Union[float, tuple[float, dict[str, float]]]:
+        result = {instrument: sum(trade.pl for trade in self.trades if trade.instrument == instrument)
+                  for instrument in self._data.instruments}
+        if self.is_single_instrument:
+            return self._cash + result['default_instrument']
+        else:
+            return self._cash, result
+
+    @property
+    def equity_total(self) -> float:
+        if self.is_single_instrument:
+            return self.equity
+        else:
+            cash, equities = self.equity
+            return cash + sum(equities.values())
 
     @property
     def margin_available(self) -> float:
@@ -772,20 +944,25 @@ def next(self):
         if equity <= 0:
             assert self.margin_available <= 0
             for trade in self.trades:
-                self._close_trade(trade, self._data.Close[-1], i)
+                self._close_trade(trade, self.last_price, i)
             self._cash = 0
             self._equity[i:] = 0
             raise _OutOfMoneyError
 
     def _process_orders(self):
         data = self._data
-        open, high, low = data.Open[-1], data.High[-1], data.Low[-1]
-        prev_close = data.Close[-2]
+        open, high, low = self.last_open, self.last_high, self.last_low
+        prev_close = self.prev_close
+        if self.is_single_instrument:
+            open = {'default_instrument': open}
+            high = {'default_instrument': high}
+            low = {'default_instrument': low}
+            prev_close = {'default_instrument': prev_close}
         reprocess_orders = False
 
         # Process orders
         for order in list(self.orders):  # type: Order
-
+            instrument = order.instrument
             # Related SL/TP order was already removed
             if order not in self.orders:
                 continue
@@ -793,7 +970,7 @@ def _process_orders(self):
             # Check if stop condition was hit
             stop_price = order.stop
             if stop_price:
-                is_stop_hit = ((high > stop_price) if order.is_long else (low < stop_price))
+                is_stop_hit = ((high[instrument] > stop_price) if order.is_long else (low[instrument] < stop_price))
                 if not is_stop_hit:
                     continue
 
@@ -804,7 +981,7 @@ def _process_orders(self):
             # Determine purchase price.
             # Check if limit order can be filled.
             if order.limit:
-                is_limit_hit = low < order.limit if order.is_long else high > order.limit
+                is_limit_hit = low[instrument] < order.limit if order.is_long else high[instrument] > order.limit
                 # When stop and limit are hit within the same bar, we pessimistically
                 # assume limit was hit before the stop (i.e. "before it counts")
                 is_limit_hit_before_stop = (is_limit_hit and
@@ -821,9 +998,9 @@ def _process_orders(self):
             else:
                 # Market-if-touched / market order
                 price = prev_close if self._trade_on_close else open
-                price = (max(price, stop_price or -np.inf)
+                price = (max(price[instrument], stop_price or -np.inf)
                          if order.is_long else
-                         min(price, stop_price or np.inf))
+                         min(price[instrument], stop_price or np.inf))
 
             # Determine entry/exit bar index
             is_market_order = not order.limit and not stop_price
@@ -907,8 +1084,8 @@ def _process_orders(self):
                 if order.sl or order.tp:
                     if is_market_order:
                         reprocess_orders = True
-                    elif (low <= (order.sl or -np.inf) <= high or
-                          low <= (order.tp or -np.inf) <= high):
+                    elif (low[instrument] <= (order.sl or -np.inf) <= high[instrument] or
+                          low[instrument] <= (order.tp or -np.inf) <= high[instrument]):
                         warnings.warn(
                             f"({data.index[-1]}) A contingent SL/TP order would execute in the "
                             "same bar its parent stop/limit order was turned into a trade. "
@@ -947,7 +1124,7 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int
 
         self._close_trade(close_trade, price, time_index)
 
-    def _close_trade(self, trade: Trade, price: float, time_index: int):
+    def _close_trade(self, trade: Trade, price: dict[str, float], time_index: int):
         self.trades.remove(trade)
         if trade._sl_order:
             self.orders.remove(trade._sl_order)
@@ -980,8 +1157,9 @@ class Backtest:
     instance, or `backtesting.backtesting.Backtest.optimize` to
     optimize it.
     """
+
     def __init__(self,
-                 data: pd.DataFrame,
+                 data: Union[pd.DataFrame, dict[str, pd.DataFrame]],
                  strategy: Type[Strategy],
                  *,
                  cash: float = 10_000,
@@ -994,17 +1172,22 @@ def __init__(self,
         """
         Initialize a backtest. Requires data and a strategy to test.
 
-        `data` is a `pd.DataFrame` with columns:
+        `data` is either a `pd.DataFrame`
+         or a dictionary containing instrument names (`str`) and corresponding instrument data (`pd.DataFrame`)
+
+        The data frame(s) must have columns:
         `Open`, `High`, `Low`, `Close`, and (optionally) `Volume`.
         If any columns are missing, set them to what you have available,
         e.g.
 
             df['Open'] = df['High'] = df['Low'] = df['Close']
 
-        The passed data frame can contain additional columns that
+        The passed data frame(s) can contain additional columns that
         can be used by the strategy (e.g. sentiment info).
-        DataFrame index can be either a datetime index (timestamps)
-        or a monotonic range index (i.e. a sequence of periods).
+
+        DataFrame index must be a datetime index (timestamps)
+        If a single instrument data is being passed,
+        a monotonic range index (i.e. a sequence of periods) is also acceptable.
 
         `strategy` is a `backtesting.backtesting.Strategy`
         _subclass_ (not an instance).
@@ -1039,56 +1222,20 @@ def __init__(self,
 
         if not (isinstance(strategy, type) and issubclass(strategy, Strategy)):
             raise TypeError('`strategy` must be a Strategy sub-type')
-        if not isinstance(data, pd.DataFrame):
-            raise TypeError("`data` must be a pandas.DataFrame with columns")
+
         if not isinstance(commission, Number):
             raise TypeError('`commission` must be a float value, percent of '
                             'entry order price')
 
-        data = data.copy(deep=False)
+        if isinstance(data, pd.DataFrame):
+            data = {'default_instrument': data}
+
+        self._data: dict[str, pd.DataFrame] = data
 
-        # Convert index to datetime index
-        if (not isinstance(data.index, pd.DatetimeIndex) and
-            not isinstance(data.index, pd.RangeIndex) and
-            # Numeric index with most large numbers
-            (data.index.is_numeric() and
-             (data.index > pd.Timestamp('1975').timestamp()).mean() > .8)):
-            try:
-                data.index = pd.to_datetime(data.index, infer_datetime_format=True)
-            except ValueError:
-                pass
-
-        if 'Volume' not in data:
-            data['Volume'] = np.nan
-
-        if len(data) == 0:
-            raise ValueError('OHLC `data` is empty')
-        if len(data.columns.intersection({'Open', 'High', 'Low', 'Close', 'Volume'})) != 5:
-            raise ValueError("`data` must be a pandas.DataFrame with columns "
-                             "'Open', 'High', 'Low', 'Close', and (optionally) 'Volume'")
-        if data[['Open', 'High', 'Low', 'Close']].isnull().values.any():
-            raise ValueError('Some OHLC values are missing (NaN). '
-                             'Please strip those lines with `df.dropna()` or '
-                             'fill them in with `df.interpolate()` or whatever.')
-        if np.any(data['Close'] > cash):
-            warnings.warn('Some prices are larger than initial cash value. Note that fractional '
-                          'trading is not supported. If you want to trade Bitcoin, '
-                          'increase initial cash, or trade μBTC or satoshis instead (GH-134).',
-                          stacklevel=2)
-        if not data.index.is_monotonic_increasing:
-            warnings.warn('Data index is not sorted in ascending order. Sorting.',
-                          stacklevel=2)
-            data = data.sort_index()
-        if not isinstance(data.index, pd.DatetimeIndex):
-            warnings.warn('Data index is not datetime. Assuming simple periods, '
-                          'but `pd.DateTimeIndex` is advised.',
-                          stacklevel=2)
-
-        self._data: pd.DataFrame = data
         self._broker = partial(
             _Broker, cash=cash, commission=commission, margin=margin,
             trade_on_close=trade_on_close, hedging=hedging,
-            exclusive_orders=exclusive_orders, index=data.index,
+            exclusive_orders=exclusive_orders,
         )
         self._strategy = strategy
         self._results: Optional[pd.Series] = None
@@ -1132,7 +1279,7 @@ def run(self, **kwargs) -> pd.Series:
             _trades                       Size  EntryB...
             dtype: object
         """
-        data = _Data(self._data.copy(deep=False))
+        data = _Data(self._data)
         broker: _Broker = self._broker(data=data)
         strategy: Strategy = self._strategy(broker, data, kwargs)
 
@@ -1144,16 +1291,20 @@ def run(self, **kwargs) -> pd.Series:
                            for attr, indicator in strategy.__dict__.items()
                            if isinstance(indicator, _Indicator)}.items()
 
-        # Skip first few candles where indicators are still "warming up"
-        # +1 to have at least two entries available
-        start = 1 + max((np.isnan(indicator.astype(float)).argmin(axis=-1).max()
-                         for _, indicator in indicator_attrs), default=0)
+        if data._is_single_instrument:
+            # Skip first few candles where indicators are still "warming up"
+            # +1 to have at least two entries available
+            start = 1 + max((np.isnan(indicator.astype(float)).argmin(axis=-1).max()
+                             for _, indicator in indicator_attrs), default=0)
+        else:
+            # if there are multiple instruments, there might be NaN values anyway
+            start = 0
 
         # Disable "invalid value encountered in ..." warnings. Comparison
         # np.nan >= 3 is not invalid; it's False.
         with np.errstate(invalid='ignore'):
-
-            for i in range(start, len(self._data)):
+            index = data.index.copy()
+            for i in range(start, len(index)):
                 # Prepare data and indicators for `next` call
                 data._set_length(i + 1)
                 for attr, indicator in indicator_attrs:
@@ -1180,13 +1331,13 @@ def run(self, **kwargs) -> pd.Series:
 
             # Set data back to full length
             # for future `indicator._opts['data'].index` calls to work
-            data._set_length(len(self._data))
+            data._set_length(len(index))
 
             equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
             self._results = compute_stats(
                 trades=broker.closed_trades,
                 equity=equity,
-                ohlc_data=self._data,
+                data=self._data,
                 risk_free_rate=0.0,
                 strategy_instance=strategy,
             )
@@ -1507,7 +1658,8 @@ def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
              smooth_equity=False, relative_equity=True,
              superimpose: Union[bool, str] = True,
              resample=True, reverse_indicators=False,
-             show_legend=True, open_browser=True):
+             show_legend=True, open_browser=True,
+             instruments: Optional[list[str]] = None):
         """
         Plot the progression of the last backtest run.
 
@@ -1525,6 +1677,10 @@ def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
         the plot is made to span 100% of browser width. The height is
         currently non-adjustable.
 
+        If `instruments` is provided, only the selected instruments will be plotted.
+        By default, if running for single instrument data, the instrument will be plotted.
+        If running for multiple instruments, only the instruments for which trades were taken will be plotted.
+
         If `plot_equity` is `True`, the resulting plot will contain
         an equity (initial cash plus assets) graph section. This is the same
         as `plot_return` plus initial 100%.
@@ -1589,9 +1745,17 @@ def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
                 raise RuntimeError('First issue `backtest.run()` to obtain results.')
             results = self._results
 
+        if instruments is None:
+            if self._is_single_instrument:
+                instruments = ['default_instrument']
+            else:
+                # todo: find instruments for which trades were taken
+                instruments = []
+
         return plot(
             results=results,
-            df=self._data,
+            data=self._data,
+            instruments=instruments,
             indicators=results._strategy._indicators,
             filename=filename,
             plot_width=plot_width,
diff --git a/backtesting/lib.py b/backtesting/lib.py
index cbcd75c6..c0ec6bf3 100644
--- a/backtesting/lib.py
+++ b/backtesting/lib.py
@@ -72,7 +72,7 @@ def barssince(condition: Sequence[bool], default=np.inf) -> int:
     Return the number of bars since `condition` sequence was last `True`,
     or if never, return `default`.
 
-        >>> barssince(self.data.Close > self.data.Open)
+        >>> barssince(self.df.Close > self.df.Open)
         3
     """
     return next(compress(range(len(condition)), reversed(condition)), default)
@@ -83,7 +83,7 @@ def cross(series1: Sequence, series2: Sequence) -> bool:
     Return `True` if `series1` and `series2` just crossed
     (above or below) each other.
 
-        >>> cross(self.data.Close, self.sma)
+        >>> cross(self.df.Close, self.sma)
         True
 
     """
@@ -95,7 +95,7 @@ def crossover(series1: Sequence, series2: Sequence) -> bool:
     Return `True` if `series1` just crossed over (above)
     `series2`.
 
-        >>> crossover(self.data.Close, self.sma)
+        >>> crossover(self.df.Close, self.sma)
         True
     """
     series1 = (
@@ -150,9 +150,9 @@ def quantile(series: Sequence, quantile: Union[None, float] = None):
     `series` at this quantile. If used to working with percentiles, just
     divide your percentile amount with 100 to obtain quantiles.
 
-        >>> quantile(self.data.Close[-20:], .1)
+        >>> quantile(self.df.Close[-20:], .1)
         162.130
-        >>> quantile(self.data.Close)
+        >>> quantile(self.df.Close)
         0.13
     """
     if quantile is None: