Walk-Forward 验证 + 参数稳定性
WFA 从单策略升级到「双因子组合 × 参数 grid」、参数稳定性的量化诊断、OOS 净值拼接背后的统计含义、WFE 阈值与决策规则
日期: 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:
| 参数 | 候选值 | 维度 |
|---|---|---|
| 动量 lookback | 12-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 的噪声捧上去的。
四、完整代码:Day 28 → walk-forward + grid search
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 IS | WFA OOS (拼接) | 差异 |
|---|---|---|---|
| Sharpe | 1.35 | 0.95 | -30% |
| CAGR | 14.5% | 11.2% | -23% |
| MaxDD | -17% | -22% | +5pp 恶化 |
| Calmar | 0.85 | 0.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.8 | OOS 几乎完整保留 IS 表现 | 极少见,怀疑数据泄漏 |
| 0.5 - 0.8 | 健康区间,策略可上 paper trade | Day 36 IBKR 部署 |
| 0.3 - 0.5 | OOS 大幅衰减但仍正向 | 缩减仓位 50% 上 paper |
| < 0.3 | OOS 几乎丢失 | 不上 paper,回炉重构 |
| < 0 | OOS 反向 | 彻底放弃这组参数 |
双因子组合的经验 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_lookback | 9-1 | 12-1 太长,6-1 太短,9-1 是文献+实战中位数 |
| rebalance_freq | M | 月度是 SP100 因子换手成本可控的甜蜜点 |
| top_pct | 0.15 | decile 集中 / quintile 分散的折中 |
| combine_method | equal | DeMiguel 2009: 1/N 在 OOS 几乎不败 |
「不优化」是一种主动决策,不是放弃。在统计噪声里硬挑参数比用合理默认值更糟。
十、预期结果总结
跑完整套 WFA + grid search,最常见的形态:
| 指标 | 数值 | 解读 |
|---|---|---|
| 平均 IS Sharpe | 1.3 - 1.5 | grid search 必然抬高 IS |
| 平均 OOS Sharpe | 0.7 - 1.0 | 真实可达水平 |
| 平均 WFE | 0.6 - 0.8 | 双因子组合典型区间,比单因子 0.4-0.6 显著好 |
| 拼接 OOS Sharpe | 0.9 - 1.1 | 5 年连续 OOS,比单 fold 更可信 |
| MaxDD | -20% ~ -25% | OOS 比 IS 更糟,正常 |
| 参数稳定性 | 2-3 个维度稳定,1-2 个维度临界 | 典型,不必恐慌 |
如果跑出来 WFE > 0.95:99% 是 bug。最常见的:
- 训练段和测试段没分干净(用了未来数据算因子)
- survivorship bias(universe 是当前 SP100,不是 PIT)
- 成本模型忘记加(Day 22 那套)
- rebalance 时用了当月而非上月权重(look-ahead)
如果跑出来 WFE < 0.3:分两种可能:
- 策略本身不稳健(接受 + 重新设计因子)
- 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 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓