# 前向滚动与交叉验证 (/zh/features/optimization/time-series-cross-validation)

优化后的策略在从未见过的数据上能否成立？VBT 在 Python 中运行前向滚动优化和时间序列交叉验证：将历史切分为训练和测试区间，在各训练区间选择参数，并仅在之后区间评估。

```python title="Walk-forward optimize a moving average strategy over 16 quarters"
>>> from itertools import product

>>> data = vbt.YFData.pull("BTC-USD", start="2020-01-01", end="2025-01-01")
>>> splitter = vbt.Splitter.from_rolling(  # (1)
...     data.index,
...     length=455,
...     split=365,
...     offset=90,
...     offset_anchor="prev_start",
...     set_labels=["train", "test"],
... )
>>> splitter.n_splits
16

>>> pairs = [(f, s) for f, s in product(range(10, 55, 5), range(60, 220, 20))]
>>> fast_windows, slow_windows = zip(*pairs)
>>> 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_above(slow)  # (2)
>>> exits = fast.ma_below(slow)

>>> bounds = splitter.get_bounds(index_bounds=True)
>>> rows, oos_returns = [], []
>>> for split in range(splitter.n_splits):
...     train_start, train_end = bounds.loc[(split, "train")]
...     test_start, test_end = bounds.loc[(split, "test")]
...     train = slice(train_start, train_end - pd.Timedelta(days=1))
...     test = slice(test_start, test_end - pd.Timedelta(days=1))
...     train_pf = vbt.PF.from_signals(
...         data.loc[train], entries.loc[train], exits.loc[train], fees=0.001
...     )
...     best = train_pf.sharpe_ratio.idxmax()  # (3)
...     test_pf = vbt.PF.from_signals(
...         data.loc[test], entries.loc[test, best], exits.loc[test, best], fees=0.001
...     )
...     rows.append(dict(
...         test_start=test_start.date(),
...         fast=best[0],
...         slow=best[1],
...         train_sharpe=train_pf.sharpe_ratio[best],
...         test_sharpe=test_pf.sharpe_ratio,
...     ))
...     oos_returns.append(test_pf.returns)

>>> summary = pd.DataFrame(rows)
>>> summary.head(6).round(2)
   test_start  fast  slow  train_sharpe  test_sharpe
0  2020-12-31    10   200          3.19         3.56
1  2021-03-31    10   200          3.42        -2.00
2  2021-06-29    20    60          2.83         0.98
3  2021-09-27    20    60          2.97         0.12
4  2021-12-26    20    80          1.86        -2.01
5  2022-03-26    20    80          0.80        -1.74
>>> summary[["train_sharpe", "test_sharpe"]].mean().round(2)
train_sharpe    1.92
test_sharpe     0.24
dtype: float64

>>> oos = pd.concat(oos_returns)  # (4)
>>> hold = data.close.loc[oos.index] / data.close.loc[oos.index].iloc[0]
>>> print(round((1 + oos).prod(), 3), round(hold.iloc[-1], 3))
2.744 3.36
```

1.  每次切分用 365 天训练，再测试之后 90 天，随后向前移动 90 天。
2.  指标在完整历史上计算，因此测试区间无需预热。快速均线高于慢速均线时保持持仓，让每个测试期以正确持仓开始。
3.  选择训练年中夏普比率最高的窗口组合。
4.  将 16 个测试季度连接为一个样本外收益率序列。

2021 至 2024 年前向滚动优化比特币均线策略与买入持有的权益比较. [Figure data (JSON)](/assets/figures/features/optimization/walk-forward-equity.a8892d15ae61.json)

每个训练年中表现最佳的参数，样本内平均夏普比率为 1.92，而随后季度仅为 0.24。拼接后，样本外策略增长至 2.7 倍，持有比特币增长至 3.4 倍。揭示这种样本内外差距，正是前向滚动测试的目的。

## 前向滚动优化 \[#walk-forward-optimization]

前向滚动测试重复同一循环：在训练窗口优化，将结果应用于下一窗口，再向前移动。连接测试收益，评估重复选择过程。上方每个测试窗口独立模拟，若快均线高于慢均线，可在第一根 K
线入场。如果策略要求每个边界必须空仓，请在每个测试窗口最后一根 K
线添加退出；如果状态必须延续，请按[实时模拟](/zh/features/backtesting/live-simulation/)页面的方法，从一个窗口继续投资组合到下一个窗口。测试期投资组合可通过
`vbt.PF.row_stack` 沿时间连接，形成具备全部指标和图表的单个样本外投资组合。

您选择每次决策使用多少历史。滚动窗口保留固定长度的近期数据，例如最近一年。扩展窗口随新观测到来保留较早历史。按日历切分可匹配研究计划，例如每月选择参数或每季重新训练模型。

## 切分器 \[#splitters]

`vbt.Splitter` 构建切分：

| 构造器                                           | 切分形式         | 用途         |
| --------------------------------------------- | ------------ | ---------- |
| `from_rolling`、`from_n_rolling`               | 向前移动的固定长度窗口  | 在近期历史上重新优化 |
| `from_expanding`、`from_n_expanding`           | 从固定起点增长的窗口   | 保留全部可用训练历史 |
| `from_grouper`                                | 月、季等日历区间     | 按时期比较绩效    |
| `from_n_random`                               | 用于稳健性检查的随机窗口 | 测试不同历史起点   |
| `from_ranges`、`from_splits`                   | 您定义的准确区间     | 自定义研究计划    |
| `from_purged_walkforward`、`from_purged_kfold` | 清除重叠信息的切分    | 分离重叠的标签信息  |

每次切分可包含训练、测试等多个集合，以 K 线数、比例或时长指定大小，并在其间留出间隔。`splitter.plot()` 在时间轴上显示每次切分，`get_coverage` 和
`get_overlap_matrix` 衡量各集合覆盖多少数据，以及切分之间重叠多少，如下方[切分器](#splitter)功能介绍所示。

### 在训练与测试之间留出间隔 \[#leave-a-gap-between-training-and-testing]

扩展窗口可以在增长训练历史的同时，保留固定测试期及两者之间的间隔。这里每次测试覆盖三天，开始前留出两天。表格显示各集合第一个和最后一个包含的日期。

```python title="Inspect expanding windows with a gap"
>>> index = pd.date_range("2025-01-01", periods=18, freq="D")
>>> splitter = vbt.Splitter.from_expanding(
...     index,
...     min_length=12,
...     offset=3,
...     split=(1.0, vbt.RelRange(length=2, is_gap=True), 3),
...     split_range_kwargs=dict(backwards=True),
...     set_labels=["train", "test"],
... )
>>> splitter.get_bounds(index_bounds=True, right_inclusive=True)
bound            start        end
split set
0     train 2025-01-01 2025-01-07
      test  2025-01-10 2025-01-12
1     train 2025-01-01 2025-01-10
      test  2025-01-13 2025-01-15
2     train 2025-01-01 2025-01-13
      test  2025-01-16 2025-01-18
```

`is_gap=True` 将这些日期排除在两个集合之外。反向切分先保留测试期和间隔，再将剩余历史分配给训练。使用同一切分器，从价格、特征和标签取得匹配切片。

## 应用切分 \[#applying-splits]

`splitter.take(obj)` 将数据、指标或投资组合切成各区间，`splitter.apply` 在每个区间运行函数。`@vbt.split`
装饰器为任意函数添加切分，`@vbt.cv_split` 将切分与参数搜索结合：一次调用在每个训练集优化、选择最佳组合，并在测试集评估，如下方
[CV 装饰器](#cv-decorator)和[切分装饰器](#split-decorator)功能介绍所示。结果按切分和集合标注，可直接比较。默认 `cv_split`
保留结果最高的组合，因此函数应为每种组合返回一个数字，例如夏普比率。设置 `selection="min"` 则保留最低者；基于网格结果的模板可应用其他任意规则，例如由多个指标构成的评分。

### 检查完整参数网格 \[#inspect-full-parameter-grids]

使用 `return_grid=True` 保留训练网格，或用 `return_grid="all"`
在每个集合评估完整网格。这样可比较周围参数，了解强劲训练结果来自广泛区域还是孤立组合。选择仍来自训练结果。额外测试结果可用于研究[稳健性](/zh/features/optimization/robustness-and-overfitting/)。

### 跨资产与时间周期验证 \[#validate-across-assets-and-timeframes]

在 `cv_split` 外层再包一层参数化函数，就能为每个资产独立运行验证流程。外层搜索选择资产，内层搜索在每个训练窗口选择策略参数。结果保留标签，方便比较哪些设置跨标的有效，哪些依赖单一市场。

切分边界也可在一个时间索引上定义，再应用于其他频率的数据。例如，定义日频训练和测试边界，再切片[回测](/zh/features/backtesting/intraday-and-tick-backtesting/)使用的分钟
K 线。

### 保留指标历史 \[#keep-indicator-history-available]

测试窗口可使用更早价格预热均线，而不将这些早期 K
线计入绩效。像开头示例一样，预先计算只使用当前及历史价格的指标，或为函数提供历史缓冲区，在模拟前裁剪。如果长回看期会使较短测试窗口的大部分时间没有信号，这尤其有用。

## 清除重叠信息与组合交叉验证 \[#purged-and-combinatorial-cross-validation]

当标签像许多机器学习目标一样使用未来信息时，靠近测试期的训练样本可能包含该测试期信息。清除（purging）移除这类样本，禁运期（embargo）按照 López de Prado
的方法，在每个测试期之后留出间隔。`PurgedWalkForwardCV` 对前向滚动折叠清除重叠信息，`PurgedKFoldCV`
运行组合清除交叉验证：选定数量折叠的每种组合都成为测试集，因此一段历史产生多次样本外测试，而非仅一次，如下方[清除重叠信息的 CV](#purged-cv)功能介绍所示。

对于结果在不同时间才可获知的[标签](/zh/features/indicators/machine-learning-labels/)，请提供预测时间和评估时间，让清除过程考虑每个样本的信息区间。按时间重新训练使用清除重叠信息的前向滚动切分，比较多种训练测试安排则使用组合清除切分。下方十折中取两折测试的示例产生
45 种组合。

## scikit-learn \[#scikit-learn]

切分器与 scikit-learn 双向兼容。`vbt.SplitterCV` 将 VBT 切分器转换为用于 `GridSearchCV` 或 `cross_val_score` 的
scikit-learn 交叉验证器，`vbt.Splitter.from_sklearn` 则将 scikit-learn 切分器转换为 VBT 切分器，以便绘图和分析。

这也为现有 scikit-learn 模型接入流程提供途径。一起切分特征和标签，在训练数据拟合模型，再在匹配测试集上评估预测。如果拟合模型、生成信号和回测预测需要独立步骤，您可将相同切片传入自己的
Python 流水线。

!!! info "教程"
    会员专属的[交叉验证（英文）](https://members.vectorbt.pro/tutorials/cross-validation/workflow/)教程通过切分器和装饰器演示完整验证流程。

## 清除重叠信息的 CV \[#purged-cv]

新增于 v2024.4.1

✅ 基于 Marcos Lopez de Prado
的[《金融机器学习进阶》](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086)，新增支持带清除重叠信息的前向滚动交叉验证（CV），以及带清除与禁运期的组合
CV。

```python title="Create and plot a combinatorial splitter with purging and embargoing"
>>> splitter = vbt.Splitter.from_purged_kfold(
...     vbt.date_range("2024", "2025"),
...     n_folds=10,
...     n_test_folds=2,
...     purge_td="3 days",
...     embargo_td="3 days"
... )
>>> splitter.plots().show()
```

2024 年包含训练集与测试集的组合清除交叉验证折叠. [Figure data (JSON)](/assets/figures/features/optimization/purged-cross-validation.dfd99f035a1b.json)

## CV 装饰器 \[#cv-decorator]

新增于 1.8.1

✅ 大多数交叉验证任务都需要在训练数据上测试参数组合网格、选择最佳组合，再在测试数据验证。每次切分都要重复此过程。交叉验证装饰器将参数化与切分装饰器组合，以自动完成这一任务。

```python title="Cross-validate a SMA crossover using random search"
>>> @vbt.cv_split(
...     splitter="from_rolling",
...     splitter_kwargs=dict(length=365, split=0.5, set_labels=["train", "test"]),
...     takeable_args=["data"],
...     parameterized_kwargs=dict(random_subset=100, seed=42),
...     merge_func="concat"
... )
... def sma_crossover_cv(data, fast_period, slow_period, metric):
...     fast_sma = data.run("sma", fast_period, hide_params=True)
...     slow_sma = data.run("sma", slow_period, hide_params=True)
...     entries = fast_sma.real_crossed_above(slow_sma)
...     exits = fast_sma.real_crossed_below(slow_sma)
...     pf = vbt.PF.from_signals(data, entries, exits, direction="both")
...     return pf.deep_getattr(metric)

>>> sma_crossover_cv(
...     vbt.YFData.pull("BTC-USD", start="4 years ago"),
...     vbt.Param(np.arange(20, 50), condition="x < slow_period"),
...     vbt.Param(np.arange(20, 50)),
...     "trades.expectancy"
... )
```

切分 7/7: 100%

```text title="Output"
split  set    fast_period  slow_period
0      train  20           25               8.015725
       test   20           23               0.573465
1      train  40           48              -4.356317
       test   39           40               5.666271
2      train  24           45              18.253340
       test   22           36             111.202831
3      train  20           31              54.626024
       test   20           25              -1.596945
4      train  25           48              41.328588
       test   25           30               6.620254
5      train  26           32               7.178085
       test   24           29               4.087456
6      train  22           23              -0.581255
       test   22           31              -2.494519
dtype: float64
```

!!! info "教程"
    更多内容请参阅[交叉验证](/zh/tutorials/cross-validation)教程。

## 切分装饰器 \[#split-decorator]

新增于 1.8.1

✅ 通常，要在每个切分上运行函数，必须针对传给函数的输入数据构建专用切分器。这意味着每次输入变化都要重建切分器。切分装饰器通过封装函数，获得全部参数并按需决定切分方式，从而自动化这一过程。它可以为任意
Python 函数“感染”上切分能力 🦠

```python title="Get total return from holding in each quarter"
>>> @vbt.split(
...     splitter="from_grouper",
...     splitter_kwargs=dict(by="Q"),
...     takeable_args=["data"],
...     merge_func="concat"
... )
... def get_quarter_return(data):
...     return data.returns.vbt.returns.total()

>>> data = vbt.YFData.pull("BTC-USD")
>>> get_quarter_return(data.loc["2021"])
Date
2021Q1    1.005805
2021Q2   -0.407050
2021Q3    0.304383
2021Q4   -0.037627
Freq: Q-DEC, dtype: float64

>>> get_quarter_return(data.loc["2022"])
Date
2022Q1   -0.045047
2022Q2   -0.572515
2022Q3    0.008429
2022Q4   -0.143154
Freq: Q-DEC, dtype: float64
```

!!! info "教程"
    更多内容请参阅[交叉验证](/zh/tutorials/cross-validation)教程。

## 切分器 \[#splitter]

新增于 1.8.0

✅ [scikit-learn](https://scikit-learn.org/stable/) 中的切分器并不完全适合验证基于机器学习或规则的交易策略。VBT
提供强大类，支持许多对回测安全的切分方案，包括滚动窗口、扩展窗口、时间锚定窗口、用于分块自助法的随机窗口，甚至 Pandas 原生 `groupby`、`resample`
指令，例如表示月频的“M”。此外，生成的切分可轻松分析和可视化！例如，可检测切分或集合重叠，将全部切分转换为单一布尔掩码以自定义分析，对切分和集合分组，并分析它们的相互分布。这个类的代码行数超过整个
[backtesting.py](https://github.com/kernc/backtesting.py) 软件包，别小看这位新王者！🦏

```python title="Roll a 360-day window and split it equally into train and test sets"
>>> data = vbt.YFData.pull("BTC-USD", start="4 years ago")
>>> splitter = vbt.Splitter.from_rolling(
...     data.index,
...     length="360 days",
...     split=0.5,
...     set_labels=["train", "test"],
...     freq="daily"
... )
>>> splitter.plots().show()
```

BTC-USD 滚动 360 天窗口，平均分为训练集和测试集. [Figure data (JSON)](/assets/figures/features/optimization/splitter.102d7b03605c.json)

!!! info "教程"
    更多内容请参阅[交叉验证](/zh/tutorials/cross-validation)教程。
