返回交易笔记
TR Day 35

Walk-Forward 验证 + 参数稳定性

WFA 从单策略升级到「双因子组合 × 参数 grid」、参数稳定性的量化诊断、OOS 净值拼接背后的统计含义、WFE 阈值与决策规则

2026-06-13
Phase 2: 策略实战 + AI 信号
WalkForwardWFAWFEParameterStabilityGridSearchOOS

日期: 2026-06-13 方向: Phase 2 / WFA 阶段: Phase 2: 策略实战 + AI 信号 标签: #WalkForward #WFA #WFE #ParameterStability #GridSearch #OOS


今日目标

类型内容
学习WFA 从单策略升级到「双因子组合 × 参数 grid」、参数稳定性的量化诊断、OOS 净值拼接背后的统计含义、WFE 阈值与决策规则
实操把 Day 28 双因子代码升级为 walk-forward + grid search,跑 2014-2024,输出 fold-by-fold 报告、参数 timeline、拼接 OOS 净值
产出TR-DAY35 笔记 + wfa_dual_factor.py + 参数稳定性诊断表 + Paper Trade 参数选定建议

一、从 Day 25 到 Day 35:WFA 概念的「真正落地」

1.1 Day 25 我们学到了什么

Day 25 我们在 SMA 单策略上跑了一遍 WFA:

2015 -------- 2017 -------- 2018  → fold 1: train [2015-2017], test [2018]
2016 -------- 2018 -------- 2019  → fold 2: train [2016-2018], test [2019]
...

每个 fold 在 train 段 grid search 最优 (fast, slow),在 test 段直接跑这组参数。最后把所有 OOS 段拼接,算 WFE = OOS Sharpe / IS Sharpe。

那一天的产出是「会写 WFA 框架」,但单 SMA 是个 toy 策略,WFA 在它身上的结论意义不大。

1.2 Day 35 真正要回答的问题

Day 28 我们做了「动量 + 低波动」双因子组合,整篇笔记的 IS Sharpe 漂亮——但那是单次切分

今天要回答的不是「这个策略好不好」,而是三个更尖锐的问题:

问题Day 28 的答案Day 35 要给的答案
这套参数能跨周期吗?不知道,只看了一次 IS/OOS跨 6+ 个 fold 看 OOS 一致性
不同 fold 选出来的参数是同一组吗?没问过参数稳定性诊断(σ/μ < 30%?)
Paper Trade 该用哪组参数?全样本最好的fold-by-fold 最常被选中的

第三个问题最容易被忽略也最关键。「全样本最好」是事后挑选偏差的极致体现——你只有在 2024 年才能知道 2014-2024 全样本最好的参数,但 2014 年你只能用 2014 年之前的数据选。WFA 要逼着你只用每个时点之前的信息做决策,再看 OOS 表现。

1.3 PM 视角的复习

Day 25 类比Day 35 类比
一个功能在 1 个城市 A/B test一个功能在 6 个城市轮流 A/B test
验证「功能能不能跑通」验证「这个功能的最佳参数是不是稳定」
决策:上不上线决策:上线时 default 参数选哪组

Day 28 是 MVP,Day 35 是 GA 前的最后一道质量门。没过 WFE 的策略不上 paper trade,没过参数稳定性的策略不固化默认参数


二、WFA 设置:训练 60 个月 + 测试 12 个月,滚动 12 个月前进

2.1 为什么是 60 + 12 + 12 这个组合

维度选择理由
训练窗口60 个月(5 年)一个完整 macro 周期至少要 5-7 年,60 个月接近下限。再短会被单一 regime(如长牛市)主导
测试窗口12 个月(1 年)OOS 至少要覆盖一个季节性循环(财报四季、年末税损卖压、Santa Rally),3-6 个月会被短期噪声主导
滚动步长12 个月前进步长 = 测试窗口 → 所有 OOS 段无重叠,拼接 OOS 净值无需任何加权
数据起点2014-01提前 1 年因子计算需要 lookback
数据终点2024-12完整 11 年数据

2.2 fold 切分(rolling,非 anchored)

fold     train (5y)              test (1y)
-----    -------------------     ----------
fold 1   2015-01 → 2019-12       2020-01 → 2020-12
fold 2   2016-01 → 2020-12       2021-01 → 2021-12
fold 3   2017-01 → 2021-12       2022-01 → 2022-12
fold 4   2018-01 → 2022-12       2023-01 → 2023-12
fold 5   2019-01 → 2023-12       2024-01 → 2024-12

只有 5 个 fold,看起来不多——但 5 个 OOS 一年完整覆盖了 2020 疫情、2021 大牛、2022 加息熊、2023 AI 反弹、2024 高位震荡这五种截然不同的 regime。

anchored vs rolling 的取舍:anchored(训练窗口越来越长)对「市场结构稳定」的资产更友好;rolling 对「结构变化频繁」的资产更友好。SP100 大盘股 + 因子策略,rolling 略胜——避免 2008 的极端数据永久污染权重。Day 25 详细讨论过。

2.3 为什么不切更细

新手常想「我切 10 个 fold 不就更稳吗?」——切更细只会让每个 train 段更短,参数估计的方差爆炸,反而失稳

fold 切分     每 fold train     train 期内 monthly obs    估计稳定性
3 折          7-8 年           84-96                       好
5 折          5-6 年           60-72                       中
10 折         3-4 年           36-48                       差(过拟合 + regime 太单一)
20 折         2 年             24                          完全失效

5-6 折是个人量化的甜蜜点。这也是 WFA 文献里反复出现的经验值。


三、要扫描的参数 grid

3.1 参数维度

Day 28 我们固定了所有参数。Day 35 要把它们当作未知数 grid search:

参数候选值维度
动量 lookback12-1(经典)/ 6-1(短期)/ 9-1(中期)3
rebalance 频率月度(M)/ 季度(Q)2
持仓分位top decile(10%)/ top quintile(20%)2
因子合成权重等权 0.5/0.5 / IC 加权(过去 6 月滚动)2

总 grid size: 3 × 2 × 2 × 2 = 24 组参数

每个 fold 都要在 train 段把这 24 组参数全部跑一遍,挑 OOS 之前的 IS Sharpe 最高的那组,然后在 test 段直接用。

3.2 每个参数为什么入选 grid

动量 lookback (12-1 / 9-1 / 6-1): 学术经典是 12-1(Jegadeesh-Titman 1993),但 6-1 在 2010+ 大盘股上多次跑赢 12-1(短期反转效应增强)。Day 35 不预判,让数据选。

rebalance 频率(M / Q): 月度调仓信号新鲜但换手成本高;季度调仓换手低但信号陈旧。这是个纯经济学 trade-off,最优解依赖滑点和因子衰减速度——也依赖 regime。

持仓分位(decile / quintile): top decile 信号纯但单票暴露大(10 只);top quintile 分散更好但混入了「中等好」的票(20 只)。

因子权重(等权 / IC 加权): 等权是无参数 baseline;IC 加权是「相信最近 6 个月哪个因子工作就加大它的权重」——典型的 momentum-of-factor。Day 28 论证过 IC 估计噪声大,但 grid search 也得让它有机会证明自己。

3.3 grid size 与多重检验的边界

24 组参数 × 5 个 fold = 120 次独立测试。这是个人量化 grid 的合理上限。再多就要做 Bonferroni 校正:

α = 0.05 / 120 = 0.0004   # 任何 fold 的「单 Sharpe」要超过这个 p 值才算显著

实务上不直接卡 Bonferroni(因为 Sharpe 不服从正态),而是用 WFA 的「跨 fold 一致性」当天然的多重检验防火墙——一组参数要在 5 个 fold 里都进 top 30%,才被认为「真的好」,而不是被某一 fold 的噪声捧上去的。


4.1 数据准备(沿用 Day 28)

"""
Day 35: WFA + Grid Search on Dual Factor (Momentum + Low Vol)
依赖: pandas, numpy, yfinance, matplotlib, tqdm
"""

import pandas as pd
import numpy as np
import yfinance as yf
from itertools import product
from tqdm import tqdm
import warnings
warnings.filterwarnings('ignore')

# ---------- 1. Universe (沿用 Day 28, 注意 survivorship bias) ----------
SP100_TICKERS = [...]  # Day 28 同一份清单

# ---------- 2. 下载 monthly closes 2013-01 → 2024-12 ----------
prices = yf.download(SP100_TICKERS, start='2013-01-01', end='2024-12-31',
                     interval='1mo', auto_adjust=True)['Close']
prices = prices.dropna(thresh=int(len(prices) * 0.8), axis=1)  # 丢弃数据缺失 > 20% 的票
returns = prices.pct_change()

4.2 因子计算(参数化版本)

def compute_momentum(returns, lookback):
    """
    lookback = (n, skip): t-n 到 t-skip 的累积收益
    经典 12-1 = (12, 1)
    """
    n, skip = lookback
    # log return 累加 = 累积收益
    logret = np.log1p(returns)
    mom = logret.rolling(n - skip).sum().shift(skip)
    return mom

def compute_low_vol(returns, lookback_months=12):
    """过去 12 个月月收益标准差,越低越好"""
    return returns.rolling(lookback_months).std()

def select_factor_portfolio(factor, top_pct, ascending=False):
    """
    factor: DataFrame (date × ticker)
    top_pct: 0.10 = top decile, 0.20 = top quintile
    ascending: True 选最小(低波动), False 选最大(动量)
    """
    ranks = factor.rank(axis=1, ascending=ascending, pct=True)
    selected = (ranks <= top_pct).astype(int)
    weights = selected.div(selected.sum(axis=1), axis=0)
    return weights

4.3 组合权重(等权 + IC 加权两种)

def combine_equal(w_mom, w_lv):
    """50% MOM + 50% LV, 重叠股票权重叠加"""
    return 0.5 * w_mom + 0.5 * w_lv

def combine_ic_weighted(w_mom, w_lv, returns, lookback=6):
    """
    IC 加权: 过去 6 个月 IC 大的因子权重大
    IC = corr(rank(factor_t), return_{t+1})
    """
    # 简化版本: 用过去 6 个月每月 portfolio return 当 IC proxy
    r_mom = (w_mom.shift(1) * returns).sum(axis=1)
    r_lv  = (w_lv.shift(1) * returns).sum(axis=1)
    
    ic_mom = r_mom.rolling(lookback).mean()
    ic_lv  = r_lv.rolling(lookback).mean()
    
    # 归一化 (允许负 IC 时该因子权重为 0)
    ic_mom_pos = ic_mom.clip(lower=0)
    ic_lv_pos  = ic_lv.clip(lower=0)
    total = ic_mom_pos + ic_lv_pos
    
    # 若两者都 ≤ 0 退化为等权
    total = total.replace(0, np.nan)
    w_mom_coef = (ic_mom_pos / total).fillna(0.5)
    w_lv_coef  = (ic_lv_pos  / total).fillna(0.5)
    
    return w_mom.mul(w_mom_coef, axis=0) + w_lv.mul(w_lv_coef, axis=0)

4.4 backtest 引擎(参数化)

def run_backtest(returns, params, start, end):
    """
    params = dict(mom_lookback, rebalance_freq, top_pct, combine_method)
    return: pd.Series of monthly portfolio returns within [start, end]
    """
    # ---- 因子 ----
    n, skip = params['mom_lookback']
    mom = compute_momentum(returns, (n, skip))
    lv  = compute_low_vol(returns, 12)
    
    # ---- 选股权重 ----
    w_mom = select_factor_portfolio(mom, params['top_pct'], ascending=False)
    w_lv  = select_factor_portfolio(lv,  params['top_pct'], ascending=True)
    
    # ---- 合成 ----
    if params['combine_method'] == 'equal':
        w = combine_equal(w_mom, w_lv)
    else:
        w = combine_ic_weighted(w_mom, w_lv, returns, lookback=6)
    
    # ---- rebalance 频率 ----
    if params['rebalance_freq'] == 'Q':
        # 只在 3/6/9/12 月底调仓, 其他月份持仓不变
        mask = w.index.month.isin([3, 6, 9, 12])
        w = w.where(mask, np.nan).ffill()
    
    # ---- 计算组合收益 (t-1 权重 × t 月收益) ----
    port_ret = (w.shift(1) * returns).sum(axis=1)
    
    # ---- 扣成本: 月度换手 × 10 bp ----
    turnover = (w - w.shift(1)).abs().sum(axis=1).fillna(0)
    cost = turnover * 0.001  # 10 bp per side
    port_ret = port_ret - cost
    
    return port_ret.loc[start:end]


def sharpe(r, rf=0.02):
    """年化 Sharpe, 月度数据"""
    if r.std() == 0 or len(r) < 6:
        return np.nan
    return (r.mean() * 12 - rf) / (r.std() * np.sqrt(12))

4.5 WFA 主循环

# ---------- 参数 grid ----------
PARAM_GRID = list(product(
    [(12, 1), (9, 1), (6, 1)],          # mom_lookback
    ['M', 'Q'],                          # rebalance_freq
    [0.10, 0.20],                        # top_pct
    ['equal', 'ic_weighted'],            # combine_method
))

def params_to_dict(p):
    return dict(mom_lookback=p[0], rebalance_freq=p[1],
                top_pct=p[2], combine_method=p[3])

# ---------- WFA folds ----------
FOLDS = [
    ('2015-01', '2019-12', '2020-01', '2020-12'),
    ('2016-01', '2020-12', '2021-01', '2021-12'),
    ('2017-01', '2021-12', '2022-01', '2022-12'),
    ('2018-01', '2022-12', '2023-01', '2023-12'),
    ('2019-01', '2023-12', '2024-01', '2024-12'),
]

results = []
oos_returns_list = []
best_params_per_fold = []

for fold_idx, (train_s, train_e, test_s, test_e) in enumerate(FOLDS):
    fold_grid_results = []
    
    # ----- IS grid search -----
    for p in PARAM_GRID:
        pd_ = params_to_dict(p)
        is_ret = run_backtest(returns, pd_, train_s, train_e)
        is_sharpe = sharpe(is_ret)
        fold_grid_results.append({
            'params': p, 'is_sharpe': is_sharpe, 'is_ret': is_ret
        })
    
    # ----- 选 IS Sharpe 最高的参数 -----
    fold_grid_results.sort(key=lambda x: x['is_sharpe'], reverse=True)
    best = fold_grid_results[0]
    best_params_per_fold.append(best['params'])
    
    # ----- OOS 用 best params -----
    oos_ret = run_backtest(returns, params_to_dict(best['params']), test_s, test_e)
    oos_sharpe = sharpe(oos_ret)
    wfe = oos_sharpe / best['is_sharpe'] if best['is_sharpe'] > 0 else np.nan
    
    results.append({
        'fold': fold_idx + 1,
        'train': f'{train_s} → {train_e}',
        'test':  f'{test_s} → {test_e}',
        'best_params': best['params'],
        'is_sharpe': best['is_sharpe'],
        'oos_sharpe': oos_sharpe,
        'wfe': wfe,
    })
    oos_returns_list.append(oos_ret)

results_df = pd.DataFrame(results)
print(results_df)

五、参数稳定性诊断

5.1 量化指标:σ/μ「偏离度」

对每个参数维度,计算它在 5 个 fold 里的标准差 / 均值:

def stability_diagnosis(best_params_per_fold):
    """
    输出每个参数维度的"偏离度"
    偏离度 = σ(选中值) / μ(选中值)
    > 30% → 不稳定,怀疑过拟合
    """
    # 解构成 4 个维度
    moms  = [p[0][0] for p in best_params_per_fold]      # 12 / 9 / 6
    freqs = [p[1]    for p in best_params_per_fold]      # 'M' / 'Q'
    pcts  = [p[2]    for p in best_params_per_fold]      # 0.10 / 0.20
    combs = [p[3]    for p in best_params_per_fold]      # 'equal' / 'ic_weighted'
    
    out = {}
    # 数值参数: σ/μ
    out['mom_lookback'] = {
        'values': moms,
        'mean': np.mean(moms), 'std': np.std(moms),
        'cv': np.std(moms) / np.mean(moms),
    }
    out['top_pct'] = {
        'values': pcts,
        'mean': np.mean(pcts), 'std': np.std(pcts),
        'cv': np.std(pcts) / np.mean(pcts),
    }
    # 类别参数: 用最常见值的出现频率衡量
    from collections import Counter
    out['rebalance_freq'] = {
        'values': freqs,
        'mode_freq': Counter(freqs).most_common(1)[0][1] / len(freqs),
    }
    out['combine_method'] = {
        'values': combs,
        'mode_freq': Counter(combs).most_common(1)[0][1] / len(combs),
    }
    return out

diag = stability_diagnosis(best_params_per_fold)
for k, v in diag.items():
    print(k, v)

5.2 预期结果与解读

经验上,跑出来大概率是这样的形态:

参数5 个 fold 选中值σ/μ 或 mode 频率稳定性判定
mom_lookback[12, 9, 6, 9, 12]σ/μ ≈ 0.26临界(regime 依赖明显)
rebalance_freq['M','M','Q','M','M']mode = 'M', 80%稳定
top_pct[0.10, 0.10, 0.20, 0.10, 0.20]σ/μ ≈ 0.33不稳定
combine_method['equal','equal','equal','equal','ic_weighted']mode = 'equal', 80%稳定

关键解读

  • mom lookback 在不同 regime 偏好不同:2020-2021 牛市 + 反弹偏好长 lookback(12-1);2022-2023 震荡偏好短 lookback(6-1)。这是有经济学解释的——趋势市长 lookback 抓得住大趋势,震荡市短 lookback 减少反向暴露。
  • rebalance 频率稳定在月度:换手成本控制内 + 信号新鲜度更值钱。
  • top_pct 不稳定:decile 在牛市更猛,quintile 在熊市更稳——这是「集中 vs 分散」的经典 trade-off,没有跨周期的「最优」。
  • 合成方法选 equal 居多:IC 加权的噪声太大,等权依然是 Day 27 / Day 28 论证过的稳健选择。

5.3 偏离度 > 30% 的处理

不是「直接放弃这个策略」,而是对该参数维度做投票而不是优化

维度的状态应对
σ/μ < 15%优化:选最常出现的值,paper trade 信心高
σ/μ 15-30%集成:跑 ensemble,5 个 fold 选中值各占 20% 资金
σ/μ > 30%不优化:用 robust default(如等权 / 中位数),不指望靠它增 Sharpe

我们这次:动量 lookback 临界 → 用 9-1(中位数 + 经验合理);top_pct 不稳定 → 用 0.15(两个候选的中点)。


六、OOS 净值拼接

6.1 为什么要拼接

5 个 fold 的 OOS 各自一年。如果分开看,每个 Sharpe 受样本量小(12 个月)影响,置信区间很宽。拼起来变成 5 年连续的 OOS 净值——这是策略「假装上线」的近似真实表现

oos_combined = pd.concat(oos_returns_list).sort_index()
nav_oos = (1 + oos_combined).cumprod()

# 整体 OOS metrics
total_oos_sharpe = sharpe(oos_combined)
total_oos_cagr   = nav_oos.iloc[-1] ** (12 / len(oos_combined)) - 1
total_oos_mdd    = (nav_oos / nav_oos.cummax() - 1).min()

print(f"OOS Combined Sharpe: {total_oos_sharpe:.2f}")
print(f"OOS Combined CAGR: {total_oos_cagr*100:.1f}%")
print(f"OOS Combined MaxDD: {total_oos_mdd*100:.1f}%")

6.2 拼接 OOS vs 全样本 IS 对比

# 全样本 IS: 把所有 fold train+test 当一段, 用「事后挑选」的最佳参数
# (这是为了演示 lookahead 偏差有多大)
all_data_grid = []
for p in PARAM_GRID:
    pd_ = params_to_dict(p)
    full_ret = run_backtest(returns, pd_, '2015-01', '2024-12')
    all_data_grid.append((p, sharpe(full_ret)))

all_data_grid.sort(key=lambda x: x[1], reverse=True)
full_sample_best_params = all_data_grid[0][0]
full_sample_best_sharpe = all_data_grid[0][1]
full_sample_ret = run_backtest(returns, params_to_dict(full_sample_best_params),
                               '2015-01', '2024-12')

print(f"Full-sample IS Sharpe (with lookahead): {full_sample_best_sharpe:.2f}")
print(f"WFA OOS Sharpe (no lookahead):          {total_oos_sharpe:.2f}")
print(f"Lookahead inflation: {(full_sample_best_sharpe / total_oos_sharpe - 1)*100:.0f}%")

6.3 预期数字

一个典型的双因子组合跑下来大概会是:

指标Full-sample ISWFA OOS (拼接)差异
Sharpe1.350.95-30%
CAGR14.5%11.2%-23%
MaxDD-17%-22%+5pp 恶化
Calmar0.850.51-40%

OOS Sharpe 0.95 不是失败,反而是漂亮的成绩。比 Full-sample 1.35 低 30%(lookahead inflation),但仍然 > 0.5,且比单一指数(SP500 Sharpe ~0.5)明显胜出。


七、WFE 决策规则

7.1 WFE 阈值表

# 每个 fold 的 WFE
fold_wfes = [r['wfe'] for r in results]
mean_wfe = np.mean(fold_wfes)
print(f"WFE per fold: {fold_wfes}")
print(f"Mean WFE: {mean_wfe:.2f}")
WFE 范围含义决策
> 0.8OOS 几乎完整保留 IS 表现极少见,怀疑数据泄漏
0.5 - 0.8健康区间,策略可上 paper tradeDay 36 IBKR 部署
0.3 - 0.5OOS 大幅衰减但仍正向缩减仓位 50% 上 paper
< 0.3OOS 几乎丢失不上 paper,回炉重构
< 0OOS 反向彻底放弃这组参数

双因子组合的经验 WFE 是 0.6-0.8——比单因子(Day 25 SMA 跑出 0.4-0.6)明显更稳。这就是 Day 28 论证过的「低相关因子组合是 free lunch」的另一个体现:不止 IS Sharpe 抬升,OOS 稳健性也抬升,所以 WFE 也更好。

7.2 单 fold WFE 异常的处理

如果某个 fold 的 WFE 显著低于其他(比如 fold 3 = 0.1,其他都是 0.7),不要简单平均掉。先问:

问题排查路径
那一年是不是特殊 regime?看 fold 3 = 2022 → 加息 + 通胀回归,因子典型失灵年
选中的参数是不是异常?看 best_params_per_fold[2] 是不是和其他差很多
是不是 cost 模型在这一年特别敏感?单独算这一年的 turnover 和 cost drag
是不是数据问题?检查 fold 3 train 段是否有异常缺失

重要原则:发现单 fold WFE 异常,去看「数据 / regime / 选参」三个维度,不要简单平均它。WFA 的价值之一就是暴露你的策略在哪种 regime 下失效——这是 paper trade 前最有价值的体检报告。


八、完整诊断报告

8.1 fold-by-fold 报告(模板输出)

================ WFA Diagnostic Report ================

Fold  Train          Test       BestParams                              IS_Sh  OOS_Sh  WFE
----  -----------    --------   -----------------------------------     ----   -----   ----
1     2015-19        2020       mom=12-1, freq=M, pct=0.10, eq          1.38    1.12   0.81
2     2016-20        2021       mom=9-1,  freq=M, pct=0.10, eq          1.52    1.08   0.71
3     2017-21        2022       mom=6-1,  freq=Q, pct=0.20, eq          1.28    0.18   0.14   ⚠
4     2018-22        2023       mom=9-1,  freq=M, pct=0.10, eq          1.31    1.05   0.80
5     2019-23        2024       mom=12-1, freq=M, pct=0.20, ic          1.41    0.94   0.67

Mean IS Sharpe:  1.38
Mean OOS Sharpe: 0.87
Mean WFE:        0.63    ← 健康区间 ✓
OOS Combined Sharpe (concat): 0.95

⚠ Fold 3 (2022) WFE 异常低: regime = 加息熊市, 因子普遍失灵
   → 不影响整体决策, 但要在 paper trade 时对加息周期信号加 vol filter

================ Parameter Stability ================

mom_lookback:    [12, 9, 6, 9, 12]      σ/μ = 0.26   临界, regime-dependent
rebalance_freq:  ['M','M','Q','M','M']  mode = M, 80%   稳定 ✓
top_pct:         [0.10,0.10,0.20,0.10,0.20]  σ/μ = 0.33   不稳定
combine_method:  ['eq','eq','eq','eq','ic'] mode = eq, 80%   稳定 ✓

================ Paper Trade Recommendation ================

mom_lookback:    9-1   (中位数 + 经验合理)
rebalance_freq:  M     (mode + 信号新鲜)
top_pct:         0.15  (两候选中点, 不优化)
combine_method:  equal (mode + 稳健)

预期 Paper Trade Sharpe: 0.7 - 1.0 (按 WFA OOS 区间)
仓位建议: 80% 满仓 (留 20% 现金应对 regime 切换)

8.2 参数选择 timeline 图

import matplotlib.pyplot as plt

fig, axes = plt.subplots(4, 1, figsize=(10, 8), sharex=True)
folds = list(range(1, 6))

axes[0].plot(folds, [p[0][0] for p in best_params_per_fold], 'o-')
axes[0].set_ylabel('Mom Lookback'); axes[0].set_yticks([6, 9, 12])
axes[1].plot(folds, [1 if p[1]=='M' else 0 for p in best_params_per_fold], 'o-')
axes[1].set_ylabel('Rebalance'); axes[1].set_yticks([0,1]); axes[1].set_yticklabels(['Q','M'])
axes[2].plot(folds, [p[2] for p in best_params_per_fold], 'o-')
axes[2].set_ylabel('Top Pct'); axes[2].set_yticks([0.10, 0.20])
axes[3].plot(folds, [1 if p[3]=='equal' else 0 for p in best_params_per_fold], 'o-')
axes[3].set_ylabel('Combine'); axes[3].set_yticks([0,1]); axes[3].set_yticklabels(['IC','EQ'])
axes[3].set_xlabel('Fold')

plt.suptitle('Best Parameters Timeline across WFA Folds')
plt.tight_layout()
plt.savefig('wfa_param_timeline.png', dpi=150)

8.3 拼接 OOS 净值图

fig, ax = plt.subplots(figsize=(12, 5))
nav_oos.plot(ax=ax, label='WFA OOS (concat)', color='steelblue', linewidth=2)
nav_full = (1 + full_sample_ret).cumprod()
nav_full.plot(ax=ax, label='Full-sample IS (lookahead)', color='lightcoral', linewidth=2, linestyle='--')

# fold 分界线
for _, _, test_s, _ in FOLDS[1:]:
    ax.axvline(pd.Timestamp(test_s), color='gray', alpha=0.3, linestyle=':')

ax.set_title('OOS Combined vs Full-sample IS')
ax.set_ylabel('NAV (starting at 1.0)')
ax.legend()
plt.savefig('wfa_oos_concat.png', dpi=150)

九、关键决策:Paper Trade 用哪组参数?

9.1 三种「错误」的选法

选法为什么错
「全样本最好」完美的 lookahead bias,没有任何样本外信号
「最近 fold 的最好」单个 fold 12 个月 = 12 个观察,统计上是噪声
「平均 IS Sharpe 最高的参数」还是 lookahead——你是在用所有 fold 的 train 数据选

9.2 正确做法:fold-by-fold 投票

「最常被选中」而非「平均最好」

from collections import Counter

# 把每个 fold 选中的参数 tuple 整体投票
voted = Counter(best_params_per_fold)
print("参数投票:")
for params, votes in voted.most_common():
    print(f"  {votes}x  {params}")

# 但若没有任何一组拿到 ≥3 票, 按维度独立投票
def vote_per_dimension(best_params_per_fold):
    moms  = Counter([p[0] for p in best_params_per_fold]).most_common(1)[0][0]
    freqs = Counter([p[1] for p in best_params_per_fold]).most_common(1)[0][0]
    pcts  = Counter([p[2] for p in best_params_per_fold]).most_common(1)[0][0]
    combs = Counter([p[3] for p in best_params_per_fold]).most_common(1)[0][0]
    return (moms, freqs, pcts, combs)

paper_params = vote_per_dimension(best_params_per_fold)
print(f"\nPaper Trade 默认参数: {paper_params}")

9.3 当稳定性 < 70% 时

如果某个维度最常出现的值频率 < 60%(5 个里有 ≤ 2 个),说明 WFA 没给出明确答案,不要 grid search,用经验默认值

维度经验默认来源
mom_lookback9-112-1 太长,6-1 太短,9-1 是文献+实战中位数
rebalance_freqM月度是 SP100 因子换手成本可控的甜蜜点
top_pct0.15decile 集中 / quintile 分散的折中
combine_methodequalDeMiguel 2009: 1/N 在 OOS 几乎不败

「不优化」是一种主动决策,不是放弃。在统计噪声里硬挑参数比用合理默认值更糟。


十、预期结果总结

跑完整套 WFA + grid search,最常见的形态:

指标数值解读
平均 IS Sharpe1.3 - 1.5grid search 必然抬高 IS
平均 OOS Sharpe0.7 - 1.0真实可达水平
平均 WFE0.6 - 0.8双因子组合典型区间,比单因子 0.4-0.6 显著好
拼接 OOS Sharpe0.9 - 1.15 年连续 OOS,比单 fold 更可信
MaxDD-20% ~ -25%OOS 比 IS 更糟,正常
参数稳定性2-3 个维度稳定,1-2 个维度临界典型,不必恐慌

如果跑出来 WFE > 0.95:99% 是 bug。最常见的:

  1. 训练段和测试段没分干净(用了未来数据算因子)
  2. survivorship bias(universe 是当前 SP100,不是 PIT)
  3. 成本模型忘记加(Day 22 那套)
  4. rebalance 时用了当月而非上月权重(look-ahead)

如果跑出来 WFE < 0.3:分两种可能:

  1. 策略本身不稳健(接受 + 重新设计因子)
  2. WFA 设置过于苛刻(train 太短 / test 太长 / fold 太多)

我们的设置(60+12)属于宽松版,如果 WFE 还是 < 0.3,几乎一定是策略本身的问题。


十一、PM 视角:迁移性思考

11.1 版本迭代 = walk-forward

软件 PM 的 release planning:
  v1.0 → 用户反馈 → v1.1 → 用户反馈 → v1.2 → ...
  
量化的 WFA:
  train_fold_1 → test_fold_1 → train_fold_2 → test_fold_2 → ...

每个 release 都是「用过去的数据决策,看未来表现」。差别只在于:软件 PM 的「过去数据」是用户访谈+埋点,量化的「过去数据」是历史价格。这两个领域的方法论是同构的

11.2 A/B test 跨 cohort = OOS

软件 PM 最大的错误之一:在城市 A 灰度 ROI = +5%,全国推开 ROI = -1%——因为城市 A 的用户结构不代表全国,这本质上就是「IS 看着好 OOS 衰减」的 PM 版本

量化术语PM 等价物
In-sample灰度城市/早期用户
Out-of-sample全量上线/扩张城市
WFE全量 ROI / 灰度 ROI
参数稳定性不同 cohort 表现的一致性
Regime change用户结构变化 / 季节性

好的 PM 决策框架,必然包含 WFE 类思维

  • 在 5 个城市灰度,每个城市单独评估,取「最差的城市表现」当全量上线的下限
  • 不同 cohort(新老用户 / 年龄段 / 城市等级)单独评估,看「表现是否一致」
  • 灰度数据每次都更新假设,而不是「灰度好了直接全量」

11.3 「最优」≠ 上线参数

我做了 10 年 PM,最大的认知差是这一条:

**「实验里最好的版本」「上线后用的版本」**应该是两个不同的东西。

  • 实验里最好的版本:用所有数据事后挑出来的,受偶然因素影响大
  • 上线后用的版本:要在跨 cohort、跨季节、跨 regime 上都「足够好」的版本

这就是 Day 35 的核心:Paper Trade 用的参数,不是 grid search 里最优那组,而是 fold-by-fold 投票出来的「众数 + 稳健默认」组合

11.4 多重检验 = A/B test 的 family-wise error

PM 同事最常被指出的统计错误:

「我跑了 8 个指标,其中点击率 +3.2%,p < 0.05,显著的」

这是 0.05 的单次 p 值,不是 family-wise。8 个指标里有 1 个 p < 0.05 在零假设下的概率约 33%(1 - 0.95⁸)。量化里 grid search 24 组 × 5 fold = 120 次测试,对应的「家族错误率」就更夸张。

WFA 是怎么对抗这个的? 用「跨 fold 一致性」当天然防火墙——一组参数要在 5 个 fold 里都进 top 30% 才有意义,单次 IS Sharpe 高没用。这套机制对应到 PM 实务就是「灰度多个城市,每个城市都得正向才放量」。


十二、明日预告

Day 36: IBKR Paper Trade 部署 — 把 Day 35 的最佳参数装到 ib_insync 自动执行

  • Day 35 选出的参数固化成 config.yaml
  • 月度 rebalance 的 cron 调度(IB Gateway 自动重启对策)
  • 实盘前最后一道安全护栏(pre-trade check / max position size / dry-run mode)
  • SQLite trade log 落地 + Slack/Email 告警
  • Paper Trade 第一周的预期与「不要看」的指标

实际执行记录

启动一项填一项,时间戳 + 卡点。

  • [hh:mm] 升级 Day 28 代码为 walk-forward + grid search — ...
  • [hh:mm] 跑完 5 个 fold × 24 组参数 = 120 次回测 — ...
  • [hh:mm] 输出 fold-by-fold 报告 + 参数稳定性诊断 — ...
  • [hh:mm] 画拼接 OOS 净值图 vs 全样本 IS — ...
  • [hh:mm] 确定 Paper Trade 默认参数 — ...
  • 跑出来的 WFE 是 ___,OOS Combined Sharpe 是 ___
  • 哪个 fold 异常?为什么?
  • 卡点 / 学到的:

总字数:约 6,400 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓