本篇是「量化工程实战进阶」系列第 48 讲。上一讲(#47)我们给因子做了"体检"——用 IC 分析和分层回测判断它到底是信号还是噪声。本讲我们把镜头对准策略本身:当你看到一个回测曲线完美、夏普惊人、回撤极小的策略时,怎么判断它是真有边缘,还是只是把历史噪声"背"了下来?这一讲给你两套反过拟合的武器——样本外检验 / Walk-Forward 与参数稳定性分析,全程用魔码行情 API 零 SDK 纯 HTTP 接入,代码已本地跑通。
一、痛点开场:那条太美的曲线往往是陷阱
做量化的人几乎都经历过这一幕:花几小时回测,得到一条几乎不回撤的完美净值曲线,胜率惊人,夏普超过 3。你信心满满上实盘,三周之后账户绿了 8%,策略连续六周亏损,你才意识到——回测记住的不是市场规律,而是这一段历史数据的噪声。这就是过拟合(curve-fitting / over-optimization)。
过拟合之所以致命,是因为它"看起来"毫无破绽。几个最典型的红旗:
- 回测夏普超过 3:全球顶尖量化基金的实盘夏普通常也只有 1~2.5。一个回测夏普 4、5 的策略,几乎可以断定是过拟合或数据偏差(前视偏差、幸存者偏差)。
- 最大回撤低于 5% + 胜率超过 75%:如果三者同时出现,策略极可能过拟合。这些数字在系统化实盘里几乎不存在。
- 最优参数很怪:移动平均周期出现 137 这种非整数感的数值,而不是 50、100 这种有解释意义的整数,往往是暴力搜索拟合噪声的产物。
- 换个时间窗口策略就崩:把回测窗口挪几个月,绩效断崖式下跌,说明它只适配这一段历史。
社区里一个被反复印证的结论:“优化参数每增加一个,过拟合的表面积就乘以一倍;一个 15 个参数、跑过 1 万次组合的策略,无论回测多漂亮,几乎一定过拟合。”换句话说,对抗过拟合的本质,是克制——克制参数数量、克制规则堆叠、克制"看到结果再改规则"的冲动。本篇教你用工程化手段,把"看起来好"和"真的好"区分开。
二、本文你将得到什么
- 一个数据接入层:用魔码日线 API(零 SDK 纯 HTTP)真实拉取标的,演示接入方式。
- 一套鲁棒检验框架:单因子模型的样本内 / 样本外 IC 对比,以及 5 折滚动 Walk-Forward 检验参数稳定性。
- 一个过拟合反例:21 因子暴力拟合在"少样本 vs 多特征"下的样本内 R² 虚高、样本外 R² 崩塌的完整演示。
- 一份完整可运行代码(已验证),覆盖"接入→构造真实因子与幌子因子→鲁棒检验→过拟合对照"。
- 六条抗过拟合铁律:定义先于优化、参数最少化、样本外只测一次、跨市场/跨状态验证、蒙特卡洛重排、定期再优化但别太勤。
三、第一步:用魔码日线接入
和前几讲一致,因子与策略研究的第一步是把数据弄干净。这里用魔码行情 API 零 SDK 纯 HTTP 拉取日线,无需任何客户端 SDK:
importrequests BASE="https://api.momaapi.com"LICENCE="TEST-API-TOKEN-MOMA-836089C22111"# 演示证书,请换成你自己的正式证书deffetch_moma_daily(code,st,et):url=f"{BASE}/hsstock/history/{code}/d/n/{LICENCE}?st={st}&et={et}"rows=requests.get(url,timeout=20).json()ifnotisinstance(rows,list):raiseRuntimeError(f"接口返回异常:{rows}")returnrows魔码日线字段稳定(o/h/l/c/v/a/pc/sf),复权口径统一,意味着你在不同时间拉同一段历史,因子值与策略输入是可复现的——这恰恰是做样本内 / 样本外严格分离的前提。如果你的数据源本身字段会漂移,那么"样本外"检验从根上就不成立。
注:演示证书返回固定样本,无法做真实全市场截面;下方演示用截面 panel 为固定随机种子生成的演示数据(含一个真实因子 + 20 个纯噪声幌子因子),仅用于演示算法逻辑,非真实行情。真实证书可直接拉真实截面,把同样代码跑在真实数据上。
四、第二步:鲁棒检验——样本内 / 样本外 IC 与 Walk-Forward
对抗过拟合的第一道闸门,是把数据严格分成两段:**样本内(In-Sample, IS)**用于构造与调参,**样本外(Out-of-Sample, OOS)**预留出来只测一次。如果 OOS 表现显著差于 IS,策略就是过拟合的。黄金法则:参数定稿前绝不看 OOS;看了之后若想改参数,必须从头重来。
比单次切分更严谨的是Walk-Forward(滚动前向检验):把数据切成连续窗口,每个窗口上在 IS 段优化参数、在紧接着的 OOS 段不加修改地测试,再把所有 OOS 段拼接,得到一条"模拟实盘"的净值。它的核心产出有两个:
- 效率比(Efficiency Ratio):平均 OOS 表现 / 平均 IS 表现。高于 80% 说明参数稳健;60%~80% 有预测力但需监控;低于 60% 直接丢弃,不管 IS 多漂亮。
- 参数稳定性:如果各窗口选出的"最优参数"在数值上剧烈跳变,说明策略不稳定、大概率是过拟合。
下面用 #47 讲过的 IC 工具做鲁棒检验。我们构造一个含真实因子的截面:未来收益由单一真实因子线性驱动(这是真边缘),另有 20 个纯噪声幌子因子不进入真实模型:
importnumpyasnpdefrankdata(x):order=np.argsort(x,kind="mergesort")ranks=np.empty(len(x),dtype=float)sx=x[order];i,n=0,len(x)whilei<n:j=iwhilej+1<nandsx[j+1]==sx[i]:j+=1avg=(i+j)/2.0+1.0forkinrange(i,j+1):ranks[order[k]]=avg i=j+1returnranksdefspearman_ic(score,fwd_ret):mask=~np.isnan(score)&~np.isnan(fwd_ret)s,r=score[mask],fwd_ret[mask]iflen(s)<30:returnnp.nan rs,rr=rankdata(s),rankdata(r)rs-=rs.mean();rr-=rr.mean()denom=np.sqrt((rs**2).sum()*(rr**2).sum())returnfloat((rs*rr).sum()/denom)ifdenom>0elsenp.nandefbuild_panel(n_stocks=500,n_dates=120,n_spurious=20,seed=20260912):rng=np.random.default_rng(seed)f=rng.standard_normal((n_dates,n_stocks))# 真实因子(有边缘)g=rng.standard_normal((n_dates,n_spurious,n_stocks))# 20 个幌子因子(纯噪声)ret=0.05*f+rng.standard_normal((n_dates,n_stocks))# 真实模型:仅 f 有预测力returnf,g,ret鲁棒检验只使用真实因子f:算 IS 段与 OOS 段的截面 IC,再用 5 折滚动看 IC 是否稳定。
五、第三步:过拟合反例——21 因子暴力拟合
过拟合最容易发生在"参数数 ≈ 样本数比例偏高"时。我们故意构造一个少样本 vs 多特征的拟合场景:在一个 15 期 × 25 只 = 375 个样本的小块上,用含 20 个噪声因子的 21 因子模型做最小二乘拟合。噪声因子在 IS 段被"记住",R² 被虚抬;而在 OOS 大块上,这些系数毫无意义,R² 直接崩到 0 甚至为负。
defdesign(g_block,f_block,ret_block):T,K,N=g_block.shape X=np.column_stack([f_block.reshape(-1)]+[g_block[:,k,:].reshape(-1)forkinrange(K)])returnX,ret_block.reshape(-1)defr2(X,y,coef):pred=X @ coef ss_res=((y-pred)**2).mean()ss_tot=((y-y.mean())**2).mean()return1.0-ss_res/ss_totifss_tot>0else0.0对照实验同时给出:同一小块只拟合真实因子(1 因子)本应有的 R²,让你直观看到噪声因子把 IS R² 虚抬了多少。
六、完整可运行代码(已本地跑通)
# -*- coding: utf-8 -*-importrequestsimportnumpyasnp BASE="https://api.momaapi.com"LICENCE="TEST-API-TOKEN-MOMA-836089C22111"# 演示证书,请换成你自己的正式证书deffetch_moma_daily(code,st,et):url=f"{BASE}/hsstock/history/{code}/d/n/{LICENCE}?st={st}&et={et}"rows=requests.get(url,timeout=20).json()ifnotisinstance(rows,list):raiseRuntimeError(f"接口返回异常:{rows}")returnrowsdefrankdata(x):order=np.argsort(x,kind="mergesort")ranks=np.empty(len(x),dtype=float)sx=x[order];i,n=0,len(x)whilei<n:j=iwhilej+1<nandsx[j+1]==sx[i]:j+=1avg=(i+j)/2.0+1.0forkinrange(i,j+1):ranks[order[k]]=avg i=j+1returnranksdefspearman_ic(score,fwd_ret):mask=~np.isnan(score)&~np.isnan(fwd_ret)s,r=score[mask],fwd_ret[mask]iflen(s)<30:returnnp.nan rs,rr=rankdata(s),rankdata(r)rs-=rs.mean();rr-=rr.mean()denom=np.sqrt((rs**2).sum()*(rr**2).sum())returnfloat((rs*rr).sum()/denom)ifdenom>0elsenp.nandefdesign(g_block,f_block,ret_block):T,K,N=g_block.shape X=np.column_stack([f_block.reshape(-1)]+[g_block[:,k,:].reshape(-1)forkinrange(K)])returnX,ret_block.reshape(-1)defr2(X,y,coef):pred=X @ coef ss_res=((y-pred)**2).mean()ss_tot=((y-y.mean())**2).mean()return1.0-ss_res/ss_totifss_tot>0else0.0defbuild_panel(n_stocks=500,n_dates=120,n_spurious=20,seed=20260912):rng=np.random.default_rng(seed)f=rng.standard_normal((n_dates,n_stocks))g=rng.standard_normal((n_dates,n_spurious,n_stocks))ret=0.05*f+rng.standard_normal((n_dates,n_stocks))returnf,g,retdefmain():real=fetch_moma_daily("600519","20250101","20250901")print("真实接口返回 rows=",len(real),"(演示证书固定样本)")f,g,ret=build_panel()n_dates=f.shape[0]is_end=int(n_dates*0.7)ic_is=np.nanmean([spearman_ic(f[:is_end][t],ret[:is_end][t])fortinrange(is_end)])ic_oos=np.nanmean([spearman_ic(f[is_end:][t],ret[is_end:][t])fortinrange(n_dates-is_end)])print(f"鲁棒(1因子): IS-IC={ic_is:.4f}OOS-IC={ic_oos:.4f}效率比={ic_oos/ic_is:.2%}")# 5 折滚动folds,step=5,n_dates//5fis=[]forkinrange(folds):a=k*step;b=a+int(step*0.7);c=a+stepifc>n_datesorb<=a:continuefis.append(np.nanmean([spearman_ic(f[a:b][t],ret[a:b][t])fortinrange(b-a)]))print(f" 各折IC={np.round(fis,4).tolist()}跨折IC标准差={np.std(fis):.4f}")# 过拟合反例sd,sn=15,25Xis,yis=design(g[:sd,:,:sn],f[:sd,:,:sn],ret[:sd,:,:sn])Xos,yos=design(g[sd:],f[sd:],ret[sd:])coef,*_=np.linalg.lstsq(Xis,yis,rcond=None)r2_is=r2(Xis,yis,coef)r2_oos=r2(Xos,yos,coef)coef1,*_=np.linalg.lstsq(Xis[:,:1],yis,rcond=None)r2_is_1f=r2(Xis[:,:1],yis,coef1)print(f"过拟合(21因子): IS-R²={r2_is:.4f}(1因子仅{r2_is_1f:.4f}) OOS-R²={r2_oos:.4f}效率比={r2_oos/r2_is:.2%}")if__name__=="__main__":main()真实运行输出(节选,演示证书接入层已验证、panel 为固定种子演示数据):
真实接口返回 rows= 50 (演示证书固定样本) 鲁棒(1因子): IS-IC=0.0400 OOS-IC=0.0446 效率比=111.49% 各折IC=[0.0396, 0.0319, 0.0415, 0.0579, 0.0471] 跨折IC标准差=0.0086 过拟合(21因子): IS-R²=0.0900(1因子仅-0.0018) OOS-R²=-0.0945 效率比=-105.05%结果非常说明问题:鲁棒单因子模型 IS-IC 与 OOS-IC 几乎一致(效率比 111%,跨折 IC 标准差仅 0.0086,极其稳定)——它抓到的是真实边缘,样本外能复现。而 21 因子暴力拟合,IS-R² 被噪声因子虚抬到 0.09(注意:只用真实因子的 1 因子模型在同一小块 IS-R² 仅为 -0.0018),可一到 OOS 大块,R² 直接跌到 -0.0945,效率比 -105%——典型的"样本内完美、样本外崩塌"。这条曲线如果拿去实盘,亏损是大概率事件。文中数据为演示用合成截面,仅供演示算法逻辑,非实时行情;生产环境请使用你自己的魔码正式证书,并以官方文档与实时返回为准。
七、六个抗过拟合铁律
- 定义先于优化。跑任何优化前,先写下策略逻辑和每个参数的经济含义。一个没有理由的参数,就是过拟合的直接入口。
- 参数最少化(奥卡姆剃刀)。同等性能下,永远选参数更少的策略。经验上每个自由参数至少需要约 30 笔成交来支撑,5 个以内参数最稳妥。
- 样本外只测一次。IS 定稿前绝不看 OOS;看了之后若想改参数,必须从头重来,否则检验作废。
- 跨市场 / 跨状态验证。一个只在 2023-2025 牛市的策略,和只在某一指数上有效的策略,都应视为可疑。真边缘应在趋势、震荡、高波动多种状态下都站得住。
- 蒙特卡洛重排(互补手段)。把历史成交序列随机打乱上千次,若你的真实净值排不进前 5%,说明优势可能来自运气而非真实边缘。
- 定期再优化但别太勤。日线级策略每 2~3 个月再优化一次足够;更频繁本身就是一种过拟合。
八、小结与下篇预告
过拟合的本质不是"模型太复杂",而是"模型在记忆而非学习"。对抗它的工程手段并不神秘:严格分离样本内 / 样本外、用 Walk-Forward 看效率比与参数稳定性、控制自由参数数量、跨状态验证。当你学会用这些工具对一条"完美曲线"保持怀疑,你就已经绕开了绝大多数量化新手会踩的第一个大坑。
下一篇(#49)我们把视野拉到更上游的数据组织层:多资产组合数据底座(A股 / ETF / 指数统一接入)——如何用一个统一接口把不同资产类别的行情收拢成可交叉分析的数据资产。
文中接口调用使用演示证书,返回数据为样本示例;生产环境请使用你自己的魔码正式证书,并以官方文档与实时返回为准。
更多量化工程实战内容,欢迎访问魔码官方技术博客:https://www.momaapi.com/blog/