# 信号回测 (/zh/features/backtesting/signal-backtesting)

已有入场和离场信号，想知道它们如何转化为交易，以及相互冲突时如何处理？VBT 可通过向 `vbt.PF.from_signals` 传入布尔数组，在 Python
中回测买卖信号，并将它们转化为订单、持仓和可完整检查的投资组合。

信号可以来自技术指标、自定义 pandas 计算，或转化为入场与离场条件的模型预测。可以做多或做空、分批建仓，并将自定义信号与内置止损和限价单结合。

```python title="Backtest a moving average crossover on Bitcoin"
>>> data = vbt.YFData.pull("BTC-USD", start="2021-01-01", end="2024-01-01")
>>> fast = data.run("talib:sma", timeperiod=20).real
>>> slow = data.run("talib:sma", timeperiod=50).real
>>> entries = fast.vbt.crossed_above(slow)
>>> exits = fast.vbt.crossed_below(slow)

>>> pf = vbt.PF.from_signals(data, entries, exits, price="nextopen", fees=0.001)  # (1)
>>> pf.trades.readable[["Entry Index", "Exit Index", "Return"]].head(3)
                Entry Index                Exit Index    Return
0 2021-08-02 00:00:00+00:00 2021-09-24 00:00:00+00:00  0.122841
1 2021-10-12 00:00:00+00:00 2021-11-28 00:00:00+00:00 -0.049127
2 2022-02-20 00:00:00+00:00 2022-03-08 00:00:00+00:00 -0.053252

>>> pf.stats(["total_return", "total_trades", "win_rate", "max_dd"])
Total Return [%]    -13.87894
Total Trades               12
Win Rate [%]        18.181818
Max Drawdown [%]      58.6206
dtype: object

>>> pf.plot(settings=dict(bm_returns=False)).show()
```

1.  信号按每根收盘价计算，在下一根开盘成交，每笔订单收取 0.1% 手续费。

2021 至 2023 年比特币价格、均线交叉入场与离场、交易收益和累计收益. [Figure data (JSON)](/assets/figures/features/backtesting/signal-backtesting-sma-crossover.c0a5967bf35b.json)

## 多资产与参数组合 \[#multiple-assets-and-parameter-combinations]

同一方法既适用于单个信号 Series，也适用于包含多个资产和参数组合的 DataFrame。在一次模拟中比较指标设置、仓位大小与冲突规则，再按列标签选择各个结果。

默认每列拥有独立现金。若要把信号作为多资产组合测试，应为资产列分组并启用现金共享。[回测引擎](/zh/features/backtesting/backtesting-engine/#many-backtests-in-one-call)展示两种方式，[参数优化](/zh/features/optimization/strategy-optimization/)展示如何构建更大搜索。

## 从信号到订单 \[#from-signals-to-orders]

信号是请求，并非订单。每根 K 线上，模拟器读取信号、检查当前持仓，并将最终生效信号转化为带数量、价格和方向的订单。默认空仓时入场会开仓；已有同方向持仓时再次入场会被忽略；离场则全部平仓。

每个订单参数都与信号广播，因此可以是标量、单行、单列或完整数组。若每个信号使用不同值，只在信号位置填充数组：

```python title="Use a different size for each entry"
>>> close = pd.Series(
...     [10.0, 11.0, 12.0, 11.0, 10.0, 12.0],
...     index=pd.date_range("2026-01-01", periods=6)
... )
>>> entries = pd.Series([True, False, False, False, True, False], index=close.index)
>>> exits = pd.Series([False, False, True, False, False, True], index=close.index)

>>> size = close.vbt.wrapper.fill(np.nan)
>>> size[entries] = [5, 10]

>>> pf = vbt.PF.from_signals(close, entries, exits, size=size, init_cash=1000)
>>> pf.orders.readable[["Fill Index", "Size", "Side"]]
  Fill Index  Size  Side
0 2026-01-01   5.0   Buy
1 2026-01-03   5.0  Sell
2 2026-01-05  10.0   Buy
3 2026-01-06  10.0  Sell
```

未指定数量时，每次入场使用全部可用现金。`"targetpercent"` 等目标持仓数量类型会被拒绝，因为目标可能与信号方向矛盾。若按投资组合净值比例确定每次入场规模，使用
`"valuepercent"`，或按[将目标作为信号](#targets-as-signals)的方法传入目标。

逐信号设置方式同样适用于 `price`、`order_type`、`fees` 和其他订单参数。例如，价格数组可让离场在自定义逻辑计算的价位成交：

```python title="Exit at computed prices and enter at the close"
>>> price = close.copy()  # (1)
>>> price[exits] = [11.8, 11.7]

>>> pf = vbt.PF.from_signals(close, entries, exits, price=price)
>>> pf.orders.readable[["Fill Index", "Size", "Price", "Side"]]
  Fill Index  Size  Price  Side
0 2026-01-01  10.0   10.0   Buy
1 2026-01-03  10.0   11.8  Sell
2 2026-01-05  11.8   10.0   Buy
3 2026-01-06  11.8   11.7  Sell
```

1.  从收盘价开始填充，使入场保留有效价格。价格为 `NaN` 的 K 线不会下单，因此若只在离场处填充价格，所有入场都会跳过，且不报错。

价格数组中，`-np.inf` 表示本根开盘，`np.inf` 表示本根收盘。在填满 `np.inf` 的数组上设置 `price[entries] = -np.inf`，即可开盘入场、收盘离场。

!!! warning "执行时机"
    默认订单在信号出现的 K 线收盘成交。若信号正由同一收盘价计算，应使用 `price="nextopen"` 或
    `price="nextclose"`，或将信号后移一根，避免回测以当时来不及知晓的价格交易。

## 止损与限价单 \[#stops-and-limit-orders]

入场信号可配合止损、移动止损、止盈目标和时间离场。模拟器同时跟踪这些规则和您的信号，因此不必自行编写持仓跟踪逻辑，就能测试离场规则。不同资产和参数组合可设置不同止损水平。参见[止损与止盈](/zh/features/backtesting/stop-loss-and-take-profit/)。

信号也可创建等待指定价格、而非立即成交的限价单。入场和离场可使用不同订单类型，并可设定未成交限价单保持有效的时长。限价、到期和成交行为详见[订单与执行](/zh/features/backtesting/orders-and-execution/)。

## 多头、空头与反转 \[#long-short-and-reversals]

有两种方式描述方向。使用 `entries` 和 `exits` 两个掩码时，`direction` 决定它们的含义。默认 `"longonly"`，`"shortonly"`
反转方向，`"both"` 使每次离场反转为空头。使用 `long_entries`、`long_exits`、`short_entries` 和 `short_exits`
四个掩码时，信号自身指定方向，因此交易之间也能空仓，`direction` 被忽略。

这种区别很重要。下方布林带策略在下轨以下买入、上轨以上做空、中轨离场。将其合并为两个掩码并设置 `direction="both"` 会去掉离场，使策略始终在场：

```python title="Compare four direction-aware masks with two reversing masks"
>>> data = vbt.YFData.pull("BTC-USD", start="2023-01-01", end="2024-01-01")
>>> bb = data.run("bbands", window=20, alpha=2)
>>> long_entries = data.close.vbt.crossed_below(bb.lower)
>>> long_exits = data.close.vbt.crossed_above(bb.middle)
>>> short_entries = data.close.vbt.crossed_above(bb.upper)
>>> short_exits = data.close.vbt.crossed_below(bb.middle)

>>> pf_four = vbt.PF.from_signals(
...     data,
...     long_entries=long_entries,
...     long_exits=long_exits,
...     short_entries=short_entries,
...     short_exits=short_exits,
... )
>>> pf_two = vbt.PF.from_signals(
...     data,
...     entries=long_entries,
...     exits=short_entries,
...     direction="both"
... )
>>> metrics = ["total_trades", "total_time_exposure", "total_return"]
>>> pd.DataFrame({
...     "four masks": pf_four.stats(metrics),
...     "two masks": pf_two.stats(metrics),
... })
                      four masks  two masks
Total Trades                  15          6
Position Coverage [%]  44.931507  89.315068
Total Return [%]       14.886316 -40.748144
```

使用四个掩码时，15 笔交易分为 6 笔多头和 9 笔空头，策略超过半年持有现金。整列由同一个方向规则控制时可用两个掩码；需要平仓而不反转时，应使用四个。

## 信号冲突 \[#conflicting-signals]

不同规则生成的信号常在同一根触发。模拟器按固定顺序解决冲突，每一步各有参数：

| 冲突            | 参数                                         | 默认值               |
| ------------- | ------------------------------------------ | ----------------- |
| 同方向入场与离场同时出现  | `upon_long_conflict`、`upon_short_conflict` | `"ignore"`        |
| 多头入场与空头入场同时出现 | `upon_dir_conflict`                        | `"ignore"`        |
| 与当前持仓相反方向的入场  | `upon_opposite_entry`                      | `"reversereduce"` |

同方向冲突可选择 `"entry"`、`"exit"`，或与当前持仓 `"adjacent"`（同侧）或 `"opposite"`（相反）的信号。方向冲突可选择
`"long"`、`"short"`、`"adjacent"` 或 `"opposite"`。反向入场可以忽略、平仓或反转。与其他参数一样，各模式可逐根逐列变化，`vbt.Param`
可一次测试多个：

=== "同时入场与离场"
    ```python title="Resolve an entry and an exit on the same bar"
    >>> close = pd.Series(
    ...     [10.0, 11.0, 12.0, 11.0],
    ...     index=pd.date_range("2026-01-01", periods=4)
    ... )
    >>> entries = pd.Series([True, False, True, False], index=close.index)
    >>> exits = pd.Series([False, False, True, False], index=close.index)

    >>> pf = vbt.PF.from_signals(
    ...     close,
    ...     entries,
    ...     exits,
    ...     upon_long_conflict=vbt.Param(["ignore", "exit"]),
    ...     size=1,
    ... )
    >>> pf.assets
    upon_long_conflict  ignore  exit
    2026-01-01             1.0   1.0
    2026-01-02             1.0   1.0
    2026-01-03             1.0   0.0
    2026-01-04             1.0   0.0
    ```

=== "反向入场"
    ```python title="Close or reverse a long position on a short entry"
    >>> long_entries = pd.Series([True, False, False, False], index=close.index)
    >>> short_entries = pd.Series([False, False, True, False], index=close.index)

    >>> pf = vbt.PF.from_signals(
    ...     close,
    ...     long_entries=long_entries,
    ...     short_entries=short_entries,
    ...     upon_opposite_entry=vbt.Param(["ignore", "close", "reverse"]),
    ...     size=1,
    ... )
    >>> pf.assets
    upon_opposite_entry  ignore  close  reverse
    2026-01-01              1.0    1.0      1.0
    2026-01-02              1.0    1.0      1.0
    2026-01-03              1.0    0.0     -1.0
    2026-01-04              1.0    0.0     -1.0
    ```

!!! info "反转只有一个订单"
    反转在同一订单中平掉旧仓并开立新仓，因此两部分共享一个价格和订单类型。若要用市价平仓、用限价重开，应在一根 K 线上发出离场，下一根发出反向入场，并按信号设置
    `order_type`。交易记录仍显示两笔交易，一笔平仓、一笔开仓；小于两部分合计数量的 `max_size` 可能阻止反转。

## 加减仓与阶梯入场 \[#accumulation-and-entry-ladders]

未启用加减仓时，模拟器每列最多保留一个持仓，并忽略重复入场。设置 `accumulate=True` 后，每个信号都视为订单，入场增加持仓，带数量的离场则减少持仓：

```python title="Add one unit on each entry signal"
>>> close = pd.Series(
...     [10.0, 11.0, 12.0, 13.0, 12.0, 14.0],
...     index=pd.date_range("2026-01-01", periods=6)
... )
>>> entries = pd.Series([True, True, True, True, False, False], index=close.index)
>>> exits = pd.Series([False, False, False, False, False, True], index=close.index)

>>> pf = vbt.PF.from_signals(
...     close,
...     entries,
...     exits,
...     size=1,
...     accumulate=vbt.Param([False, True])
... )
>>> pf.assets
accumulate  False  True
2026-01-01    1.0    1.0
2026-01-02    1.0    2.0
2026-01-03    1.0    3.0
2026-01-04    1.0    4.0
2026-01-05    1.0    4.0
2026-01-06    0.0    3.0
```

`accumulate` 还可设为 `"addonly"`，只允许加仓、每次离场全部平仓；或设为 `"removeonly"`，一次入场后分批减仓。使用 `size_type="percent"`
时，离场数量是当前持仓的比例，因此先减半再全部平仓需要两次离场：

```python title="Buy with all cash, sell half, then sell the rest"
>>> close = pd.Series(
...     [10.0, 11.0, 12.0, 11.5, 13.0, 12.0],
...     index=pd.date_range("2026-01-01", periods=6)
... )
>>> entries = pd.Series([True, False, False, False, False, False], index=close.index)
>>> exits = pd.Series([False, False, True, False, True, False], index=close.index)
>>> size = close.vbt.wrapper.fill(np.nan)
>>> size[entries] = 1.0  # (1)
>>> size[exits] = [0.5, 1.0]

>>> pf = vbt.PF.from_signals(
...     close,
...     entries,
...     exits,
...     size=size,
...     size_type="percent",
...     accumulate="removeonly",
...     init_cash=1000,
... )
>>> pf.orders.readable[["Fill Index", "Size", "Price", "Side"]]
  Fill Index   Size  Price  Side
0 2026-01-01  100.0   10.0   Buy
1 2026-01-03   50.0   12.0  Sell
2 2026-01-05   50.0   13.0  Sell
```

1.  对入场而言，百分比应用于可用现金，因此 1.0 表示全部投入。

带上限的金字塔加仓依赖已有持仓，不能预先计算为掩码。Numba 编写的信号函数能逐根读取模拟状态。这里每次创 20 日新高加一单位，最多三单位，跌破 20 日均线时离场：

```python title="Pyramid into new highs with a three-unit cap"
>>> @njit
... def pyramid_signal_nb(c, new_high, exits, max_units):
...     position = c.last_position[c.col]  # (1)
...     if position > 0 and vbt.pf_nb.select_nb(c, exits):
...         return False, True, False, False  # (2)
...     if position < max_units and vbt.pf_nb.select_nb(c, new_high):
...         return True, False, False, False
...     return False, False, False, False

>>> new_high = data.close >= data.close.rolling(20).max()
>>> exits = data.close.vbt.crossed_below(data.close.rolling(20).mean())
>>> pf = vbt.PF.from_signals(
...     data,
...     signal_func_nb=pyramid_signal_nb,
...     signal_args=(vbt.Rep("new_high"), vbt.Rep("exits"), 3),
...     broadcast_named_args=dict(new_high=new_high, exits=exits),  # (3)
...     size=1,
...     init_cash=100_000,
...     accumulate="addonly",
... )
>>> pf.orders.readable[["Fill Index", "Size", "Price", "Side"]].head(4)
                 Fill Index  Size         Price  Side
0 2023-01-20 00:00:00+00:00   1.0  22676.552734   Buy
1 2023-01-21 00:00:00+00:00   1.0  22777.625000   Buy
2 2023-01-23 00:00:00+00:00   1.0  22934.431641   Buy
3 2023-02-06 00:00:00+00:00   3.0  22760.109375  Sell
```

1.  当前列在本根订单之前的持仓。
2.  函数返回四个标志：多头入场、多头离场、空头入场和空头离场。
3.  此处传入的数组广播到数据形状，再替换 `vbt.Rep` 占位符。

同一技术可构建阶梯入场和定投计划。信号函数记录首次入场，随后经过指定 K 线数量或到达指定价位时，释放分阶段入场，各自拥有数量。

也可以在盈利持仓达到收益阈值后加仓。信号回调可读取未平仓头寸的收益，在启用加减仓时发出新的入场。逻辑可用的实时持仓度量详见[持仓信息](/zh/features/backtesting/portfolio-accounting/#position-info)。

!!! info "文档"
    会员专享的[从信号构建投资组合（英文）](https://members.vectorbt.pro/documentation/portfolio/from-signals/)指南，逐步构建参数化阶梯入场。

## 将目标作为信号 \[#targets-as-signals]

有时您知道想持有多少，而非具体信号。设置 `order_mode=True` 后，数量数组作为订单列表读取，模拟器推导匹配信号，使止损和限价功能仍可应用。`pf.get_signals()`
返回实际执行的四个信号掩码：

```python title="Derive signals from target positions"
>>> close = pd.Series(
...     [10.0, 11.0, 12.0, 11.0, 13.0],
...     index=pd.date_range("2026-01-01", periods=5)
... )
>>> target = pd.Series([5, np.nan, 10, 0, -5], index=close.index)  # (1)

>>> pf = vbt.PF.from_signals(
...     close,
...     size=target,
...     size_type="targetamount",
...     order_mode=True,
...     accumulate=True,
...     direction="both",
...     init_cash=1000,
... )
>>> long_entries, long_exits, short_entries, short_exits = pf.get_signals()
>>> pd.DataFrame({
...     "long entry": long_entries,
...     "long exit": long_exits,
...     "short entry": short_entries,
... })
            long entry  long exit  short entry
2026-01-01        True      False        False
2026-01-02       False      False        False
2026-01-03        True      False        False
2026-01-04       False       True        False
2026-01-05       False      False         True
```

1.  持有 5 单位、保持不变、增加至 10、平仓，再持有 5 单位空头。`NaN` 表示不下单。

## 直接调用内核 \[#calling-the-kernel-directly]

`vbt.PF.from_signals` 准备参数、广播并封装结果。底层引擎是接收普通 NumPy 数组的编译函数，可直接调用，例如放入自己的 Numba 代码或大型优化循环：

```python title="Run the compiled signal simulator on NumPy arrays"
>>> close_arr = np.array([[10.0], [11.0], [12.0], [11.0], [13.0]])
>>> long_entries = np.array([[True], [False], [False], [True], [False]])
>>> long_exits = np.array([[False], [False], [True], [False], [True]])

>>> sim_out = vbt.pf_nb.from_signals_nb(
...     target_shape=close_arr.shape,
...     group_lens=np.array([1]),  # (1)
...     close=close_arr,
...     init_cash=100.0,
...     long_entries=long_entries,
...     long_exits=long_exits,
... )
>>> pd.DataFrame(sim_out.order_records)[["idx", "size", "price"]]
   idx       size  price
0    0  10.000000   10.0
1    2  10.000000   12.0
2    3  10.909091   11.0
3    4  10.909091   13.0
```

1.  一个组，包含一列。同组列共享现金。

此层级需要自行准备参数：字符串选项转为枚举整数，数组必须已具有目标形状或能广播至该形状。回调版本 `from_signal_func_nb` 接受与 `from_signals`
相同的信号函数，交易记录与度量也可用编译函数从订单记录重建。

!!! info "教程"
    会员专享的[从 Python 到 Rust（英文）](https://members.vectorbt.pro/tutorials/from-python-to-rust/static/)教程将一个策略从
    `vbt.PF.from_signals` 下沉到 Numba 内核，再迁移至 Rust。

## 目标仓位转信号 \[#target-size-to-signals]

新增于 1.10.0

✅ 可以使用专门信号函数，将目标仓位转化为信号，从而使用止损和限价单功能。例如，这对投资组合优化尤其有用。

```python title="Perform the Mean-Variance Optimization with SL and TP"
>>> data = vbt.YFData.pull(
...     ["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
...     start="2022",
...     end="2023",
...     missing_index="drop"
... )
>>> pfo = vbt.PFO.from_riskfolio(data.returns, every="M")
>>> pf = pfo.simulate(
...     data,
...     pf_method="from_signals",
...     sl_stop=0.05,
...     tp_stop=0.1,
...     stop_exit_price="close"  # (1)
... )
>>> pf.plot_allocations().show()
```

1.  否则，用户信号和止损信号会在同一根 K 线的不同时刻发生，无法确定正确执行顺序。

2022 年具有止损和止盈保护的月度均值方差投资组合配置. [Figure data (JSON)](/assets/figures/features/portfolio/target-size-to-signals.8f527b79be15.json)
