一个做量化的朋友把他压箱底的文件夹发过来里面躺着gupiao.m一个脚本、一个 csv 数据文件、四张结果图。图上那根预测曲线几乎贴着真实价格走标题栏还印着一行字方向预测准确率 92.3%。他看到的第一反应是这不就找到钥匙了第二反应是我照着跑一遍怎么只有 50% 多。这大概是 MATLAB 股票趋势预测工具包最常见的开局——代码是真的图也是真的但图上那个数字和你能复现出来的数字之间隔着一整套被省略掉的工程细节和几个藏得很深的逻辑漏洞。我前后拆过不下十套这类工具包结构大同小异一个主脚本负责读数据、算指标、切样本、训模型、画图几个子函数分别处理特征、网络、回测。gupiao.m这个名字起得很直白就是股票的拼音说明作者压根没打算做成产品就是个自用的实验脚本。这种代码有一个共同特点——跑得通但经不起追问。你问它数据复权了吗它不吭声你问它标签是不是用了未来信息它装死你问它验证集和训练集怎么切的它给你一个randperm。这篇东西适合两类人看一类是刚拿到类似工具包、想搞清楚里面到底在干什么的另一类是自己想用 MATLAB 写一套趋势预测的实验框架、但不确定哪些地方必须较真的。我会把gupiao.m从数据入口一路拆到结果图重点讲清楚三个问题每个环节为什么这么写、换个写法会出什么事、以及那些看起来很好看的图到底该怎么读。全文不涉及任何具体标的用的都是模拟数据所有代码片段为教学演示用途历史回测的表现和未来没有任何关系。1. 先给 gupiao.m 划一条清晰的能力边界1.1 它实际在做的是方向分类不是价格预言很多人第一次看那些结果图会误以为模型在预测价格点位因为图上画的是折线。仔细看坐标轴就知道gupiao.m输出的绝大多数是二分类标签——下一个周期涨还是跌或者是三分类——涨、跌、横盘。这跟回归预测价格是两件完全不同的事。方向分类的好处是目标明确、评价简单、不容易被极端值带偏。价格回归里偶尔出现一个跳空缺口MSE 能瞬间炸掉模型会拼命去拟合那个离群点方向分类把它压成了一个 0/1鲁棒性高一截。代价是信息量损失你只知道会上涨不知道会涨多少在真实的决策场景里这个粒度往往不够用。所以工具包里的预测更准确的叫法是条件判断在当前这组技术指标构成的条件下历史上类似状态之后出现上涨的频率是多少。这个频率是统计意义上的不是因果意义上的。理解这一点后面所有的参数设置和结果判读才有意义。1.2 教学工具包和可用系统之间差着哪些东西我不想贩卖焦虑但确实有必要把差距列清楚否则你会对它产生错误预期。拿gupiao.m和一套真正能跑的框架对比缺的东西大致是这几块环节工具包的常见做法完整框架需要做的事数据源一个本地 csv几十到几百行多源校验、复权因子对齐、停牌补全特征5~10 个经典指标数百维含截面、行业、宏观因子标签固定持有周期多周期、带交易成本的最优持有期搜索切分随机或末段切分滚动窗口、按时间分块交叉验证评价准确率方向准确率、IC、夏普、最大回撤、换手率稳健性单次运行参数敏感性、多随机种子、子样本稳定性这张表不是让你望而却步恰恰相反——工具包最有价值的用法是你把它当成一个可以逐个环节升级的骨架。先让它跑通再按表从上往下补每补一项都能看到结果图的变化这个学习曲线的斜率比从零写代码陡得多。1.3 拿到这套代码最该先做的三件事第一件原样跑通。别急着改模型先确认数据文件路径、MATLAB 版本、工具箱依赖都对得上。LSTM 分支依赖 Deep Learning Toolboxfitcsvm依赖 Statistics and Machine Learning Toolbox。少一个就直接报错报错信息通常很明确照着装就行。版本差异要特别注意老版本用classificationLayer和trainNetwork新版本推trainnet加crossentropyex损失函数函数名对不上会浪费半天。第二件把中间变量 dump 出来对齐。在特征矩阵构造完之后、切分之前加一句save(checkpoint.mat,X,Y,T)看看样本量对不对标签的 0/1 比例是多少。我见过太多情况是标签写反了——label ret 0和label ret 0哪个是涨全看你怎么想但混淆矩阵的轴一换解释就全反了。第三件找一个已知结论的简单样本做断点。比如人为构造一段完全单调递增的价格序列喂进去模型应该有接近 100% 的准确率。如果连这个都做不到问题一定在数据管道上不用去调网络。2. 数据进来之前把原始行情洗成模型能吃的矩阵2.1 复权、停牌、缺失值三个必须在这里解决的东西一份没处理过的行情数据通常是这样的某些日期整行缺失某些日期成交量是 0某些日期开盘价和前一天的收盘价差出了一大截。这三个现象背后是同一个问题——原始价格序列不连续。除权除息造成的跳空最要命。假设某标的每 10 股派 5 元除息日价格会直接往下掉一截这个跌幅和基本面没有半点关系。模型看到的是一根大阴线学到的是这种形态要跌实际上那天什么事都没发生。所以喂给模型的价格必须是后复权价前复权也行但序列随时间更新会变回溯测试时容易踩到不一致的坑。后复权的思路是把历史价格按分红送配比例往上调整让序列在除权日保持连续。gupiao.m里通常没有复权处理因为它读进来的 csv 已经是处理过的。但如果你自己换数据源这一步不能跳。一个粗糙的检测办法是先看看有没有异常跳空% 检测疑似除权造成的跳空启发式仅用于定位问题行 gap abs(T.open(2:end) - T.close(1:end-1)) ./ T.close(1:end-1); suspect find(gap 0.11); if ~isempty(suspect) fprintf(发现 %d 处疑似异常跳空行号\n, numel(suspect)); disp(suspect); end阈值 0.11 是给常规涨跌幅限制留的余量不同市场不同品种要调整。查出可疑行之后去查当天的公告确认是除权还是真的暴跌然后决定是换数据源还是手工修正。停牌日的处理更简单也更粗暴那天的 open/high/low/close 全是前一天的值volume 是 0。这种行留着会污染技术指标因为均线、波动率都把它当成一个横盘日算进去了等于人为提升了样本里的低波动比例。直接删掉是主流做法function T cleanQuotes(fname) opts detectImportOptions(fname); opts.VariableNamingRule preserve; T readtable(fname, opts); T.Properties.VariableNames {date,open,high,low,close,volume}; T.date datetime(T.date, InputFormat,yyyy-MM-dd); T sortrows(T, date); T T(T.close 0, :); % 停牌特征成交量为零且四价合一 isHalt (T.volume 0) (T.open T.close) (T.high T.low); T(isHalt, :) []; % 剩余零星缺失用前值填补 T fillmissing(T, previous, DataVariables, {open,high,low,close}); T.volume(isnan(T.volume)) 0; end删完停牌日之后序列的时间间隔就不均匀了。这一点对价格本身没影响但如果你的特征里包含日历天数这种维度就会引入偏差。我的建议是干脆不要用时间维度做特征所有特征都只依赖排好序的 K 线序列本身这样停牌删不删都不会在特征层面造成错位。2.2 标签怎么造从收盘价序列到分类标签这是整套代码里最容易埋雷的地方也是我反复强调要盯着看的地方。gupiao.m里标签的构造一般长这样n height(T); h 5; % 持有周期5 个交易日 fwdRet nan(n, 1); for i 1:(n - h) fwdRet(i) T.close(i h) / T.close(i) - 1; end label fwdRet 0; % 二分类未来 h 日涨/跌 label(isnan(fwdRet)) []; % 关键最后 h 行必须丢掉它们的标签依赖不存在的未来数据 T T(1:(n - h), :); fwdRet fwdRet(1:(n - h));最后那三行看着不起眼漏了就是灾难。fwdRet尾部必然是 NaN因为第 n-h1 行开始找不到 ih 的数据。如果不裁掉label的长度和特征矩阵的行数会对不上或者被NaN 0悄悄处理成false——这相当于给最后几天强行贴上跌的标签凭空制造了一批样本来污染训练集。持有周期h的选择有讲究。取 1 就是预测明天的涨跌信噪比极低基本上是在拟合噪声取 20 以上标签的平滑性好了但相邻样本的标签高度重叠第 i 天和第 i1 天的 20 日收益共享 19 个交易日样本独立性大打折扣。实操里 3 到 10 天是个比较舒服的区间配合每次重训的时间成本5 天左右是很多实验的默认值。另外label用logical还是double还是categorical取决于后面喂给哪个模型。fitcsvm接受 logicaltrainNetwork的分类层要求categorical。别到最后一刻才发现类型不对报错信息还特别晦涩。2.3 时序切分与归一化这两步搞错后面全白做先说切分。金融时间序列绝对不能随机切分。有一个非常反直觉的现象随机打乱切分后测试集准确率会明显高于时序切分不少人以为是自己模型好实际上是数据泄漏。相邻交易日的数据几乎一样随机切分会把几乎是同一天的样本同时分进训练集和测试集模型只要记住那一天的形态就能答对测试集里的另一份副本看起来准确率飙升实际上一上真实场景立刻打回原形。正确做法是按时间顺序切n size(X, 1); nTrain floor(0.70 * n); nVal floor(0.15 * n); idxTrain 1:nTrain; idxVal (nTrain1):(nTrainnVal); idxTest (nTrainnVal1):n; fprintf(训练/验证/测试 %d / %d / %d\n, ... numel(idxTrain), numel(idxVal), numel(idxTest));顺序不能乱测试集必须在时间上最靠后模拟的是用历史训练面对未来的真实场景。如果你还想更严格可以在训练集内部再做一次滚动验证walk-forward把训练集切成若干段每段用前面的训、后面的验统计 N 次验证结果的均值和方差。gupiao.m通常只做了单次切分这就是为什么它报出来的准确率波动很大——换个时间点重跑数字能差好几个点。再说归一化。**均值和标准差必须只从训练集算出来然后原封不动地套到验证集和测试集上。**这是最容易被忽视的一条mu mean(X(idxTrain, :), 1); sg std(X(idxTrain, :), 0, 1); sg(sg 0) 1; % 防止除零 Xn (X - mu) ./ sg; % 全部三部分用同一组 mu/sg如果对整份数据算mu/sg再切分测试集的分布信息就提前泄漏进了训练过程。这个泄漏的量级不一定大但在特征维度多、时间跨度长的时候能明显虚抬指标。更隐蔽的一种写法是用normalize(X)对整表操作一行代码看着很优雅实际上是同一个错误。3. 特征构造把 K 线翻译成模型看得懂的语言3.1 趋势类指标均线本身不是重点斜率才是模型看不懂金叉多头排列这些词它只认识数字。把均线变成数值特征最常见的做法就是直接给出 MA5、MA10、MA20 的值但这里有个问题——均线是价格的绝对水平不同标的、不同时间段的量级完全不同模型会学到一个跟价格水平强相关的奇怪规律。更稳的做法是给均线相对价格的位置和均线自身的斜率function ma movAvgManual(x, n) % 等价于 movmean这里手写便于理解 w ones(n, 1) / n; ma conv(x, w, same); ma(1:n-1) NaN; % 前 n-1 个位置无效 end close T.close; ma5 movAvgManual(close, 5); ma20 movAvgManual(close, 20); feat_ma_ratio close ./ ma20 - 1; % 价格偏离 20 日均线的幅度 feat_ma_slope [NaN; diff(ma20)] ./ ma20; % 均线的日变化率 feat_ma_order double(ma5 ma20); % 短期是否在长期之上feat_ma_ratio是关键特征之一它把价格高不高变成了一个无量纲的比值模型在不同标的上都能用。feat_ma_slope描述趋势方向比单纯的均线值信息密度高得多——两条均线可能数值差很远但斜率一样说明当下的动能是一致的。注意conv的same参数会让结果的前后各有半个窗口是循环卷积的产物严格来说前 n-1 个和最后 n-1 个都不可靠。gupiao.m里通常只处理了前面尾部就带着一点虚假信息进了训练集。样本量大的时候影响可以忽略样本量小的时候这几行能污染不少。3.2 动量与超买超卖MACD 和 RSI 的正确算法MACD 的核心是指数移动平均很多人用movmean近似这是错的。EMA 的递推式是y(k) α·x(k) (1-α)·y(k-1)α 2/(n1)用filter实现最干净function y ema(x, n) a 2 / (n 1); y filter(a, [1, -(1-a)], x); y(1:n) NaN; % 丢弃初始瞬态 end dif ema(close, 12) - ema(close, 26); dea ema(dif, 9); hist 2 * (dif - dea); feat_dif dif ./ close; % 除以价格做归一化 feat_hist hist ./ close;初始瞬态是个必须处理的问题。filter从第一个样本就开始算但此时y(0)是 0前十几个点的结果全是错的。EMA(26) 大概需要 3 倍窗口长度即 78 个点才能收敛到合理范围。y(1:n) NaN只砍了前面的 n 个保守一点可以用y(1:3*n) NaN反正前面那点数据对训练也没贡献。RSI 用 Wilder 平滑本质是指数平滑系数是1/n而不是2/(n1)function v rsi(close, n) d [0; diff(close)]; a 1 / n; up filter(a, [1, -(1-a)], max(d, 0)); dn filter(a, [1, -(1-a)], max(-d, 0)); rs up ./ max(dn, eps); v 100 - 100 ./ (1 rs); v(1:n) NaN; endmax(dn, eps)那一句是必需的横盘期间dn可能是 0直接除会得到 Inf后面整个特征矩阵都被污染训练时报出莫名其妙的 NaN 梯度。eps给的是2.2e-16足够小不会影响正常数值的精度。3.3 波动与量能被大多数工具包忽略的两个维度我看过的gupiao.m版本里一半以上只做价格类指标成交量根本没进特征矩阵。这是个明显的浪费——量价配合是技术分析里最基础的一条经验模型看不到量等于瞎了一只眼。布林带是个好用的组合特征它同时给出波动率和相对位置mid movmean(close, 20); sd movstd(close, 20); % 默认按 N-1 归一化 upper mid 2 * sd; lower mid - 2 * sd; feat_boll_pos (close - lower) ./ max(upper - lower, eps); % 0~1 之间的位置 feat_boll_bw (upper - lower) ./ mid; % 带宽波动率代理feat_boll_pos在 0 附近是贴着下轨1 附近是贴着上轨这两个位置在统计上确实对应不同的后续收益分布。feat_boll_bw描述波动状态带宽收窄往往意味着方向选择临近带宽张开说明趋势已经启动。量能特征我习惯用量比的变形vma5 movmean(T.volume, 5); vma20 movmean(T.volume, 20); feat_vol_ratio vma5 ./ max(vma20, 1); % 短期量能相对长期 feat_vol_z (T.volume - vma20) ./ max(movstd(T.volume, 20), 1); feat_amount log(close .* T.volume); % 成交额对数压缩量级对数变换是量能特征的标准操作。成交量的分布极度右偏原始值能差三四个数量级直接标准化也压不住长尾取对数之后接近正态模型收敛快很多。max(..., 1)这种防零保护在量能类特征里尤其常见因为停牌前后常常出现连续的 0 值。3.4 特征数量要做减法不是越多越好新人最容易犯的错是往特征矩阵里塞三四十列觉得信息越多模型越聪明。实际结果通常是准确率不动甚至下滑训练时间翻了好几倍。原因有三个。一是冗余MA5、MA10、MA20 之间相关性超过 0.95对线性模型来说是共线性问题对树模型来说只是多了一堆等价的分裂点二是维度诅咒样本量几百到几千特征几十维模型很容易找到偶然的规律三是噪声特征淹没信号真正有用的那几个特征在标准化之后和其他特征量级相同模型分不出轻重。我一般的做法是控制在 8 到 15 维按下面的顺序逐步筛步骤操作判断依据1去掉两两相关系数 0.9 的特征对中的一列corrcoef矩阵热力图2去掉方差近乎为 0 的特征标准化后标准差 0.013用训练集算单特征与标签的 AUC低于 0.5 的直接删或取反4树模型的特征重要性排序predictorImportance取前 60%第三步有个细节AUC 要用训练集算不能用全量数据否则又泄漏了。而且 AUC 等于 0.5 的特征不是没用可能是方向反了取反之后照样有信息量。4. gupiao.m 结构拆解一个主函数带几个子函数4.1 主流程骨架长什么样把脚本从头到尾扫一遍逻辑脉络基本是固定的五段式%% 1. 读数据 T cleanQuotes(data_sample.csv); %% 2. 造特征与标签 [X, Y, dates] buildFeatures(T, 5); %% 3. 切分与归一化 [Xtr, Ytr, Xva, Yva, Xte, Yte, mu, sg] splitNormalize(X, Y); %% 4. 训练与预测 [net, Ypred] trainLSTM(Xtr, Ytr, Xva, Yva, Xte); %% 5. 评价与出图 reportMetrics(Yte, Ypred, dates, Xte);这个骨架是合理的值得保留。真正需要动刀的地方在每一步的内部实现——buildFeatures有没有用未来数据、splitNormalize的mu/sg从哪来、trainLSTM的验证集是不是独立、reportMetrics算的到底是什么指标。有一类很典型的重构值得做把配置项提到最前面用结构体管理。gupiao.m里参数常常散落在十几处改个持有周期要搜五次容易漏改。提出来之后是这样的cfg struct(); cfg.holdPeriod 5; cfg.winLen 20; cfg.hiddenUnits 64; cfg.dropout 0.2; cfg.maxEpochs 40; cfg.miniBatch 64; cfg.lrInit 1e-3; cfg.seed 20240101; cfg.trainRatio 0.70; cfg.valRatio 0.15;后面所有函数都接收这个cfg做参数扫描的时候改一处就行。这个小改动省下的时间比调模型本身省的多。4.2 几种模型各自的适用场景gupiao.m通常只实现一种模型但工具包里被问得最多的就是该用哪个。我按实际跑下来的经验给个对照模型适合的样本量训练速度对特征工程依赖典型准确率区间逻辑回归200极快高50%~55%SVMRBF500快中52%~58%随机森林/GBDT1000中低54%~60%LSTM/GRU3000慢低52%~60%ARIMA回归版200快极高不适用分类看到这些数字别激动准确率在 55% 以下基本等同于噪音。二分类问题抛硬币是 50%一个 52% 的模型扣掉交易成本之后通常是负的。工具包最能教会你的不是怎么把准确率做到 60%而是怎么判断一个 55% 到底是真的还是运气。以我的经验样本量在三千条以下深度模型打不过梯度提升树。这不是理论推导是实测结论——几千个样本喂 LSTM参数上百万过拟合速度快到验证集 loss 在前五个 epoch 就开始往上翘。除非你有几十万条高频数据否则别一上来就上 LSTM。4.3 LSTM 分支的层结构与训练参数gupiao.m的深度分支一般是一层 LSTM 全连接 softmax的结构简单但够用function layers buildLayers(numFeatures, cfg) layers [ sequenceInputLayer(numFeatures, Normalization,zscore) lstmLayer(cfg.hiddenUnits, OutputMode,last) dropoutLayer(cfg.dropout) fullyConnectedLayer(2) softmaxLayer classificationLayer]; endOutputMode设成last还是sequence取决于你要不要每个时间步都出预测。做趋势分类一般取last只关心窗口末尾那一个判断。如果设成sequence输出维度会变成2 × winLen后面接全连接层要额外处理容易出错。sequenceInputLayer里的Normalization参数有个坑它是在每个 mini-batch 内部做归一化。如果你的验证集只有一个 batch那验证时的归一化统计量和训练时不一致指标会有轻微抖动。稳妥做法是前面已经手工标准化过了这里就不要再开避免双重归一化。训练选项里几个参数值得单独说opts trainingOptions(adam, ... MaxEpochs, cfg.maxEpochs, ... MiniBatchSize, cfg.miniBatch, ... InitialLearnRate, cfg.lrInit, ... LearnRateSchedule, piecewise, ... LearnRateDropPeriod,15, ... LearnRateDropFactor,0.5, ... GradientThreshold, 1, ... Shuffle, never, ... ValidationData, {Xva, Yva}, ... ValidationFrequency,50, ... ValidationPatience, 8, ... Plots, training-progress, ... Verbose, false);Shuffle设成never是为了可复现。严格来说时序样本的训练过程中打乱 batch 顺序危害不大每个样本内部的时间结构没被破坏但为了让两次运行的曲线能对上关掉更省心。GradientThreshold设 1 是针对 LSTM 的梯度爆炸。RNN 系模型的梯度连乘效应严重一次异常大的梯度就能把权重推飞后续训练全废。这个参数不是可选项是必需品。ValidationPatience设 8 意味着验证集指标连续 8 次没改善就提前停。这个值不宜太大因为金融数据上过拟合来得很快等太久只会训出一个记住训练集的模型。4.4 传统模型分支为什么常常更好用工具包里如果同时留了fitcsvm或者fitcensemble的分支你会发现同一份数据上它们经常打赢 LSTM。原因很朴素样本量不够。% SVM 分支 svmMdl fitcsvm(Xtr, Ytr, ... KernelFunction,rbf, ... KernelScale,auto, ... BoxConstraint,1, ... Standardize,false); % 前面已经手工标准化过 YpredSvm predict(svmMdl, Xte); accSvm mean(YpredSvm Yte); % 集成树分支 tpl templateTree(MaxNumSplits,20, MinLeafSize,30); ensMdl fitcensemble(Xtr, Ytr, ... Method,Bag, ... NumLearningCycles,200, ... Learners,tpl); YpredEns predict(ensMdl, Xte);MinLeafSize设 30 这个约束很关键。默认值会让每棵树一直分裂到叶子节点只剩几个样本训练集准确率能到 99%测试集惨不忍睹。设成 30 相当于强制每个叶子至少有 30 个样本支撑这在对样本量有限的数据上几乎是标配。fitcsvm的KernelScale用auto让 MATLAB 自己启发式选配合BoxConstraint 1是个不错的起点。如果发现模型把几乎所有样本都判成同一类说明BoxConstraint太大模型太想拟合每一个点降到 0.1 试试。还有个小技巧训练完之后看ensMdl.OOBLoss。这是袋外误差几乎免费的验证指标和你在测试集上看到的数字差得太多说明两次切分的时间段特性差异很大模型的稳定性存疑。5. 结果图不是装饰四张图各自的判读方法5.1 训练曲线什么时候应该按停training-progress窗口里通常有三条线训练准确率、验证准确率、训练损失。判读的重点不在绝对值而在两条线的相对走势。训练准确率一路上升到 90% 以上验证准确率在 55% 附近横着不动甚至往下掉——这是标准的过拟合形态说明模型在背训练集。这时候任何再训久一点会更好的想法都是错的。反过来的情况是两条线一起爬都很慢那是欠拟合问题出在模型容量或者特征上加大hiddenUnits或者补特征不是在训练轮数上折腾。还有一个细节值得看验证损失的最低点出现在哪个 epoch。trainNetwork返回的info结构体里有FinalValidationLoss但只给最终值。想看完整曲线得在trainingOptions里加OutputFcn, (info) saveHistory(info)自己把每一轮的数字存下来画图。这一步值得做因为它能告诉你模型在第几轮就学完了后续的轮数纯属浪费时间。5.2 混淆矩阵准确率是最容易骗人的指标figure(Name,混淆矩阵); confusionchart(Yte, Ypred, ... RowSummary,row-normalized, ... ColumnSummary,column-normalized);这份图里四个格子对角线是判对的非对角线是判错的。看的时候有几个重点。第一看两类各自的召回率是否均衡。如果模型把 80% 的样本都判成涨整体准确率可能还不错如果测试集里涨的占比高但跌那一类的召回率可能只有 30%。这种模型在实盘里就是永远看多一点用没有。第二看误判的方向。把跌判成涨假阳性和把涨判成跌假阴性代价完全不同。前者会让你在不该进场的时候进场后者只是错过机会。做多策略里假阳性更致命所以可以接受假阴性多、假阳性少的不对称模型这个偏好要在阈值或者类别权重上体现出来fitcsvm的Cost矩阵就是干这个的。第三看样本量的绝对数字。测试集只有 200 个样本准确率差 3 个百分点就是 6 个样本的差别完全在随机波动范围内。这种情况下去优化模型是自欺欺人先把样本量堆上去。5.3 预测-真实曲线叠图错位的根源在哪这类图一般是两条曲线一条是真实的标签序列0/1 或者归一化价格一条是模型输出的概率或类别。看着像两条线在一起跳舞仔细看会发现很多地方晚了一拍。这种错位不是 bug是标签设计的必然结果。你的标签是未来 h 日收益模型在第 i 天给出的是第 ih 天的判断。如果画图时把预测值直接对齐到第 i 天看起来就是模型提前 h 天预判了走势效果特别唬人。正确的画法是把预测值整体右移 h 个位置让它和它真正对应的那一天对齐这时候你会发现模型的预知能力没那么神大多数时候是在跟随而不是引领。另一个常见问题是图上只画了测试集没画训练集和验证集。单看测试集那段漂亮的拟合你没法判断这是模型的稳定表现还是恰好赶上了一段趋势行情。我习惯把三段拼在一起中间用竖线分隔一眼就能看出模型在不同时间段的表现差异有多大。5.4 累计收益曲线与最大回撤这个最接近能不能用四张图里最有信息量的是这张。它把分类结果翻译成了真金白银的变化路径——当然是历史回测意义上的。构造方式% 信号次日生效避免未来函数 pos double(Ypred categorical(1)); pos [0; pos(1:end-1)]; stratRet pos .* retTest(:); nav cumprod(1 stratRet); peak cummax(nav); dd nav ./ peak - 1; fprintf(累计净值 %.3f最大回撤 %.2f%%\n, nav(end), min(dd)*100);pos [0; pos(1:end-1)]这一行是灵魂。第 i 天的预测是用第 i 天的收盘数据算出来的你不可能在同一天收盘前执行最快也得等第二天。整体后移一天或者更严格地按开盘价成交才勉强接近现实。少了这一行回测收益能凭空多出一大截。看图的时候重点看三件事净值曲线有没有超过一条简单的买入持有基准线如果跑不赢这个模型就没必要存在、最大回撤有多大回撤 40% 的策略实盘里很少有人能拿住、以及曲线的形态是不是集中在某一段全靠某一次行情爆发拉起来的剔除那一段就归零了。6. 最容易翻车的几个地方附排查链路6.1 未来函数准确率高得不正常的第一嫌疑准确率超过 70% 的股票方向预测几乎可以先假定有问题然后再去证伪。我踩过的最典型一次是特征里混进了close的当期值当预测依据——听起来很蠢但换个马甲就很难认出来。比如用布林带特征时upper和lower都是基于 20 日均线算的而 20 日均线包含了当天的收盘价。这在技术分析里天经地义因为你确实是在收盘后才知道今天的均线位置。问题出在标签如果你预测的是今天涨还是跌而特征里包含了今天的收盘价那模型实际上已经看到了答案。解决方案只有两个——要么把预测目标改成下一个交易日要么把特征整体滞后一天。排查的方法很直接把特征矩阵整体往右移一个位置其他什么都不改重跑一遍看准确率掉多少。掉 5 个点以内说明原来的特征基本没掺未来信息掉 15 个点以上问题就大了。Xshift [nan(1, size(X,2)); X(1:end-1, :)]; valid ~any(isnan(Xshift), 2); fprintf(滞后一期后准确率%.4f\n, ... mean(predict(mdl, Xshift(valid,:)) Y(valid)));6.2 随机切分造成的泄漏前面提过一次这里再展开说怎么排查因为它的症状很隐蔽——不是报错而是太顺了。判断方法同一套模型、同一份数据跑一次随机切分跑一次时序切分对比两个准确率。差 8 个点以上基本可以确认随机切分在作弊。这个差距在样本量小、持有周期短、特征里含长周期均线的时候尤其明显因为相邻样本的相似度更高。还有一个更隐蔽的变体切分是按时间切的但特征里有类似过去 60 日收益这种跨度很长的窗口。训练集最后一个样本的第 60 日窗口会覆盖测试集最前面几十天的数据。窗口越长这个跨界的重叠越大。解决办法是在切分点周围留一段隔离带两边各自丢掉窗口长度那么多个样本。6.3 样本外衰减验证集好看实盘就散这个不是 bug是所有金融预测模型的宿命。你在验证集上看到 58% 的准确率换个时间段重跑同一套参数可能只剩 51%。原因写在数据的本质上——市场状态会切换一段时间的规律到下一段时间就失效了。对抗它的手段是滚动重训加多段验证segLen 250; % 每段约一年交易日 step 60; results []; for s 1:step:(n - segLen) idx s : (s segLen - 1); [Xa, Ya] makeSequence(Xn(idx,:), Y(idx), cfg.winLen); m floor(0.7 * numel(Ya)); mdl fitcsvm(Xa(1:m,:), Ya(1:m), KernelFunction,rbf); acc mean(predict(mdl, Xa(m1:end,:)) Ya(m1:end)); results(end1) acc; %#okSAGROW end fprintf(均值 %.4f标准差 %.4f最差 %.4f\n, ... mean(results), std(results), min(results));这段代码跑出来的三个数字里最差的那个最有参考价值。均值 55% 但最差 48% 的模型比均值 53% 但最差 52% 的模型要危险得多因为前者会在某一段行情里把你打懵。6.4 一套能照着走的排查顺序真出问题时别乱试按下面的顺序走基本上三次以内能定位先验证数据管道。构造单调序列看模型能不能达到 95% 以上准确率。做不到问题在特征或标签不在模型。再验证切分。同一套参数随机切分和时序切分对比差距超 8 点回头查泄漏。然后验证特征时效。所有特征滞后一期重跑准确率掉太多说明有未来函数。检查类别分布。tabulate(Y)看一眼如果 90% 都是同一类准确率再高也没有意义。最后才是调模型。前面四步都干净了再考虑换模型、调超参。顺序反了会浪费大量时间。我见过有人换了七八种网络结构最后发现是标签裁尾没做对前功尽弃。7. 从能跑到好用参数、效率与复现性7.1 关键参数的取值区间和调整逻辑工具包里真正影响结果的参数其实不多我把它们和合理区间列一下参数含义建议区间调大的后果holdPeriod标签的持有周期3 ~ 10标签重叠加剧样本独立性下降winLen序列窗口长度10 ~ 30单样本维度变大需要更多样本hiddenUnitsLSTM 隐层单元数32 ~ 128参数暴涨过拟合加快dropout丢弃比例0.1 ~ 0.4欠拟合收敛变慢MiniBatchSize批大小32 ~ 128泛化略降显存吃紧MinLeafSize树模型最小叶样本20 ~ 50模型变保守欠拟合调参的策略建议一次只动一个而且用滚动验证的均值加最差值来判断不要看单次测试集的结果。单次结果里运气成分太大两个参数差 1 个百分点完全可能是噪声。还有一个容易忽略的点holdPeriod和winLen会联动。winLen越大能构造出的有效样本越少因为前面winLen-1行用不了holdPeriod再占掉尾部几位样本量掉得很快。三百行的数据集配winLen30加holdPeriod10最后只剩两百多个样本什么模型都学不出东西。7.2 MATLAB 里省时间的几个习惯LSTM 训练慢是公认的但有几个地方能省下大量时间。用parfor做参数扫描。网格搜索天然并行前提是每轮训练的随机种子要单独设置否则并行出来的结果不可复现lrList [5e-4, 1e-3, 2e-3]; accMat zeros(numel(lrList), 1); parfor k 1:numel(lrList) rng(cfg.seed k); localCfg cfg; localCfg.lrInit lrList(k); accMat(k) runOneFold(Xtr, Ytr, Xva, Yva, localCfg); endparfor里不能直接用cfg修改得复制一份循环变量k要用来区分种子。这两个细节不注意并行出来的结果会互相污染。向量化替代循环。特征计算里最常见的性能杀手是一个 for 循环算均线样本量上万时慢得离谱。前面用的filter、conv、movmean都是内置的 C 实现速度快一到两个数量级。能上 GPU 就上。训练前加一句Xtr gpuArray(Xtr)需要 Parallel Computing Toolbox但小样本上 GPU 反而更慢传输开销超过计算收益样本量过万再考虑。别在循环里figure。我在别人代码里见过在参数扫描循环里画图的跑一次弹几十个窗口MATLAB 直接卡死。画图统一放到循环外面。7.3 固定随机种子和结果归档这一条纯粹是给自己省事。LSTM 的初始化权重、dropout 的掩码、fitcsvm的启发式参数全都有随机成分。不固定种子两次运行结果能差好几个百分点你会永远搞不清是改了参数有效还是碰上了好运。function setSeed(s) rng(s, twister); % 部分版本的深度网络训练使用独立的随机流 try parallel.gpu.rng(s, Philox); catch % 没有 GPU 时忽略 end end归档我习惯用一个结构体把关键信息全打包存成带时间戳的 mat 文件runRec struct(); runRec.cfg cfg; runRec.accTe accTe; runRec.cm confusionmat(Yte, Ypred); runRec.navEnd nav(end); runRec.maxDD min(dd); runRec.featName featNames; runRec.stamp datestr(now, yyyymmdd_HHMMSS); save(fullfile(runs, [run_ runRec.stamp .mat]), runRec);跑上十几次之后这个文件夹就是你最有价值的资产——它记录了什么参数组合在什么时间段上表现如何比任何一次单点的高准确率都更有说服力。我自己在复现别人的结果时第一件事就是翻他的 runs 目录看看有没有多次运行的结果如果只有一次那个数字我会打个对折看。最后说个很实际的体会。这套gupiao.m加上几张结果图最大的价值不在那个 92% 的数字而在它提供了一个完整的、可以逐段拆开验证的流程骨架。我把它跑通之后做的第一件事不是调模型是把每一段中间变量导出、逐个核对最后发现三处问题标签尾部没裁、归一化用了全量统计、回测信号没做次日生效。改完这三处准确率从 92% 掉到了 54%图也没那么好看了但我第一次觉得这个数字是真的。从 54% 往上每挪一个百分点都比从 92% 往 93% 挪要有意义得多。