手上有一堆零散的日线数据,想尽快验证一个关于股票趋势预测的想法,最省事的路径往往不是从零搭一套 Python 工程,而是打开 MATLAB 写一个几百行的脚本——我把它叫 gupiao.m。这个 MATLAB 股票趋势预测工具包不是高频交易系统,也不承诺任何收益,它做的事情非常具体:读入一份行情表,算出若干技术指标和统计特征,构造"未来一段时间的涨跌方向"标签,训练一个分类模型,做滚动回测,最后把资金曲线、混淆矩阵、特征重要性画成几张结果图。整套源码就是一个主脚本加六个辅助函数,跑完自动落盘 PNG,方便反复对比不同参数下的结果图。
我最初写它纯粹是为了省时间。做量化研究的人都清楚,一个想法从"脑子里闪过"到"看到图上有没有信号",中间隔着的全是脏活:复权、对齐、缺失值、标签对齐、训练测试切分。这些东西每次重写一遍非常折磨人,所以干脆固化成模板,换数据只改配置。适合读这篇内容的人大概有三类:一是刚接触量化、想找个能跑通的最小闭环练手的学生;二是有 MATLAB 授权、懒得再装一套环境的研究人员;三是想拿它做教学演示或者内部技术分享的从业者。下面我按数据、算法、代码、读图、排错的顺序,把踩过的坑和最后的定稿方案完整拆一遍。
1. 整体设计与技术选型:这个工具包到底解决什么问题
动手写代码之前,最该想清楚的是边界。我见过太多人一上来就说"我要预测股价",然后花三个月做出来的东西既不能回测也不能解释,最后不了了之。所以 gupiao.m 从第一版起就给自己划了三条线:不预测具体价格点位,只预测方向;不做日内,只用日线;不做全市场选股,只对单一标的或一小组标的做时序判断。这三条限制看起来是自我阉割,实际上是让整个流程能在几百行代码里闭环的关键,也是我后面所有设计取舍的出发点。
1.1 需求拆解:预测什么、不预测什么
预测价格的具体数值,是一个几乎不可能做好的任务。股价是非平稳序列,均值和方差随时间漂移,你今天拟合出来的回归系数,下个月就失效了。所以工具包把问题降维成二分类或三分类:未来 N 个交易日的累计收益是涨、跌、还是横盘。降维之后,模型输出的不再是"明天收盘 12.37 元"这种容易被证伪的绝对值,而是一个概率,比如"上涨概率 0.58",这个概率还能画成分布图,一眼看出模型是自信还是在瞎猜。
这个拆解带来的第二个好处是可评估。方向预测可以用混淆矩阵、AUC、命中率来衡量,这些指标的口径非常清楚,不像价格回归那样容易被"平均绝对误差看起来还行但其实全靠猜均值"骗过去。工具包里我固定输出三个口径:整体命中率、上涨类的精确率与召回率、以及扣除成本后的资金曲线。三个口径必须同时看,只看其中一个必然误判。
注意:方向预测的准确率天花板很低。日线级别上,能稳定做到 55% 以上就已经算有信息量,任何声称 80% 以上命中率的日线模型,第一反应应该是去查未来函数,而不是高兴。
1.2 技术选型:为什么是 MATLAB 而不是别的
选 MATLAB 的理由很实在,不是情怀。第一,这个工具包处理的是"时间 × 特征"的二维表,本质就是矩阵,MATLAB 的 table 和 timetable 类型对时间索引、重采样、对齐的支持非常顺手,一行synchronize或retime就能解决很多麻烦。第二,绘图成本极低,plot、histogram、confusionchart几行代码就能出能贴进汇报材料的图,这对需要频繁看结果图的研究流程是巨大加成。
第三,统计与机器学习工具箱里现成的分类器够用,逻辑回归、决策树集成、支持向量机都能直接调,交叉验证和超参数搜索也有封装好的函数,不用自己造轮子。第四,脚本天然适合"改一行跑一次"的探索式工作,不需要处理包管理和虚拟环境,这对非软件工程背景的使用者友好得多。
代价也要说清楚:MATLAB 是商业软件,没有授权就没法跑,源码发给别人对方未必能执行;部署成服务也不方便,它更适合做研究原型而不是生产系统;另外社区里现成的量化库远不如另一种主流语言丰富,很多轮子得自己写。所以我的定位很明确:gupiao.m 是研究工具,跑通想法、验证信号、产出图表,验证成功了再考虑用别的技术栈重写。
1.3 工具包的整体结构与 gupiao.m 的职责边界
最终的目录结构我调整过三次,最后一次定稿是把配置、数据、特征、标签、回测、绘图彻底分开,主脚本只负责编排流程,不写具体逻辑。这样做的原因是,探索阶段最常改的是特征和参数,如果这些都塞在主脚本里,改一次要翻两百行,很容易改错地方。拆开之后,改特征只需动一个文件,回测逻辑完全不受影响,出错的概率大幅下降。
| 文件名 | 职责 | 改动频率 |
|---|---|---|
| gupiao.m | 主流程编排,串起所有环节 | 低 |
| config_init.m | 路径、窗口长度、阈值、成本参数 | 高 |
| load_price.m | 读取行情、复权、对齐交易日 | 中 |
| build_features.m | 生成技术指标与统计特征 | 高 |
| make_labels.m | 构造前瞻收益与分类标签 | 中 |
| walk_forward.m | 滚动训练与预测,输出概率序列 | 低 |
| plot_result.m | 统一出图并保存 PNG | 低 |
职责边界划清之后,还有一个隐性好处:可复现。每次实验只要把 config_init.m 里的参数和随机种子记录下来,配合固定的数据文件,理论上任何一次结果图都能原样重现。我吃过亏——早期版本里随机种子没固定,同一个配置跑两次结果差 3 个百分点,我以为是数据问题,查了大半天才发现是树模型每次随机抽样不同。从那以后,种子固定写进配置,成了铁律。
2. 数据层:脏活干不好,后面全是白费
这一章是我最想强调的部分,因为它决定了后面所有工作的上限。我见过太多人把 80% 的精力花在调模型上,数据只花 20%,结果模型怎么调都不灵。真实情况恰恰相反:日线数据里能挖出的信息本来就稀薄,一点点处理不当——复权错了、停牌没对齐、除权跳空当成信号——就足以把本来微弱的信号彻底淹没。数据层的目标不是"洗干净",而是"不制造假信号"。
2.1 字段口径与复权处理
最小可用的字段集合只有七个:日期、开盘、最高、最低、收盘、成交量、成交额。有这七个就能算出绝大部分常用指标。麻烦在于复权。原始行情在除权除息日会出现价格跳空,比如某标的实施送转,价格从 30 元直接变成 15 元,如果你不做处理,均线会瞬间跌破,各种突破信号集体触发,而这些全是假的。
这里必须区分两种复权。前复权是以当前价格为基准向前调整历史价格,好处是图形连续、当前价格与实际报价一致,适合做可视化和近期分析;缺点是每次出现新的除权,历史序列会被整体重算,导致你今天跑出的历史信号和上个月跑出的不一样,回测结果无法复现。后复权是以历史某点为基准向后调整,历史序列一旦生成就不再变化,适合做严谨的滚动回测。我的选择是:做回测和训练时用后复权,画图给人看时用前复权,两份数据分开存,文件名后缀区分。
注意:成交量在复权时也要同步调整,否则量价关系类特征(比如量比、换手率)在除权日前后会失真。经验做法是成交量按价格复权因子的倒数缩放,很多人只调价格忘了调量,这个坑非常隐蔽。
2.2 交易日对齐、停牌与缺失值
真实数据里一定会遇到停牌。停牌期间没有行情记录,如果你直接按行号索引计算滚动窗口,等于把停牌前后的日子硬拼在一起,时间跨度被压缩,指标全错。解决办法是构造一份完整的交易日历,用 timetable 把每只标的重新索引到日历上,缺失的日期显式标记为 NaN,这样滚动窗口的时间跨度就永远是真实的交易日数。
缺失值的填补要分情况。价格类的短缺口(一到三天)我允许前向填充,因为停牌复牌后价格连续性大体还在;超过五天的缺口我不填,直接把该窗口标记为无效,在训练时剔除。成交量类缺口一律不填,用 NaN 表示"当日无交易",让模型自己学习这个状态——实际上"无交易"本身就是信息,停牌前后往往伴随波动率变化。这条经验是我在对比实验里发现的:把停牌日当成交量零处理,比前向填充的模型表现稳定得多。
另外还要处理涨跌停造成的"不可成交"。回测里如果信号触发在涨停当天,实际是买不进去的,把这种交易算进收益就等于自我欺骗。工具包里我用当日最高价等于最低价、且涨幅达到限制这一条件来做粗略判定,命中就把该笔交易标记为不可执行并跳过。这个判定不完美,但足以过滤掉大部分虚假收益。
2.3 特征工程的务实做法
特征的数量控制是新手最容易犯的错误。我刚做这件事的时候,一口气写了六十多个特征,什么都能算一点,结果模型在训练集上 AUC 到 0.9,测试集掉到 0.5,典型的过拟合。后来把特征砍到二十个左右,效果反而稳定。判断标准很简单:这个特征背后有没有一个能说清楚的行为逻辑?说得清就留,说不清就砍。
留下来的特征大致分四组。趋势组:5/10/20/60 日均线及其相对位置、均线斜率;动量组:5/20 日收益率、相对强弱指标、MACD 柱状值;波动组:20 日已实现波动率、真实波幅均值、最高最低价区间宽度;量能组:成交量相对 20 日均量的比值、成交额变化率。每组三到六个,总数控制在二十上下。
注意:所有需要标准化的特征(比如收益率、成交量比值),标准化参数必须只用训练窗口内的数据计算,然后应用到测试窗口。如果在全样本上算一次均值和标准差再切分,测试集的信息就泄漏进训练过程了,这是最经典也最难自查的错误之一。
3. 核心算法:三条路线怎么选、参数怎么算
算法部分我不想写成教科书,重点讲取舍。这个工具包里实际上并存三条路线,不是让它们互相打架,而是让它们互为参照:技术指标路线给可解释性,统计模型给基线,树模型给非线性能力。三条路线跑同一份数据、同一种标签,结果图叠在一起看,如果三条路线给出的信号高度一致,可信度就高一些;如果只有某一条特别亮眼,大概率是过拟合。
3.1 技术指标路线:先有一个能说人话的基线
技术指标路线的价值不在于赚钱,而在于提供一个"说人话的基线"。均线金叉、MACD 柱由负转正、相对强弱指标从超卖区回升,这些东西拿给任何人看都能理解,而且不需要训练。工具包里我把它们组合成一个简单的评分:三个条件各贡献一分,总分大于等于二就给出看多信号。这个评分不做任何优化,参数就用最常见的设置,故意保持"朴素"。
为什么要刻意保持朴素?因为它是检验复杂模型的标尺。如果花了两天调出来的机器学习模型,回测表现还不如一条 20 日均线,那基本可以断定模型学的是噪声,而不是规律。我遇到过一次,某个加了十二个特征的模型年化比均线策略还低两个点,一开始以为是自己代码有 bug,后来把特征重要性打出来看,发现排前两位的全是滞后一期的收盘价衍生物,等于模型在抄昨天的答案。
纯指标策略也有明显的短板。它的规则是硬的,一旦市场结构变化就整体失效;它无法输出概率,只能给二值信号,不利于做仓位管理;参数(比如均线周期)稍有改动结果差异很大,说明它对参数敏感。所以它只能作为基线,不能作为最终方案。
3.2 统计与机器学习路线:从线性到树模型
统计路线的第一步是逻辑回归。它的好处是系数可解释,正负号和大小直接告诉你某个特征在往哪个方向推。我一般先把全部特征标准化后跑一遍逻辑回归,看系数符号是否符合常识:如果波动率特征的系数是正的(意味着波动越大越看涨),那就要回头检查标签构造是不是有问题,因为在多数市场状态下,波动放大伴随的是下行风险。
第二步上树模型。决策树集成方法对特征的非线性关系和交互效应捕捉得更好,也不太需要精细的标准化,代价是可解释性下降,只能靠特征重要性图来找补。工具包里我限制树的最大深度在 3 到 4 层,叶子节点最小样本数不低于 30,目的就是压住过拟合。这两条限制看起来保守,但实测下来,放开深度的收益几乎全在训练集上,测试集反而更差。
至于时间序列专用的模型,我也试过。自回归类方法在纯价格序列上的短期拟合能力不差,但它对平稳性的要求很严,日线价格本身不平稳,做一阶差分之后噪声又太大,实际用下来不如直接把滞后收益率当特征喂给分类器来得省事。所以最终版本里,自回归模型只作为一条对照线保留,不参与主流程。
3.3 标签设计:把"趋势"翻译成可学习的目标
标签设计是整个工具包里最需要琢磨的一环。定义太松,正负样本混在一起,模型学不到东西;定义太紧,样本量骤减,又容易过拟合。我用的是三段式:先算未来 N 个交易日的累计收益,再设定一个阈值带,收益高于正阈值为上涨类,低于负阈值为下跌类,中间的不参与训练。
阈值怎么定?不能拍脑袋。我的做法是用过去 250 个交易日的日收益率标准差估计当前波动水平,再按 √N 缩放得到 N 日累计收益的标准差,阈值取这个标准差的一半。举例说明:假设某标的近一年日收益率标准差为 1.8%,N 取 20,那么 20 日累计收益的标准差约等于 1.8% × √20 ≈ 8.0%。阈值取一半就是 ±4.0%。用正态近似估算,落在 ±0.5 个标准差内的概率大约是 38%,也就是说上涨类约占 31%,下跌类约占 31%,中间类约 38%。这个比例相对平衡,既保留了足够的训练样本,又让类别之间有明显区分。
这个设计有一个重要的隐含假设:波动率是慢变的。在绝大多数时段这个假设成立,但在波动率骤升的阶段(比如市场情绪剧烈变化),固定窗口估计的阈值会偏小,导致大量样本被划入极端类。所以工具包里加了一层保护:如果近 20 日的已实现波动率超过近 250 日均值的 1.8 倍,就暂停开新仓,只做预测不做交易。这条规则不是最优的,但它挡住了几次明显的失效。
3.4 滚动窗口与样本量估算
训练测试切分绝对不能随机打乱。时间序列打乱之后,模型在训练时见过"未来",测试集的表现会虚高到离谱。我采用严格的滚动前推:用第 1 到第 500 个交易日训练,预测第 501 到第 520 个交易日;窗口整体前移 20 天,再用第 21 到第 520 天训练,预测第 521 到第 540 天,如此循环。
样本量要提前算清楚。五年日线大约 1215 个交易日,减去标签前瞻损失的 20 天,可用样本约 1195 个。训练窗 500 天,每折前进 20 天,大约能滚 34 折,累计得到约 680 个样本外预测点。这个数量对于评估一个模型来说是够的,但要注意:这 680 个预测点之间高度相关,因为它们共享重叠的训练数据和相邻的标签窗口,所以有效样本量远小于 680,统计显著性会比表面看起来弱得多。
注意:二十个特征、五百个训练样本,样本参数比约 25 比 1。这个比例对线性模型勉强够用,对树模型就得靠深度限制和叶子最小样本数来约束。如果你想把特征加到四十个,训练窗至少要拉到一千天,否则别指望样本外表现。
4. gupiao.m 的实操流程与关键代码
前面讲的是设计,这一章讲怎么落地。我按主脚本的执行顺序走一遍,把关键的几段代码贴出来,并说明每段为什么这么写。代码风格上我刻意保持朴素,不用任何高级语法糖,目的是让别人拿到源码能直接读懂、直接改。所有函数名和变量名都用英文,注释用中文,这个混搭方式在团队里接受度最高。
4.1 主流程骨架:把七步串成一条线
主脚本只做编排,不写业务逻辑。它的结构是一条直线:初始化配置、读数据、建特征、造标签、滚动回测、算指标、出图。我特意把它写成直线而不是包成一个大函数,是因为探索阶段需要频繁在中间打断点看变量,包成函数之后调试会很不方便。
% gupiao.m clc; clear; close all; rng(20240101); % 固定随机种子,保证可复现 cfg = config_init(); % 路径、窗口、阈值、成本 raw = load_price(cfg); % 读行情 + 复权 + 交易日对齐 feat = build_features(raw, cfg); lab = make_labels(raw, cfg); res = walk_forward(feat, lab, cfg); fprintf('样本外命中率: %.2f%%\n', res.hitRate * 100); fprintf('上涨类精确率: %.2f%%, 召回率: %.2f%%\n', ... res.precUp * 100, res.recUp * 100); plot_result(res, cfg); % 统一出图,自动存 PNG这段代码里唯一值得多说一句的是rng。放在最前面而不是函数内部,是因为只要放一次,整个会话的随机性就全都固定住了;如果放在函数里,不同函数调用顺序变化还是会引入差异。
4.2 数据读取与指标计算
读取环节我要求所有数据源都先转成统一的七列格式,字段名固定为 date、open、high、low、close、volume、amount,日期统一转成 datetime 类型并设为 timetable 的时间索引。这一步看似繁琐,实际上是整个工具包可移植性的基础——换数据源只改前面的转换代码,后面全部不动。
function raw = load_price(cfg) t = readtable(cfg.file); t.date = datetime(t.date, 'InputFormat', 'yyyy-MM-dd'); t = sortrows(t, 'date'); tt = table2timetable(t(:, 2:end), 'RowTimes', t.date); % 交易日历对齐:缺失日期显式置 NaN fullCal = (cfg.calStart : caldays(1) : cfg.calEnd)'; fullCal = fullCal(isbusday(fullCal)); % 剔除周末,节假日由数据本身决定 tt = retime(tt, fullCal, 'fillwithmissing'); % 价格短缺口前向填充,长缺口保留 NaN raw.tt = fillmissing(tt, 'previous', 'MaxGap', caldays(3)); raw.ret = [NaN; diff(log(raw.tt.close))]; raw.valid = ~isnan(raw.tt.close) & ~isnan(raw.ret); endfillmissing的MaxGap参数是关键。不加这个限制,前向填充会把长达半年的停牌期全部填成同一个价格,波动率特征直接变成零,模型会误以为这段时间"极度稳定"。加上三天限制之后,长缺口保持 NaN,后面在特征构造阶段用valid掩码整体剔除。
指标计算我全部手写,没有用现成的技术分析工具箱函数。原因是手写版本能精确控制 NaN 的处理方式,而且窗口长度是变量,方便做参数敏感性测试。以均线为例,用movmean一行就能算,但要注意窗口不足时的输出是 NaN,必须在前瞻对齐之前处理干净。
4.3 特征与标签的构建
特征构建函数返回的是一张与 timetable 行对齐的矩阵,每一行对应一个交易日,每一列对应一个特征。标签则是一个分类向量,取值 1 表示上涨、-1 表示下跌、0 表示中性(不参与训练)。两者必须严格对齐到同一时间索引,否则会出现"用未来特征预测过去"这种致命错误。
function feat = build_features(raw, cfg) c = raw.tt.close; v = raw.tt.volume; f = struct(); for k = [5 10 20 60] f.(sprintf('ma%d', k)) = movmean(c, k) ./ c - 1; end f.mom5 = c ./ circshift(c, 5) - 1; f.mom20 = c ./ circshift(c, 20) - 1; f.vol20 = movstd(raw.ret, 20, 'omitnan') * sqrt(252); f.vr20 = v ./ movmean(v, 20, 'omitnan'); f.range = (raw.tt.high - raw.tt.low) ./ c; names = fieldnames(f); M = nan(height(raw.tt), numel(names)); for i = 1:numel(names) M(:, i) = f.(names{i}); end feat.X = M; feat.names = names; feat.idx = raw.valid; end标签构造要注意两点。第一,前瞻收益必须用未来 N 日的收盘价计算,且最后 N 行没有完整标签,要整行删除,不能填零。第二,阈值必须用滚动窗口估计,不能用全样本的波动率,否则又是一种信息泄漏。
function lab = make_labels(raw, cfg) n = cfg.horizon; % 前瞻天数,如 20 c = raw.tt.close; fwd = circshift(c, -n) ./ c - 1; % 未来 n 日累计收益 sigma = movstd(raw.ret, 250, 'omitnan') * sqrt(n); thr = 0.5 * sigma; % 阈值 = 半个标准差 y = zeros(size(fwd)); y(fwd > thr) = 1; y(fwd < -thr) = -1; y(isnan(fwd) | isnan(thr)) = NaN; lab.y = y; end4.4 滚动回测:这一段决定了结果可不可信
滚动回测是整个工具包里代码最短但最容易写错的一段。核心约束只有一条:任何在时点 t 做出的预测,只能使用 t 及之前的信息。我把这条约束落实成一个循环,每一折都重新训练、重新标准化、重新预测,绝不跨折复用任何拟合结果。
function res = walk_forward(feat, lab, cfg) idx = find(feat.idx & ~isnan(lab.y)); X = feat.X(idx, :); y = lab.y(idx); nTr = cfg.trainWin; nTe = cfg.testWin; P = nan(numel(y), 1); for s = 1 : nTe : (numel(y) - nTr - nTe + 1) tr = s : s + nTr - 1; te = s + nTr : s + nTr + nTe - 1; mu = mean(X(tr, :), 1, 'omitnan'); sg = std(X(tr, :), 0, 1, 'omitnan'); sg(sg == 0 | isnan(sg)) = 1; Xtr = (X(tr, :) - mu) ./ sg; Xte = (X(te, :) - mu) ./ sg; % 用训练窗口的参数标准化 mdl = fitcensemble(Xtr, y(tr), 'Method', 'Bag', ... 'NumLearningCycles', 150, ... 'Learners', templateTree('MaxNumSplits', 12, ... 'MinLeafSize', 30)); [~, score] = predict(mdl, Xte); P(te) = score(:, 2); % 上涨类概率 end res.P = P; res.y = y; res.hitRate = mean((P > 0.5) == (y == 1), 'omitnan'); end标准化那两行是重点。均值mu和标准差sg全部来自训练窗口,然后原封不动地应用到测试窗口。如果图省事在循环外面用全样本算一次,测试集的信息就漏进训练了,模型在样本外会显得异常准,而你真金白银照做的时候会发现完全不是那么回事。
4.5 结果图:每张图都要能回答一个问题
绘图我统一收在 plot_result.m 里,固定输出四张图:资金曲线对比图、预测概率分布直方图、混淆矩阵、特征重要性条形图。每张图都对应一个具体问题——策略有没有超额、模型是自信还是模糊、错在哪一类、靠什么在做判断。四张图缺一张,判断就会偏。
function plot_result(res, cfg) fig1 = figure('Visible', 'off'); plot(res.equity, 'LineWidth', 1.4); hold on; plot(res.bench, 'LineWidth', 1.4); legend({'策略', '基准'}, 'Location', 'northwest'); title('样本外累计净值(含交易成本)'); exportgraphics(fig1, fullfile(cfg.outDir, 'equity.png'), 'Resolution', 200); fig2 = figure('Visible', 'off'); histogram(res.P(res.y == 1), 20, 'Normalization', 'probability'); hold on; histogram(res.P(res.y == -1), 20, 'Normalization', 'probability'); legend({'真实上涨', '真实下跌'}); title('预测概率分布'); exportgraphics(fig2, fullfile(cfg.outDir, 'probdist.png'), 'Resolution', 200); end一个小细节:'Visible','off'加上exportgraphics,可以在不弹窗的情况下批量出图,适合在服务器上跑长任务。早期我用saveas,缺点是分辨率不可控,插进文档里全是锯齿,后来统一换成exportgraphics并固定 200 dpi,出图质量稳定多了。
5. 结果图怎么读:别被一条漂亮的曲线骗了
结果图是工具包的输出,也是最容易误读的部分。我见过有人看到资金曲线一路向上就兴奋,完全没注意到同期基准涨得更多;也见过有人盯着 62% 的命中率沾沾自喜,却没发现样本里本来就有 58% 是上涨类。读图需要一套固定的对照逻辑,我把自己的检查顺序整理成下面这几步,每次跑完新配置都按这个顺序过一遍。
5.1 资金曲线与概率分布的正确读法
资金曲线的第一眼不是看斜率,而是看两条线的相对位置和回撤结构。如果策略曲线长期在基准下方,说明它连"什么都不做"都没跑赢,问题多半出在交易成本或者信号过于频繁;如果策略曲线在基准上方但回撤更深,说明它在放大风险暴露,需要检查仓位规则。我通常会额外算两个数:年化超额和最大回撤比,两者一起看,单看任何一个都会误判。
概率分布直方图是我最喜欢的一张图。理想情况下,真实上涨样本的预测概率分布应该整体偏向右侧,真实下跌样本偏左,两条分布有可见的重叠区但峰位分开。如果两条分布几乎完全重合,说明模型什么都没学到,特征和标签之间没有可利用的关系;如果两条分布分得很开但峰位贴在最两端(大量 0.0 和 1.0),那要警惕过拟合,这种极端自信在样本外几乎必然崩掉。
还有一种情况值得单独说:概率分布的双峰变成单峰且集中在 0.5 附近。这不是模型不行,而是特征的信息量确实已经耗尽,此时正确的做法是承认现实、回退到简单基线,而不是继续加特征或者调参数,那只会让你在训练集上越走越远。
5.2 混淆矩阵与特征重要性暴露的问题
混淆矩阵要按行看,也就是看真实类别的召回情况。如果真实上涨的样本只有三成被识别出来,而真实下跌的样本有八成被识别,说明模型严重偏向看空,这在下跌市里表现不错,一旦市场转向就会连续打脸。反之亦然。两侧召回率的差距超过 25 个百分点,就该检查标签的类别比例是不是失衡了,或者阈值设置是否偏向一边。
特征重要性的作用是查偷懒。正常情况下,排在前面的应该是动量、波动这类有明确含义的特征,且各特征的贡献分散,没有哪一个占据绝对主导。如果某个特征的重要性超过 40%,或者排在首位的是某个滞后价格衍生量,那基本可以确定模型在走捷径,这时候要么删掉这个特征重跑,要么直接换一套特征体系。我踩过这个坑:某次结果特别好,一查重要性发现排第一的是"收盘价相对 60 日均线的偏离",而这个特征和标签在构造上有时间重叠,等于把答案抄进去了。
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 训练集 AUC 0.9,测试集 0.5 | 过拟合或信息泄漏 | 删特征、加正则、检查标准化范围 |
| 两侧召回率差距超 25% | 类别失衡或阈值偏向 | 调整阈值带宽度、改类别权重 |
| 单一特征重要性超 40% | 特征与标签存在时间重叠 | 检查该特征的窗口是否跨越标签期 |
| 资金曲线平滑但贴地 | 信号过于频繁,成本吞噬收益 | 加最小持仓期、提高开仓阈值 |
| 概率分布全挤在 0.5 | 特征信息量耗尽 | 回退基线,换特征体系而非调参 |
5.3 回测里那些看不见的坑
未来函数是头号杀手,而且形式极多。最常见的一种是:用当日收盘价算出指标,然后假设当日收盘价成交。现实中你不可能在收盘的同一瞬间既知道收盘价又完成交易。工具包里我把成交价统一设为信号出现后的下一个交易日开盘价,这个改动让很多策略的收益直接腰斩,但它是真实的。
幸存者偏差同样致命。如果你只用"现在还活着"的标的做回测,那些中途退市、长期停牌的样本全被剔除了,结果天然乐观。工具包的设计是单标的时序回测,天然规避了这个问题,但如果你打算扩展到多标的,务必把历史全样本(含已退出的)都纳入。
交易成本别省。佣金、印花税、滑点,三项加起来在日线级别上通常要按单边千分之一到千分之二估算。我做过对比,一个换手率极高的策略,在零成本假设下年化超额 8 个百分点,加上成本之后变成 -1 个百分点。成本参数的默认值我设在偏保守的一端,宁可低估收益,也不要高估。
注意:任何在样本外表现远超预期的配置,第一件事是跑一次"标签打乱"对照实验——把标签随机重排后重新训练,如果模型还能得到接近的命中率,那说明你的评估流程里有结构性漏洞,而不是模型厉害。
6. 常见问题与排查速查表
写到这里,把这两年攒下来的报错和症状整理成一张表,遇到问题先查表再动手。大部分问题其实反复就那么几个,只是每次换了个面孔出现。下面分代码层和模型层两块,另外补几条只能在实战里学到的经验。
6.1 代码层面的典型报错
| 报错或现象 | 原因 | 处理办法 |
|---|---|---|
Undefined function 'isbusday' | 未安装金融工具箱 | 自己写工作日判断,剔除周六日即可 |
| timetable 对齐后行数变多 | 交易日历包含非交易日 | 用数据自身日期构造日历,别硬编码 |
circshift结果时间错位 | 位移方向与时间顺序相反 | 明确约定数据按时间升序,位移用负值取未来 |
| 绘图中文变方框 | 字体不支持 | 指定系统内存在的字族,或统一用英文标签 |
predict输出列数不符 | 类别编码与预期顺序不同 | 显式读取模型类别属性,按类别名取对应列 |
| 内存暴涨 | timetable 未裁剪、表格复制过多 | 只保留用到的时间段和列,中间结果及时 clear |
predict那一条我吃过不小的亏。二分类模型的评分矩阵列顺序是按类别排序来的,如果标签是 1 和 -1,那么第一列对应 -1、第二列对应 1,这个顺序和直觉相反。早期我直接取第一列当上涨概率,结果整个信号方向是反的,资金曲线竟然是完美的反向指标。从那以后,取列之前一定先读一遍模型类别,按名字映射,不再靠位置猜。
6.2 模型层面的典型症状
训练集和测试集表现差距过大,八成是过拟合,处理顺序是:先砍特征,再加深深度限制,再提高叶子最小样本数,最后才考虑加正则。这个顺序很重要,因为正则只能压住参数幅度,压不住特征本身携带的噪声。
样本外结果时好时坏、波动极大,通常是折数太少或者测试窗太短。测试窗设成 5 天,每折只有 5 个预测点,随机性主导一切。我的经验是测试窗至少 20 天,累计样本外点不少于 300 个,再谈结论。
模型在某些时段集体失效、之后又恢复正常,多半是市场结构变了,不是模型坏了。这时候不要急着重新训练,先看那段时期的波动率有没有异常放大。我加的那条波动率暂停规则就是为了应对这种情况,代价是错过了部分反弹,但避免了几次连续亏损。
6.3 几条只能靠实战攒出来的心得
第一条,先跑通再加料。第一版代码我建议只用均线和动量两组特征、一个逻辑回归,把整条流水线跑通,看到一张正常的资金曲线,再去加特征加模型。反过来做的话,一旦结果不对,你根本分不清是数据错了、特征错了还是模型错了。
第二条,把每次实验的参数和结果记在一个表里。我用的是一个简单的文本记录,每行写日期、特征版本、窗口长度、阈值、命中率、年化超额,累计记了两百多行。回头看的时候你会发现,真正有效的改动其实只有那么两三次,其余全是原地打转,这份记录能帮你快速识别哪些方向不值得再试。
第三条,控制住自己的调参冲动。同一个配置反复试不同阈值,本质上就是在测试集上做选择,试到第一百次总能找到一个好看的,但那个结果没有任何泛化意义。我给自己定的规矩是:任何参数调整必须有逻辑理由,不能因为"刚好这次跑得好"就定下来。
第四条,接受这个工具包的天花板。它是个研究原型,用来验证想法、产出图表、支撑讨论,不是印钞机。我个人在实际操作中的体会是,价值最大的产出往往不是那个最终策略,而是过程中发现的数据问题——比如某段历史的复权因子明显异常,或者某个特征在特定市况下完全失效。这些发现比一个好看的净值曲线有用得多,它们才是能沉淀下来的东西。
最后分享一个小技巧:每次定稿一个版本,就把当次的配置、数据和四张结果图打包存一份快照。我吃过因为没有留快照、三个月后想复现某个结果却怎么都跑不出来的亏。存快照的成本几乎为零,但它在你想回头对比"到底是哪一步改动让效果变好的"时候,能救你一整天。