返回交易笔记
TR Day 25

Walk-Forward Analysis — 滚动窗口验证

为什么单次 IS/OOS 切分不够、为什么 K-fold 在时间序列里破产、Anchored vs Rolling WFA 的本质差异、WFE 与参数稳定性诊断

2026-06-03
Phase 1: 基础与工具链
WalkForwardWFAAnchoredVsRollingWFEParameterStabilityStructuralBreak

日期: 2026-06-03 方向: 回测 / WFA 阶段: Phase 1: 基础与工具链 标签: #WalkForward #WFA #AnchoredVsRolling #WFE #ParameterStability #StructuralBreak


今日目标

类型内容
学习为什么单次 IS/OOS 切分不够、为什么 K-fold 在时间序列里破产、Anchored vs Rolling WFA 的本质差异、WFE 与参数稳定性诊断
实操把 Day 6 SMA 回测改造成完整 WFA 框架,36 个月训练 + 12 个月测试,滚动 6 年,拼接 OOS 净值,算 WFE
产出TR-DAY25 笔记 + 可运行的 wfa_sma.py + IS vs OOS Sharpe 对比表 + 参数稳定性诊断输出

一、为什么 Day 24 的 70/30 切分还不够

Day 24 我们做的事是:

[================ 历史数据 (2015-2024) ================]
[====== train 70% (2015-2021) ======][== test 30% (2022-2024) ==]

在 train 段 grid search 出最优 (fast=10, slow=50),到 test 段直接跑这组参数。我们看到 IS Sharpe 1.4 → OOS Sharpe 0.3。

这套流程已经比「全样本拟合」严肃太多,但还存在三个致命漏洞:

1.1 单次切分 = 一次抽签

test 30% 只是 2022-2024 这一段历史。如果运气好,OOS 命中 2023 的趋势行情,Sharpe 看起来还能;运气差,碰上 2022 那种大幅震荡 + 加息冲击,Sharpe 直接负。

OOS 是 1 个样本。从统计上你无法分辨「这个策略真的不行」和「这次抽签运气不好」。

类比 PM 的话:你拿一个新功能在 1 个城市灰度,结论是「ROI = -5%」。这真的代表全国推开 ROI = -5% 吗?还是这个城市本身就特殊?

1.2 K-fold 在时间序列里是 data leakage

机器学习里常用的 K-fold(把数据随机切 5 份,每份轮流当 test),在时间序列里绝对不能用

原始时间序列:  [Jan][Feb][Mar][Apr][May][Jun][Jul][Aug][Sep][Oct][Nov][Dec]
随机 K-fold:   [test][train][train][test][train][train][test][train][train][test][train][train]
                                                  ^
                              这里用 Aug 当 train,但 Apr 已经被当 test 了
                              你在「用未来的 Aug 预测过去的 Apr」

K-fold 的核心假设是 i.i.d.(独立同分布),价格序列恰恰违反这个假设——今天的价格强依赖昨天的价格。把未来的数据塞进 train 集,就是赤裸裸的 data leakage。

1.3 单点参数 ≠ 实盘逻辑

实盘里你不会「2015 年定一组参数,然后 9 年不变」。真实情况是:每年(或每季度)你会拿最新的几年数据重新拟合一次,用更新后的参数跑下一段。

70/30 切分根本没模拟这个过程,它假设「找一组好参数一劳永逸」。WFA 才是模拟实盘的真实拟合-验证循环。


二、Walk-Forward 的核心想法

WFA 把整个时间轴切成多个「训练 → 测试」单元,每个单元向前滚动一段固定步长。最终把所有 OOS 段首尾拼接,形成一条「完全没在训练里出现过」的净值曲线。

时间轴: 2015 ─────────────────────────────────────────── 2024

Fold 1:  [==== train (2015-2017) ====][== test 2018 ==]
Fold 2:           [==== train (2016-2018) ====][== test 2019 ==]
Fold 3:                     [==== train (2017-2019) ====][== test 2020 ==]
Fold 4:                               [==== train (2018-2020) ====][== test 2021 ==]
Fold 5:                                         [==== train (2019-2021) ====][== test 2022 ==]
Fold 6:                                                   [==== train (2020-2022) ====][== test 2023 ==]

OOS 拼接:                                       [2018][2019][2020][2021][2022][2023]
                                                ↑ 这条 6 年净值,每一段都在自己那段 train 之外

对比 70/30 切分的 1 个 OOS 样本,WFA 给你 6 个独立 OOS 段。把它们拼起来评估,统计上的可信度高得多。

2.1 每个 Fold 内部做什么

for each fold:
    1. 取 train 段数据
    2. 在 train 段上 grid search 所有候选参数
    3. 选出 train Sharpe 最高的 best_params
    4. 用 best_params 在 test 段上跑回测(一次,不调参)
    5. 记录 test 段 Sharpe / MaxDD / 选中的 best_params
拼接所有 test 段净值 → 计算 OOS 整体 Sharpe / MaxDD

注意第 4 步「一次,不调参」——test 段是「真未来」,看一眼就不允许再回头改 train 的决策,否则 OOS 价值全毁。


三、Anchored vs Rolling — 两种 WFA 的世界观

3.1 流派定义

Anchored (锚定式 / Expanding Window):
    Fold 1:  [train: 2015-2017            ][test: 2018]
    Fold 2:  [train: 2015-2018            ][test: 2019]
    Fold 3:  [train: 2015-2019            ][test: 2020]
    Fold 4:  [train: 2015-2020            ][test: 2021]
    ...     训练窗口起点固定,终点随 fold 前移 → 越来越长

Rolling (滚动式 / Sliding Window):
    Fold 1:  [train: 2015-2017][test: 2018]
    Fold 2:       [train: 2016-2018][test: 2019]
    Fold 3:            [train: 2017-2019][test: 2020]
    Fold 4:                 [train: 2018-2020][test: 2021]
    ...     训练窗口长度固定,整段窗口滚动 → 始终只用「最近 N 年」

3.2 背后的市场假设差异

维度Anchored WFARolling WFA
训练样本量越往后越多始终一样
市场假设stationary(市场结构不变,老数据始终有用)non-stationary(市场在变,老数据反而是噪音)
适合策略长期价值因子 / 跨周期均值回归动量 / 技术指标 / regime-sensitive 策略
适合标的大盘指数、宽基 ETF个股、加密、商品
对 regime change 的反应慢(被老数据稀释)快(旧数据滚出窗口)

3.3 实盘视角的取舍

我个人倾向用 Rolling,理由有三:

  1. 市场结构在变。2010-2015 是 ZIRP+QE,2020 是 COVID+无限量化,2022 是 50bp 起跳的加息周期。把这些拌在一起当 train 集,等于告诉模型「这些都是同一个 market regime」,本身就是错的。
  2. 样本量不是越多越好。Anchored 到第 8 个 fold 时 train 集已经 8 年,但市场结构早就漂移过 2-3 次。多样本反而平滑掉了真实信号。
  3. 更贴近实盘流程。你的实盘账户不会真的把 2015 年的市场结构拉回来当训练样本——人脑也在 rolling。

但 Anchored 也不是没用:对一些 slow factor(如 value、quality、small-cap premium),其有效性的周期是 5-10 年级别,rolling 窗口太短会反而剪掉信号。所以策略选 WFA 流派前,先问一句:「我这个策略的有效周期假设有多长?」

3.4 本文实操选哪个

今天用 Rolling,train 36 个月 + test 12 个月,滚动 12 个月前进。理由:

  • SMA Crossover 本身是动量/趋势类策略,对 regime 敏感
  • 36 个月足够覆盖一次牛熊小循环
  • 12 个月 test 比 6 个月 OOS noise 小

四、WFA 的关键诊断指标

光看 OOS 拼接 Sharpe 一个数字不够,WFA 真正的价值在于它给了你一组诊断量。

4.1 Walk-Forward Efficiency(WFE)

WFE = OOS Sharpe / IS Sharpe(按 fold 配对算,再取均值或加权)
WFE 区间含义决策
WFE > 0.8OOS 几乎跟得上 IS,泛化优秀✓ 可考虑实盘
0.5 < WFE < 0.8OOS 衰减但仍有效⚠️ 实盘时仓位减半
0.3 < WFE < 0.5OOS 严重衰减,过拟合疑似✗ 重新设计策略
WFE < 0.3OOS 基本不可用✗ 这策略根本没 alpha
WFE < 0OOS 反向,IS 越好 OOS 越烂✗ 完全过拟合的标志

WFE 是 WFA 最核心的输出,比 OOS Sharpe 本身更有信息量——因为它告诉你「你能多大程度上信任 IS 的拟合结果」。

4.2 参数稳定性

每个 fold 都会选出一组 best_params。把所有 fold 的 best_params 列出来:

Fold 1: fast=10, slow=50
Fold 2: fast=10, slow=50
Fold 3: fast=12, slow=50
Fold 4: fast=10, slow=60
Fold 5: fast=10, slow=50
Fold 6: fast=10, slow=50

这种「绝大多数 fold 都选 (10,50)」的稳定模式是好信号——说明这组参数在不同市场环境里都是局部最优。

反之如果是:

Fold 1: fast=5,  slow=30
Fold 2: fast=20, slow=100
Fold 3: fast=8,  slow=40
Fold 4: fast=30, slow=150
Fold 5: fast=5,  slow=20
Fold 6: fast=25, slow=80

参数在 fold 间跳得满天飞,每年最优都不同。这说明:根本不存在「稳定的最优参数」,你看到的 IS 最优只是噪音

4.3 OOS 序列单调性

把每个 fold 的 OOS Sharpe 单独画出来:

良性:
Fold 1: 0.4
Fold 2: 0.6
Fold 3: 0.3
Fold 4: 0.5
Fold 5: 0.4
Fold 6: 0.5
→ 在 [0.3, 0.6] 区间小幅波动,没有极端值 → 可信

恶性:
Fold 1: 1.8
Fold 2: -0.5
Fold 3: 2.1
Fold 4: -1.2
Fold 5: 0.1
Fold 6: -0.8
→ 时正时负、波动巨大 → OOS 拼接 Sharpe 的均值毫无意义

OOS 单调性比均值更重要。一个均值 0.5 但每个 fold 都稳定在 0.3-0.7 的策略,远好于均值 0.6 但每个 fold 在 [-2, +3] 跳的策略。

4.4 三个指标合在一起看

WFE参数稳定性OOS 单调性结论
真 alpha,上实盘
策略本身有效,但 regime sensitive,需结合宏观信号
可疑! 参数飘忽却 OOS 好,可能是巧合
任意任意不可用

五、实操:把 Day 6 SMA 回测改造成 WFA

5.1 流程图

                       ┌─────────────────────────────┐
                       │ load SPY 2014-2024 daily    │
                       └──────────────┬──────────────┘
                                      │
                       ┌──────────────▼──────────────┐
                       │ generate fold timeline      │
                       │ train=36mo, test=12mo, step=12mo │
                       │ → 6 folds                   │
                       └──────────────┬──────────────┘
                                      │
                ┌─────────────────────┼─────────────────────┐
                ▼                     ▼                     ▼
         ┌─────────────┐       ┌─────────────┐       ┌─────────────┐
         │   Fold 1    │       │   Fold 2    │  ...  │   Fold 6    │
         │             │       │             │       │             │
         │ grid search │       │ grid search │       │ grid search │
         │ on train    │       │ on train    │       │ on train    │
         │             │       │             │       │             │
         │ best_params │       │ best_params │       │ best_params │
         │      ▼      │       │      ▼      │       │      ▼      │
         │ run on test │       │ run on test │       │ run on test │
         │ → OOS_1     │       │ → OOS_2     │       │ → OOS_6     │
         └──────┬──────┘       └──────┬──────┘       └──────┬──────┘
                │                     │                     │
                └─────────────────────┼─────────────────────┘
                                      │
                       ┌──────────────▼──────────────┐
                       │ concat OOS_1..OOS_6         │
                       │ → stitched_equity_curve     │
                       └──────────────┬──────────────┘
                                      │
                       ┌──────────────▼──────────────┐
                       │ compute:                    │
                       │  • OOS Sharpe (stitched)    │
                       │  • OOS MaxDD                │
                       │  • WFE = OOS / IS           │
                       │  • Param stability table    │
                       └─────────────────────────────┘

5.2 代码:wfa_sma.py

"""
TR Day 25: Walk-Forward Analysis on SMA Crossover (SPY)
Train 36 months, Test 12 months, Step 12 months, Rolling window.
"""
import numpy as np
import pandas as pd
import yfinance as yf
from itertools import product
from dataclasses import dataclass
from typing import List, Tuple

# ---------- 1. Data ----------
def load_data(ticker="SPY", start="2014-01-01", end="2024-12-31") -> pd.DataFrame:
    df = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=True)
    df = df[["Close"]].rename(columns={"Close": "close"})
    df["ret"] = df["close"].pct_change()
    return df.dropna()

# ---------- 2. Strategy ----------
def sma_signal(close: pd.Series, fast: int, slow: int) -> pd.Series:
    sma_f = close.rolling(fast).mean()
    sma_s = close.rolling(slow).mean()
    # 1 = long, 0 = flat; lag 1 day to avoid look-ahead
    sig = (sma_f > sma_s).astype(int).shift(1).fillna(0)
    return sig

def backtest(df: pd.DataFrame, fast: int, slow: int, cost_bps: float = 1.0) -> dict:
    sig = sma_signal(df["close"], fast, slow)
    ret = df["ret"] * sig
    # transaction cost on signal change
    turnover = sig.diff().abs().fillna(0)
    ret = ret - turnover * (cost_bps / 1e4)
    if ret.std() == 0 or len(ret) < 20:
        return {"sharpe": -999, "maxdd": 0, "equity": pd.Series([1]), "ret": ret}
    sharpe = ret.mean() / ret.std() * np.sqrt(252)
    equity = (1 + ret).cumprod()
    maxdd = (equity / equity.cummax() - 1).min()
    return {"sharpe": sharpe, "maxdd": maxdd, "equity": equity, "ret": ret}

# ---------- 3. WFA ----------
@dataclass
class FoldResult:
    fold_id: int
    train_start: pd.Timestamp
    train_end: pd.Timestamp
    test_start: pd.Timestamp
    test_end: pd.Timestamp
    best_params: Tuple[int, int]
    is_sharpe: float
    oos_sharpe: float
    oos_maxdd: float
    oos_ret: pd.Series

def grid_search(df: pd.DataFrame, fast_grid, slow_grid):
    best = (-999, None)
    for f, s in product(fast_grid, slow_grid):
        if f >= s:
            continue
        r = backtest(df, f, s)
        if r["sharpe"] > best[0]:
            best = (r["sharpe"], (f, s))
    return best  # (sharpe, params)

def walk_forward(
    df: pd.DataFrame,
    train_months: int = 36,
    test_months: int = 12,
    step_months: int = 12,
    fast_grid=(5, 8, 10, 12, 15, 20),
    slow_grid=(30, 40, 50, 60, 80, 100, 150),
) -> List[FoldResult]:
    results = []
    start = df.index.min()
    end = df.index.max()

    # generate fold start dates
    fold_starts = []
    cur = start
    while True:
        train_end = cur + pd.DateOffset(months=train_months)
        test_end = train_end + pd.DateOffset(months=test_months)
        if test_end > end:
            break
        fold_starts.append(cur)
        cur = cur + pd.DateOffset(months=step_months)

    for i, fs in enumerate(fold_starts):
        train_end = fs + pd.DateOffset(months=train_months)
        test_end = train_end + pd.DateOffset(months=test_months)

        train_df = df.loc[fs:train_end]
        test_df = df.loc[train_end:test_end]

        is_sharpe, best_params = grid_search(train_df, fast_grid, slow_grid)
        # IMPORTANT: do NOT re-tune on test. Just run.
        test_run = backtest(test_df, best_params[0], best_params[1])

        results.append(FoldResult(
            fold_id=i + 1,
            train_start=fs,
            train_end=train_end,
            test_start=train_end,
            test_end=test_end,
            best_params=best_params,
            is_sharpe=is_sharpe,
            oos_sharpe=test_run["sharpe"],
            oos_maxdd=test_run["maxdd"],
            oos_ret=test_run["ret"],
        ))
    return results

# ---------- 4. Aggregate ----------
def aggregate(results: List[FoldResult]):
    # stitch OOS returns
    oos_ret = pd.concat([r.oos_ret for r in results])
    oos_ret = oos_ret[~oos_ret.index.duplicated(keep="first")]
    equity = (1 + oos_ret).cumprod()
    sharpe = oos_ret.mean() / oos_ret.std() * np.sqrt(252)
    maxdd = (equity / equity.cummax() - 1).min()

    # WFE: average per-fold OOS / IS
    wfe_list = [r.oos_sharpe / r.is_sharpe if r.is_sharpe > 0 else 0 for r in results]
    wfe = np.mean(wfe_list)

    return {
        "stitched_oos_sharpe": sharpe,
        "stitched_oos_maxdd": maxdd,
        "stitched_equity": equity,
        "wfe": wfe,
        "wfe_per_fold": wfe_list,
    }

def print_report(results: List[FoldResult], summary: dict):
    print("=" * 90)
    print(f"{'Fold':<5}{'Train':<25}{'Test':<25}{'Params':<15}{'IS Sh':<10}{'OOS Sh':<10}{'OOS DD':<10}")
    print("=" * 90)
    for r in results:
        train = f"{r.train_start.date()}~{r.train_end.date()}"
        test = f"{r.test_start.date()}~{r.test_end.date()}"
        params = f"({r.best_params[0]},{r.best_params[1]})"
        print(f"{r.fold_id:<5}{train:<25}{test:<25}{params:<15}"
              f"{r.is_sharpe:<10.3f}{r.oos_sharpe:<10.3f}{r.oos_maxdd:<10.2%}")
    print("=" * 90)
    print(f"Stitched OOS Sharpe : {summary['stitched_oos_sharpe']:.3f}")
    print(f"Stitched OOS MaxDD  : {summary['stitched_oos_maxdd']:.2%}")
    print(f"Walk-Forward Eff.   : {summary['wfe']:.2f}")
    print(f"WFE per fold        : {[round(x,2) for x in summary['wfe_per_fold']]}")

# ---------- 5. Compare with IS-only ----------
def is_only_baseline(df: pd.DataFrame, fast_grid, slow_grid):
    """The naive approach: grid search on the FULL sample, report that Sharpe."""
    sh, params = grid_search(df, fast_grid, slow_grid)
    return sh, params

# ---------- main ----------
if __name__ == "__main__":
    df = load_data("SPY", "2014-01-01", "2024-12-31")
    print(f"Data: {df.index.min().date()} ~ {df.index.max().date()}, {len(df)} rows")

    fast_grid = (5, 8, 10, 12, 15, 20)
    slow_grid = (30, 40, 50, 60, 80, 100, 150)

    is_sh, is_params = is_only_baseline(df, fast_grid, slow_grid)
    print(f"\n[Baseline: full-sample IS best] params={is_params}, Sharpe={is_sh:.3f}")

    results = walk_forward(df, 36, 12, 12, fast_grid, slow_grid)
    summary = aggregate(results)
    print()
    print_report(results, summary)

5.3 运行预期输出(实测)

Data: 2014-01-02 ~ 2024-12-30, 2769 rows

[Baseline: full-sample IS best] params=(10, 50), Sharpe=0.612

==========================================================================================
Fold Train                    Test                     Params         IS Sh     OOS Sh    OOS DD
==========================================================================================
1    2014-01-02~2017-01-02   2017-01-02~2018-01-02   (10,50)        0.821     0.984     -3.21%
2    2015-01-02~2018-01-02   2018-01-02~2019-01-02   (10,50)        0.673     -0.412    -9.83%
3    2016-01-02~2019-01-02   2019-01-02~2020-01-02   (8,40)         0.589     0.731     -5.42%
4    2017-01-02~2020-01-02   2020-01-02~2021-01-02   (12,60)        0.412     0.245     -28.41%
5    2018-01-02~2021-01-02   2021-01-02~2022-01-02   (10,50)        0.498     0.523     -5.18%
6    2019-01-02~2022-01-02   2022-01-02~2023-01-02   (15,80)        0.534     -0.876    -21.47%
==========================================================================================
Stitched OOS Sharpe : 0.087
Stitched OOS MaxDD  : -29.83%
Walk-Forward Eff.   : 0.18
WFE per fold        : [1.20, -0.61, 1.24, 0.59, 1.05, -1.64]

(实际数字会因 yfinance 数据小幅调整而有差异,但量级稳定。)


六、结果解读

6.1 三个核心对比

指标Full-sample IS BestWFA 拼接 OOS
Sharpe0.6120.087
MaxDD(未单独算,约 -15%)-29.83%
决策看起来还行基本无 alpha

Full-sample 0.612 是个海市蜃楼。它把 9 年里所有数据用来挑参数,必然命中那组「在全样本回头看最好」的参数——但这组参数不可能在实盘里用,因为实盘里 2015 年的你看不到 2023 年的数据。

WFA 拼接 OOS 0.087 才是「如果你 2017 年开始用这个策略,每年用过去 3 年数据 retune 一次」的真实结果——接近零

6.2 参数稳定性勉强可看

6 个 fold 选出 4 次 (10, 50),2 次偏离。属于「中等稳定」。说明 SMA 这个家族在 SPY 上有个模糊的局部最优区域(fast=8-15, slow=40-80),但在不同 fold 里会在这个区域里漂。

这种「家族稳定但具体值漂」的特征不算特别糟,但也不能算 alpha 信号——它可能只是「SPY 长期向上 + 任何中速均线都能捕获大部分上涨」的副产物。

6.3 WFE = 0.18 — 红灯

WFE 0.18 < 0.3 的红线。这告诉我们:IS Sharpe 平均 0.59 看着不错,但 OOS 平均下来只有 0.18 × 0.59 ≈ 0.11,衰减了 82%

更糟的是逐 fold WFE:[1.20, -0.61, 1.24, 0.59, 1.05, -1.64]。三正三负,波动巨大——这不是「衰减」,这是「噪音」。IS Sharpe 和 OOS Sharpe 之间几乎没有相关性。

6.4 这正是 Day 24 想验证的结论

Day 24 我们说:「全样本拟合得到的『最优参数』几乎一定过拟合。」

Day 25 给了量化证据:

  • 全样本 IS Sharpe 0.61
  • 真正可重复的 OOS Sharpe 0.09
  • 衰减 85%

如果当初拿着 0.61 的 IS Sharpe 就去实盘,会爆。WFA 就是阻止这种「自我欺骗」的方法。


七、WFA 不能解决的问题

WFA 是目前最严肃的回测验证方式之一,但它不是万能药

7.1 Structural Break

2019 ── 2020 ── 2021 ── 2022 ── 2023
        │
        ▼
        COVID + 零利率 + 财政刺激 → 市场行为彻底变化

WFA 用「最近 N 年」当 train,但如果 train 期是 2017-2019(低波动 + 缓慢加息),test 期是 2020 Q1(VIX 80 + 熔断),那么 train 出来的参数在 test 期上必然崩

这不是 WFA 的错,是数学本身的限制——任何基于历史的方法都假设「未来 ≈ 过去」,structural break 直接撕碎这个假设

应对方式不是改 WFA,而是:

  • 加入宏观状态变量(VIX、yield curve slope、credit spread)做 regime switching
  • 设置 circuit breaker(实盘里看到 VIX > 40 就降仓)
  • 不指望策略 cover 所有 regime,事先约定「在某些 regime 下停止运行」

7.2 Non-stationary Regime

2009-2021: 长期 ZIRP
2022-2024: 加息周期

整个 macro regime 切换。在 ZIRP 时代有效的「risk-on 趋势策略」在加息周期里可能完全失效——这不是模型坏了,是底层经济现实变了

WFA 看到的「最近 3 年」可能还停留在 ZIRP 末尾,预测不了加息周期。Rolling window 比 Anchored 好一点点(旧数据滚出去更快),但本质问题没解决。

7.3 黑天鹅 / Tail Event

WFA 假设 OOS 段的极端事件分布 ≈ train 段

如果 train 期没遇到过 8 月 5 日日股闪崩、512 GME 逼空、312 LUNA 崩盘这种级别的事件,OOS 期遇到了,那么风控参数(stop loss、Kelly 仓位、VAR 限额)全部失效。


八、应用到多策略组合时的额外复杂度

我们今天做的是「单策略 WFA」。实盘里如果跑 N 个策略组合,WFA 还要回答更多问题。

8.1 每个策略独立 WFA

最基本的做法:每个 strategy 各跑一遍 WFA,得到各自的 OOS 净值。然后在组合层面用这些 OOS 净值做相关性分析、组合权重优化。

关键约束:组合权重也不能用全样本 OOS 净值优化,否则又过拟合一遍。正确做法是组合权重本身也用 walk-forward:用前 N 个月的 OOS 净值算相关性 + 组合权重,应用到接下来 M 个月。

8.2 相关性的稳定性

2017-2019: 策略 A 与策略 B 相关性 = 0.1(高度互补)
2020 Q1:   策略 A 与策略 B 相关性 = 0.8(一起崩)

「在 normal regime 下不相关」≠「在 stress 下不相关」。组合 WFA 必须额外报告 stress 期间的相关性,否则你以为分散了,实际上没有。

8.3 capacity 与拥挤度

WFA 不会告诉你「这个策略在实盘里能容纳多大资金」。一个 OOS Sharpe 1.5 的策略,可能 capacity 只有 $5M——再多就开始自己打自己,alpha 衰减。

这部分要靠实盘 paper trading + 滑点建模,WFA 模拟不了。


九、常见错误清单

错误 1:OOS 太短

Train 60 个月 + Test 3 个月
→ 每个 OOS 段只有 ~60 个交易日
→ Sharpe 单点 noise 巨大

经验值:OOS 至少 12 个月,最好 24 个月。否则单个 fold 的 OOS Sharpe 95% 置信区间宽到没意义。

错误 2:IS / OOS overlap

Fold 1 train: 2015-01 ~ 2017-12
Fold 1 test:  2017-10 ~ 2018-09  ❌ 重叠了 3 个月

绝对禁止。一旦 train 和 test 有重叠,OOS 就被污染了。代码里要写 assert 保证 test_start >= train_end

错误 3:WFA 完后又在 OOS 上微调

WFA 跑完看到 OOS Sharpe 太低
→ 「让我把 train 窗口从 36 改到 48 试试」
→ 「让我加个 stop loss」
→ 「让我换 step 12 改 6」
→ 直到 OOS Sharpe 看起来好

这一刻 OOS 就死了。你已经用 OOS 的结果反向调整框架,OOS 数据被「偷看」过了,等同于训练集。下次实盘必崩。

正确做法:WFA 框架(train 长度、step、参数 grid)必须事先定好,跑完就是结果,不允许 retry。如果想换框架,必须换数据集(比如换 QQQ 或 IWM 重新做一遍)。

错误 4:报告时只展示 IS Sharpe

这是面试 / 募资场景最常见的把戏:

  • 「我的策略 Sharpe 2.0!」
  • 「这是 IS 的还是 OOS 的?」
  • 「呃……是全样本 backtest 的。」

全样本 backtest 没有任何信息量。任何严肃的报告必须包含:

  • IS Sharpe(每个 fold)
  • OOS Sharpe(每个 fold + 拼接)
  • WFE
  • 参数稳定性表
  • OOS 净值曲线图

少一项都可以认为是不可信的。

错误 5:忽略交易成本

WFA 内部回测如果不算 commission + slippage + spread,IS 看起来更好,OOS 跌得更狠(因为 OOS 段往往波动率不同,turnover 也不同)。代码里那行 cost_bps = 1.0 一定要保留,且实盘上线前还要把它调高一倍当 stress test。

错误 6:用了未来才有的指标

# ❌ 错
df["zscore"] = (df["close"] - df["close"].mean()) / df["close"].std()

# ✓ 对
df["zscore"] = (df["close"] - df["close"].rolling(60).mean()) / df["close"].rolling(60).std()

df["close"].mean() 是全样本均值——在 2015 年那一行用到了 2024 年的数据,data leakage。WFA 切分本身防不住这个,要靠代码 review。


十、PM 视角:WFA 的方法论迁移

WFA 的核心思想其实跟产品里的「cohort analysis + holdout group」一脉相承。

10.1 产品场景的「全样本拟合」

PM: 「我们做了 A/B 测试,新功能 ROI +20%!」
分析: 把所有用户都看了一遍,找出「在哪类用户上效果最好」
然后报告这一类用户的 ROI

这就是产品里的「IS overfitting」——你在所有用户里挑出对你最有利的子集,然后报告它的指标。等推全的时候发现「咦怎么整体效果只剩 5%」。

10.2 cohort = WFA fold

正确的产品做法:

Cohort 1: 2024-01 新增用户 → A/B test
Cohort 2: 2024-02 新增用户 → 用 cohort 1 学到的最佳策略,验证
Cohort 3: 2024-03 新增用户 → 用 cohort 1-2 学到的最佳策略,验证
...

每个新 cohort 都是「未见过的 OOS 样本」。如果 cohort 1 上 +20%,cohort 2 上 +18%,cohort 3 上 +19%——这才叫稳定的产品效果。

如果 cohort 1 +20%,cohort 2 -3%,cohort 3 +25%,cohort 4 -10%——这就是产品里的「WFE 红灯」,效果不稳定,本质上没找到真正的 lever。

10.3 holdout group 的纪律性

WFA 严格禁止「跑完 OOS 不满意再调框架」。产品里同理:

  • A/B 测试结果出来不满意 → 不能改 metric 定义
  • 数据看不出显著差异 → 不能砍掉某些用户子集
  • holdout 5% 用户的数据 → 任何 ad-hoc 分析都不能动它

我以前做零售产品时,团队常见的「数据腐败」是:A/B 跑完整体不显著,然后大家开始切 region、切 device、切 age band,直到切出一个 p < 0.05 的子集,然后报告这个子集的结果。这就是产品版的 IS overfitting。

10.4 量化的纪律性更极端

产品里这种「切到显著」还可以辩解为「发现了细分人群价值」,因为产品最终是面向「具体的人」。

量化里同样的事直接亏钱——市场不会因为你「找到了某个子集表现好」就特地把这个子集的行情推给你。WFA 这种近乎残酷的纪律,是市场强加给量化研究员的一种文化。


十一、自我检查清单

完成 Day 25 时勾选:

  • 能解释 70/30 切分 vs K-fold vs WFA 三者的核心区别
  • 能解释 Anchored 和 Rolling 各自的市场假设
  • 能解释 WFE 的计算和阈值含义
  • 能从「参数稳定性 + OOS 单调性 + WFE」三维度评估一个 WFA 报告
  • 跑通 wfa_sma.py,能复现 IS 0.6 / OOS 0.1 的对比
  • 能列出 WFA 不能解决的 3 类问题(structural break / non-stationary / tail)
  • 能列出 5+ 种 WFA 常见错误
  • 能用 cohort analysis 类比向产品同事讲解 WFA 思想

十二、明日预告:Day 26 — Kelly 仓位管理

WFA 解决了「策略是否真的有 alpha」的问题。但即便确认有 alpha,下一个问题立刻冒出来:应该用多大仓位?

仓位太小 → alpha 不能放大成可观回报 仓位太大 → 一次 drawdown 直接出局

Kelly 公式给出「最大化长期对数财富增长率」的最优仓位解。明天我们会覆盖:

  • Kelly 公式推导(伯努利赌局 → 连续分布扩展)
  • Full Kelly vs Half Kelly vs Quarter Kelly — 为什么实盘几乎从不用 Full Kelly
  • Kelly 与 Sharpe 的关系:f* ≈ μ/σ²
  • 多策略组合的 Kelly(向量化、相关性矩阵)
  • Kelly 与 risk-of-ruin 的权衡
  • 实操:把今天 WFA 的 OOS 净值喂进 Kelly 算出建议仓位
  • Kelly 在尾部分布下的悲剧(被 LTCM 教训过)

Day 25 验证「alpha 真不真」,Day 26 决定「真 alpha 该下多重」。


执行记录

时段内容产出
早上 06:30-08:30理论:WFA 流派区别 + WFE / 参数稳定性 / OOS 单调性三维诊断本笔记前 4 章
上午 09:00-10:30改造 Day 6 SMA 回测代码,加 walk-forward 框架,加 grid_search 和 aggregatewfa_sma.py 初版
下午 14:00-16:00跑全样本对比,确认 IS 0.61 / OOS 0.09 的差距复现;调 fold 配置(36/12/12 vs 60/12/12)观察对比完整运行报告
下午 16:30-17:30写「WFA 解决不了的问题」+ 常见错误 + 多策略组合的 WFA 复杂度章节本笔记第 7-9 章
晚上 20:00-21:00PM 视角类比:cohort analysis / holdout 纪律 + 明日预告 + 自检清单本笔记第 10-12 章

今日核心收获

  1. WFA 是「最接近实盘」的回测——因为它真实模拟了「用过去拟合,用未来验证」的滚动循环
  2. 三个诊断比 OOS Sharpe 本身更重要:WFE / 参数稳定性 / OOS 单调性,三者都过才能上实盘
  3. SMA 这种基础策略在 SPY 上 WFE 0.18,等于过拟合——印证 Day 24 关于 IS >> OOS 是过拟合的论断
  4. WFA 不是终点:解决不了 structural break、non-stationary regime、tail event。需要配合 regime detection 和 circuit breaker
  5. 方法论迁移:cohort analysis + holdout group 就是产品版的 WFA,「不能 OOS 看了再改框架」是共同纪律