简介:面向毕业设计、期末大作业与课程设计场景,Python基于机器学习的量化投资策略项目源码是一套从数据清洗、特征提取到模型训练、回测验证的完整可运行方案。整个项目围绕股价涨幅预测展开,包含股票列表获取、Tushare token配置、策略回测与K线/柱状图可视化等环节,并附带详细使用说明文档,即便新手也能快速部署复现。压缩包共15个文件,核心为5个Python脚本,辅以6张结果图表、2份文本说明、1份Word手册和1个Git忽略文件,整体仅1.14MB,体积轻量却覆盖数据、模型、回测、界面全流程;代码内含清晰注释,降低阅读门槛。目前已有285人学习,项目源自98分工科实践作品并获导师认可,部署简易,既能支撑课程设计,也可作为量化投资入门与毕业答辩的参考范例。
1. 拿到一个 Python 机器学习量化项目,先别急着跑回测
市面上贴着「Python + 机器学习 + 量化投资」标签的源码项目,十有八九长这样:一份数据预处理脚本、一份模型训练脚本、一份回测脚本,外带使用说明。按说明把回测一跑,年化 30% 往上走,回撤低得感人;可换成自己随便挑的一只股票,曲线立刻打回原形。这个标题指向的,正是这样一套完整闭环:用 Python 清洗行情、构造特征,用机器学习模型从历史数据里学规律,再用回测验证这套规律到底能不能交易。
它解决的具体问题很实际:手工盯盘靠感觉,复盘靠事后诸葛,而机器学习量化把「找规律」和「验证规律」拆成两件可重复、可质疑的事。适合有 Python 基础、想从看 K 线过渡到程序化研究的人,也适合拿它当课程设计或毕业设计主线。下面不按项目介绍讲,按你落地时会遇到的顺序来:数据怎么准备、模型怎么选怎么训、回测怎么写才不算自欺欺人、哪些隐藏问题会让漂亮曲线一夜归零。
2. 数据准备与特征工程:把行情变成机器学习能吃的表格
这一章是整个项目的地基。模型再强,喂进去的数据字段对不齐、复权方式不对、特征里有未来函数,后面所有环节都是白做。我见过太多人拿到源码后直接在原始行情上跑 LightGBM,结果 AUC 高得离谱,最后发现标签和特征共用了一段未来数据。所以数据准备阶段,慢就是快。
2.1 数据源选型与字段对齐:免费接口够用,但要先做两件事
数据源的选择,决定了你后续能做什么周期、什么范围的策略。常见做法是先选一个免费接口把流程跑通,再考虑换本地数据。akshare 和 tushare 是 Python 生态里最常用的两个免费数据源,akshare 无需注册 token,直接 pip install 就能用,适合快速验证;tushare pro 部分接口需要积分,积分不够时分钟数据拉不全。我的习惯是:先用 akshare 把日线级别的策略完整跑一遍,确认逻辑没问题后,再考虑引入更高质量的数据源。
拿到数据后的第一件事永远是做字段对齐。akshare 返回的是中文字段名,而机器学习库和回测框架基本只认英文列名,所以第一步是重命名。另外要特别注意复权方式:策略如果做日线级别,一定要用前复权数据。前复权保持最新价格不变、历史价格按分红送股调整,这样均线、动量这些依赖价格连续性的特征才不会被除权跳空坑到。不复权的数据在除权日会出现人为的价格断裂,模型会误以为这是真实的暴跌或暴涨。
import akshare as ak import pandas as pd # 拉取平安银行日线,前复权 raw = ak.stock_zh_a_hist( symbol="000001", period="daily", start_date="20180101", end_date="20231231", adjust="qfq", ) # 不同版本的 akshare 字段名略有差异,先打印列名确认再重命名 print(raw.columns.tolist()) df = raw.rename(columns={ "日期": "date", "开盘": "open", "收盘": "close", "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount", "换手率": "turnover", })[["date", "open", "close", "high", "low", "volume", "amount", "turnover"]] df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) print(df.head())这段代码里,print(raw.columns.tolist())不是多余动作。akshare 偶尔会调整返回字段,中文名变成开盘价、收盘价这种带「价」字的写法,直接 rename 会报 KeyError;先打印列名,能省掉大半天的排查时间。adjust="qfq"指定前复权,period="daily"决定策略研究粒度,日线策略用日线数据,做日内策略才需要换分钟线。最后按日期升序排列,是因为后面构造收益、移动均线等特征时,全部依赖时间顺序,数据一旦乱序,rolling 窗口算出来的全是错的。
字段对齐是新手最容易忽略的环节。很多电商项目里跑通的代码,换到 A 股数据上第一行就报错,原因往往是date列被当成了字符串、volume列的单位不一致(有的是手、有的是股)。建议在代码里加一句df = df.astype({"volume": float})做显式类型转换,避免后续pct_change()或rolling()时出现隐式类型问题。
2.2 技术指标特征:从价格和成交量里提取规律
机器学习模型本身不会「看 K 线」,它只能处理数字表格。所以要把行情数据转成特征矩阵,每一行是一个交易日,每一列是一个特征。特征构造的原则是:宁缺毋滥,先做一小批和交易逻辑直接相关的指标,跑通后再迭代。不要一上来就堆几十个特征,金融数据信噪比低,特征越多越容易过拟合到历史噪声上。
我最常用的特征分三类:第一类是价格相对位置,比如收盘价相对 20 日均线的偏离度,它刻画「当前价格贵不贵」;第二类是波动率,用过去 20 日收益率的标准差度量,它刻画「当前市场安不安静」;第三类是量价配合,比如成交量相对 20 日均量的倍数,反映资金参与热度。这三类特征互不冗余,且都有明确的交易含义,适合作为机器学习量化策略的第一批特征。
import numpy as np # 1. 均线与价格位置特征 df["ma5"] = df["close"].rolling(5).mean() df["ma20"] = df["close"].rolling(20).mean() df["price_ma20"] = df["close"] / df["ma20"] - 1 # 偏离度,正数表示站上均线 # 2. 收益率与波动率特征 df["ret_1d"] = df["close"].pct_change() # 当日收益率 df["volatility_20"] = df["ret_1d"].rolling(20).std() # 20日波动率 # 3. 量比特征:当日成交量 / 过去20日平均成交量 df["vol_ratio"] = df["volume"] / df["volume"].rolling(20).mean() # 4. RSI 相对强弱指标(14日) delta = df["close"].diff() gain = delta.clip(lower=0).rolling(14).mean() # 平均上涨幅度 loss = (-delta.clip(upper=0)).rolling(14).mean() # 平均下跌幅度 df["rsi_14"] = 100 - 100 / (1 + gain / loss) # RSI 在 0~100 之间 # 删除指标计算产生的 NaN 行 df = df.dropna().reset_index(drop=True)代码里的clip(lower=0)是个容易被忽略的技巧:它把delta中所有负数变成 0,只保留上涨部分,这样gain计算的是「过去 14 天平均涨了多少」,loss同理只算下跌部分,二者相除再换算,就得到了 RSI 值。RSI 超过 70 通常认为超买、低于 30 认为超卖,但这里我们不直接用 70/30 做规则,而是把 RSI 作为连续特征喂给模型,让模型自己学阈值。
rolling窗口参数为什么选 20 和 14?20 对应一个自然月的交易日数,是趋势类指标最常用的周期;14 是 RSI 的经典默认周期,源自 Wilder 的原始定义。这两个参数不是拍脑袋定的,而是业界沉淀下来的经验值。如果你做的是周线级别策略,窗口要相应放大到 52 和 26;做短线日内策略,窗口可以缩到 5 和 6。特征周期的选择和持仓周期匹配,是量化策略设计的基本功。
2.3 标签构造与数据切分:预测什么、怎么切才不算抢跑
特征决定了模型「看什么」,标签决定了模型「学什么」。量化策略里最常见的标签有两种:一种是预测未来 N 日收益率的方向,做成二分类;另一种是直接回归未来 N 日收益率。我的经验是,分类比回归稳定得多——回归模型会被极端收益样本带偏,而分类只要把「显著上涨」和「其他」分开就行。这里用「未来 5 日收益率是否超过 2%」作为正样本,5 日和 2% 分别对应持仓周期和收益预期。
数据切分是另一个致命细节。绝对不能用随机切分。金融数据是强时间序列,随机切分等同于让模型偷看未来——训练集里混着测试集时间段的样本,测试集里的「预测」其实模型早就见过了。正确做法是按时间顺序切分,前 80% 训练、后 20% 测试,模拟真实世界里「用历史预测未来」的场景。
# 构造标签:未来5日收益率超过2%记为正类 df["ret_future_5"] = df["close"].shift(-5) / df["close"] - 1 df["label"] = (df["ret_future_5"] > 0.02).astype(int) # 最后5行没有未来数据,直接丢弃 df = df.iloc[:-5].reset_index(drop=True) # 按时间顺序切分:前80%训练,后20%测试 cut = int(len(df) * 0.8) train_df = df.iloc[:cut].copy() test_df = df.iloc[cut:].copy() print("训练集样本数:", len(train_df), "测试集样本数:", len(test_df)) print("正样本占比:", train_df["label"].mean())shift(-5)是标签构造的核心:把未来第 5 天的收盘价挪到今天这一行,再除以今天的收盘价,得到未来 5 日的累计收益率。这里有个隐蔽的坑——shift(-5)会让最后 5 行的ret_future_5变成 NaN,因为未来数据不存在,所以必须用iloc[:-5]把这 5 行丢掉,否则 NaN 会被 LightGBM 当成一个特殊值参与训练,产生莫名其妙的偏差。
训练集正样本占比值得留意。如果正样本占比低于 10%,说明「5 天涨 2%」在标的走势里是小概率事件,模型会倾向于全部预测负类来降低损失。遇到这种情况,有两条路:一是把阈值从 2% 下调到 1.5%,扩大正样本比例;二是在训练时给正样本加权重。我一般先调阈值,简单直接且不引入额外参数。正样本占比在 20%~40% 之间是比较健康的区间。
3. 模型训练与信号生成:让 LightGBM 学会「什么时候该买」
数据准备好了,进入标题里的「机器学习」核心环节。选什么模型、怎么防过拟合、怎么把模型输出的概率变成可执行的买卖信号,这三件事决定了策略的预测能力。很多课程设计项目在模型环节只做一件事——调一个accuracy_score出来,这远远不够。量化策略要的是下一笔交易该不该下注,而不是历史数据上预测对了多少。
3.1 为什么是 LightGBM:表格数据的默认答案
标题写的是「机器学习」而不是「深度学习」,这本身就是个重要信号。行情数据经特征工程处理后是典型的表格数据,样本量撑死几万行,特征几十个,信噪比极低。这种场景下,梯度提升树模型(LightGBM、XGBoost)几乎是默认最优解,而 LSTM、Transformer 这类深度模型在数据量不足时极易过拟合,训出来的效果往往不如一棵浅树。
我一般会先用 LightGBM 作为 baseline。理由有三:一是训练速度快,几千行数据几秒钟就能跑完一轮,方便快速迭代特征;二是它对特征尺度不敏感,价格类特征和比率类特征混在一起也不用做标准化;三是自带特征重要性输出,能直接看到模型「靠什么赚钱」,这对后续优化特征工程至关重要。如果你在源码里看到的是随机森林或者逻辑回归,也能跑,但 LightGBM 通常能在同样的特征下拿到更好的 AUC。
3.2 训练与验证:早停、时序切分、防止过拟合
训练环节的核心是验证策略。很多项目把训练集和测试集切完就开训,测试集只在最后用一次,中间既不调参也不看验证曲线。正确做法是用训练集前一部分做验证集(或者用 TimeSeriesSplit 做时序交叉验证),每训练几轮就看一次验证集 AUC,一旦 AUC 不再提升就停止训练。这个机制叫 early stopping,是防止树模型过拟合最有效的手段。
import lightgbm as lgb from sklearn.metrics import roc_auc_score feature_cols = ["price_ma20", "vol_ratio", "volatility_20", "rsi_14", "ret_1d"] X_train = train_df[feature_cols] y_train = train_df["label"] X_test = test_df[feature_cols] y_test = test_df["label"] model = lgb.LGBMClassifier( n_estimators=500, # 最大迭代轮数,实际由早停决定 learning_rate=0.05, # 学习率,越小越稳但训练越慢 num_leaves=31, # 每棵树最大叶子数,越大越容易过拟合 max_depth=5, # 树深度限制 min_child_samples=50, # 叶子节点最少样本数,防止学到噪声 subsample=0.8, # 每轮迭代随机采样80%样本 colsample_bytree=0.8, # 每棵树随机采样80%特征 random_state=42, ) model.fit( X_train, y_train, eval_set=[(X_test, y_test)], # 早停用的验证集 eval_metric="auc", callbacks=[lgb.early_stopping(50), lgb.log_evaluation(50)], ) # 输出测试集 AUC:0.5 等于瞎猜,0.55 以上说明有一丁点预测能力 test_auc = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]) print("测试集 AUC:", round(test_auc, 4))AUC 是量化策略里比准确率更有意义的指标,它衡量的是「随机挑一个正样本和随机挑一个负样本,模型给正样本打分更高的概率」。AUC 0.55 在金融数据里已经不算差——不要期待 0.8、0.9 的 AUC,那基本意味着数据泄漏。eval_set传的是测试集,这个做法在这个代码里其实是「偷看」了测试集来早停。更严谨的做法是把训练集再切出一段做验证集,测试集留到最后。但考虑到单股策略样本量有限,用测试集早停虽然会略微高估模型表现,作为课程设计的验证流程仍然可接受;等做到第 6 章的样本外验证时,再补上更严格的流程。
参数上,learning_rate=0.05配合n_estimators=500是 LightGBM 的经典组合。学习率越小,每棵树贡献越小,需要更多树才能达到同样效果,但过拟合风险更低。num_leaves=31和max_depth=5一起限制了单棵树的复杂度,这两个值对金融数据的噪声特别敏感——叶子数超过 64 后基本就是在背训练集。min_child_samples=50要求每个叶子节点至少 50 个样本,直接砍掉那些只覆盖了几个极端样本的分支,是防过拟合最实用的一招。subsample和colsample_bytree类似随机森林的行采样和列采样,增加了每棵树的多样性。
3.3 信号生成:从模型概率到可执行的买卖信号
模型训练完成后,输出的是每个交易日属于正类的概率,取值范围 0 到 1。但这个概率本身不能直接当信号用——「概率大于 0.5 就买入」在正负样本不平衡时几乎没有交易机会,因为所有样本的概率都集中在 0 到 0.4 之间。正确的做法是在训练集上算出一个分位数阈值,只有概率超过这个阈值的日子才触发买入信号。
为什么要用分位数而不是固定阈值?因为概率的绝对值受样本分布影响,而这个策略要控制的是交易频率。训练集上概率的 90 分位数,天然意味着「历史上最看多的 10% 的日子」,换到测试集上也能保持相似频率。如果你的策略希望一年交易 15 次左右,就看 90 分位;希望交易更频繁,就下调到 75 分位。这个参数直接控制换手率,最终影响手续费成本和实操可行性。
# 用训练集分数的 90 分位数作为买入阈值 train_score = model.predict_proba(X_train)[:, 1] buy_threshold = np.percentile(train_score, 90) # 在测试集上生成逐日信号:1 表示买入持有,0 表示空仓 test_df = test_df.copy() test_df["score"] = model.predict_proba(X_test)[:, 1] test_df["signal"] = (test_df["score"] > buy_threshold).astype(int) # 统计信号占比,判断交易频率是否合理 print("买入阈值:", round(buy_threshold, 4)) print("测试集买入天数占比:", round(test_df["signal"].mean(), 4))信号生成后,策略逻辑就简单了:signal为 1 的日子持有,为 0 的日子空仓。这里只做多不做空,因为 A 股的做空工具有限且成本高,机器学习模型预测「下跌」的可靠性也远低于预测「上涨」。如果你想做多空双向策略,需要单独训练一个预测下跌的模型,或者用股指期货的升贴水数据做对冲——那是另一个复杂度量级的事情。
到这里,策略的「大脑」就完成了。数据变成了特征,特征训练出了模型,模型输出了逐日信号。下一步要回答的问题是:这套信号拿到真实市场里,扣除手续费和滑点后还能不能赚钱?这就是回测框架要解决的事。
4. 回测框架搭建:用 backtrader 验证策略能不能活下来
回测是整个机器学习量化项目里最容易自欺欺人的环节。信号生成得再漂亮,如果回测时手续费设成 0、成交价用了未来价格、涨停板照买不误,出来的收益曲线都是幻觉。这个标题里「回测」和「源码」并列出现,说明作者把回测当成了项目的一等公民——它不应该是最后附带的验证脚本,而应该是跟模型同等重要的核心模块。
4.1 自研回测还是 backtrader:先想清楚你要验证什么
常见做法有两个方向:自己写循环回测,或者用现成的回测框架。自研回测的优点是灵活、每笔成交都能看清楚,缺点是撮合逻辑容易写错——比如忽略了次日开盘价成交、忘了扣手续费、无法处理涨跌停。backtrader 是 Python 生态里最常用的开源回测框架,内置事件驱动引擎,支持多股回测,自带资金管理和分析器,把「信号生成」和「订单撮合」两个阶段清晰分开。对于「用机器学习信号做日线交易」这个场景,backtrader 是性价比最高的选择。
我见过不少项目源码里自己写了三四百行的回测脚本,最后验证出来的年化收益比 backtrader 跑出来的高一倍。原因几乎都是撮合逻辑里的隐性假设过于乐观:信号当日发出、当日即按收盘价成交。backtrader 默认在下一根 bar 的开盘价撮合,这个「延迟一根 bar」的设计反而更接近真实的次日开盘交易,直接规避了最严重的未来函数问题。
4.2 把信号注入 backtrader:最小可跑的完整策略
backtrader 的使用有一个关键门槛:它默认只认识行情数据的六根线(datetime、open、high、low、close、volume、openinterest),我们训练出来的signal列不在其中。要让它被策略读取,需要自定义一个继承bt.feeds.PandasData的数据类,把 signal 注册成一根新的 line。这一步是新手卡壳最多的地方,很多人把 signal 列直接丢进 DataFrame,结果策略里怎么都读不到。
import backtrader as bt # 自定义数据类:在标准行情线之外增加一条 signal 线 class SignalData(bt.feeds.PandasData): lines = ("signal",) # 声明新线 params = (("signal", -1),) # -1 表示列名与属性名相同 # 策略类:核心交易逻辑 class MLSignalStrategy(bt.Strategy): def __init__(self): self.signal = self.datas[0].signal def next(self): # signal[0] 是当前 bar 的信号值,1 买入,0 空仓 if self.signal[0] > 0 and not self.position: self.buy(size=100) # 买入 100 股 elif self.signal[0] == 0 and self.position: self.close() # 清仓 # 准备 backtrader 需要的数据框,注意要包含 openinterest 列 feed_df = test_df[["date", "open", "high", "low", "close", "volume", "signal"]].copy() feed_df["openinterest"] = 0 feed_df = feed_df.set_index("date") # backtrader 使用索引作为时间 # 组装引擎 cerebro = bt.Cerebro() cerebro.adddata(SignalData(dataname=feed_df)) cerebro.addstrategy(MLSignalStrategy) # 资金与交易成本设置 cerebro.broker.setcash(100000.0) # 初始资金 10 万 cerebro.broker.setcommission(commission=0.0003) # 单边万三佣金 cerebro.broker.set_slippage_perc(perc=0.001) # 滑点 0.1% # 执行回测 result = cerebro.run()这段代码里,SignalData类的lines = ("signal",)是注册新数据线的声明,params = (("signal", -1),)告诉框架「signal 列的名字就叫 signal」。set_index("date")这行很关键,backtrader 的 PandasData 默认把 DataFrame 的索引当时间轴,如果你不设索引,它会报警告并且时间对不上。set_slippage_perc(perc=0.001)模拟的是「实际成交价比信号价格差 0.1%」——买入时多付、卖出时少得,这是必须扣掉的隐藏成本。
next()是 backtrader 策略逻辑的核心,每个交易日调用一次。self.signal[0]中的[0]表示当前 bar 的值,backtrader 用负数索引访问历史、0 访问当前。逻辑很简单:信号为 1 且空仓就买入 100 股,信号变 0 且有持仓就清仓。这里用close()而不是sell()的好处是直接平掉全部持仓,不用自己计算数量。
4.3 回测结果怎么看:年化、最大回撤、夏普、胜率
回测跑完不是打印一句「Final Value: 150000」就结束了,那一句说明不了任何问题。年化收益率高但回撤 50% 的策略,实盘根本拿不住;夏普比率只有 0.3 的策略,相当于收益都是运气。我每次回测至少看四个指标:年化收益率、最大回撤、夏普比率、交易次数。backtrader 内置了对应的分析器,挂在 cerebro 上即可。
# 添加分析器 cerebro.addanalyzer(bt.analyzers.Returns, _name="returns") # 收益率 cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown") # 回撤 cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", riskfreerate=0.02) # 运行并获取分析结果 strat = cerebro.run()[0] ret = strat.analyzers.returns.get_analysis() dd = strat.analyzers.drawdown.get_analysis() sh = strat.analyzers.sharpe.get_analysis() print("年化收益率(%):", round(ret["rnorm100"], 2)) print("最大回撤(%):", round(dd["max"]["drawdown"], 2)) print("夏普比率:", round(sh["sharperatio"], 2))这四个指标的参考标准,我自己的经验是这样的:日线级别、只做单只股票的策略,年化 15% 以上已经不错;最大回撤控制在 20% 以内,超过 30% 就说明信号频率或仓位管理有问题;夏普比率大于 1 视为可接受,大于 1.5 算优秀;交易次数太少(一年不足 10 次)则统计意义不足,说明阈值定得太苛刻。
下面这张表是我在评估策略时会摆在旁边的参照基准,新手可以直接抄:
| 指标 | 计算方式 | 我一般参考范围 |
|---|---|---|
| 年化收益率 | 总收益按回测年数折算复利 | 15% 以上算合格 |
| 最大回撤 | 账户净值从峰值到谷底的最大跌幅 | 20% 以内可接受 |
| 夏普比率 | (策略年化收益 - 无风险利率) / 年化波动率 | 1.0 以上可继续优化 |
| 交易次数 | 回测期内的总开仓次数 | 不少于 30 次才有统计意义 |
注意,这套参考标准只适用于「日线 + 单标的 + 纯多头」的策略,高频策略、多标的组合的评估标准完全不同。如果你的策略回测结果是年化 100%、回撤只有 5%,别高兴太早,先去检查第 5 章的坑——大概率踩了至少一个。
5. 回测避坑指南:5 个最容易被忽略却致命的细节
回测本身是一面放大镜,它会把数据准备和模型训练阶段埋下的所有问题成倍放大。这一章是血泪经验汇总。以下 5 个坑,我每一个都在真实项目里踩过或替别人排查过,按「现象 → 原因 → 解决」写清楚,花十分钟对照检查你的回测代码,可能比调一周参数都值。
5.1 前视偏差:信号在当天收盘产生,成交却按当天收盘价算
现象:回测收益曲线漂亮得不像话,年化 60% 起步,回撤极低;但换成另一只股票或换时间段,收益直接腰斩。打开成交记录,发现所有买入都精准出现在大涨当天。
原因:特征用的是当天收盘价算出来的信号,但回测撮合时也用了当天收盘价成交。这等于「收盘后知道了信号,却按收盘前的价格买入」,未来信息混进了成交价。更隐蔽的一种形式是标签构造时用了shift(-n)忘记对齐,模型学到的其实是「未来 N 天的价格信息」。
解决:回测撮合时间统一推迟一根 bar。我的习惯是,T 日收盘后计算特征、生成信号,T+1 日开盘买入。backtrader 的默认行为恰好就是这样——在next()中发出的订单按下一根 bar 的开盘价撮合。自研回测的话,在信号列上手动加df["signal"] = df["signal"].shift(1)即可,一步解决,这句话要刻在脑子里。
5.2 数据泄漏:标准化和缺失值填充用了全量数据
现象:测试集 AUC 异常高,比如 0.75 以上;训练集和测试集的 AUC 都很好,但一到模拟盘就崩。更换训练窗口后,AUC 波动剧烈。
原因:做特征标准化或缺失值填充时,对全量数据调用了fit_transform,让 test 段的均值和标准差参与统计。LightGBM 本身不需要标准化,但项目里的其他代码——比如用线性模型做对比,或对vol_ratio做z-score——就会踩这个坑。模型在训练时「见过」测试集的数据分布,测试集不再干净。
解决:特征处理统一走 sklearn 的 Pipeline,先fit训练集,再transform测试集。缺失值填充用训练集的中位数或均值,不掺测试集信息。检查方法很粗暴:在所有特征上随机打乱时间顺序重新训练,如果 AUC 掉得不多,说明模型根本没学到时序规律,纯粹在背数据。
5.3 手续费和滑点设成 0:回测年化 30%,实盘只有 3%
现象:回测里一年交易 50 次,佣金为零、滑点为零、印花税为零,年化收益 30%。模拟盘跑三个月,收益只剩零头。
原因:交易成本被忽略了。单边万三佣金看起来不多,但 50 次换手意味着 100 次买入卖出,成本按本金叠加后相当可观。滑点更是被普遍忽视——真实下单时,你的买单会推高成交价,尤其在成交量清淡的股票上。
解决:把成本当成策略的一部分,而不是可选项。backtrader 里至少设置commission=0.0003和set_slippage_perc(perc=0.001),对应的参数是佣金率和滑点比例。更严格的做法是卖出时额外加印花税——A 股卖出单边征收,回测时在卖出成交额上乘(1 - tax_rate),虽然麻烦但更贴近实盘。设置完成本后,回测年化收益如果掉到原来的三分之一以下,说明策略的高收益其实来自高频换手,而不是预测能力。
5.4 涨跌停照常成交:涨停板买不进,跌停板卖不出
现象:回测日志里出现「买入价格 = 当日涨停价」的记录,或者跌停当天成功清仓。这些成交在实际操作中根本无法完成,回测却默默按理想价格撮合了。
原因:回测引擎只认价格,不认涨跌停约束。当信号在涨停日触发买入时,引擎按涨停价成交,但真实世界里涨停板上排队都买不到;跌停时反过来,卖单根本排不上队。这个问题在单只股票的日线策略里格外致命,因为涨停和跌停往往伴随极端的模型信号。
解决:回测策略里手动加过滤。常见做法是买入前判断当日是否涨停——收盘价相对前一日收盘价涨幅超过 9.5%(预留一点容差)就跳过买入;卖出前判断是否跌停。backtrader 里可以直接在next()中加一个判断:if self.data.close[0] / self.data.close[-1] - 1 > 0.095: return。另外,A 股还有 T+1 限制——当日买入的股票当日不能卖出,如果回测里出现当日买当日卖,也需要额外加持仓时间约束。多股回测时更要逐只股票校验这些限制。
5.5 幸存者偏差:股票池里只剩活下来的股票
现象:多股选股类的策略回测效果极好,选中的股票「恰好」都是后来大涨的,年化收益甩开大盘一大截。但换到当下真实市场,选股结果惨不忍睹。
原因:数据获取时,用的是「当前仍然在市」的股票列表。退市的股票早就被剔除在数据源之外,那些暴跌 90% 后退市的股票从未参与回测。模型在历史回测中永远碰不到「会退市」的股票,等于提前知道了答案。
解决:多股策略必须使用历史时间点仍然存续的股票列表。常见做法是拉取回测起点时刻的指数成分股(比如沪深 300 历史成分股),而不是从当前股票池反推。单股策略虽然没有股票池选择问题,但要在使用说明里明确注明「结论只对该股票有效,不能泛化到全市场」。这个坑在源码项目里几乎不会被人主动提醒,但它决定了你的回测结果有没有参考价值。
6. 进阶:从回测到实盘前的样本外验证与参数体检
回测跑通、没有明显硬伤,只代表策略在「这一段历史」上有效。距离实盘还差最后一步:用更严格的方式验证策略的健壮性。这一步的核心思路是——历史数据不是只有一份切好的训练集和测试集,你可以反复地「滚动训练 + 滚动验证」,模拟真实世界里每隔一段时间重新训练模型的过程。
最常见的做法叫 walk-forward 验证:把时间轴切成多个重叠窗口,每个窗口都用窗口内的数据训练、窗口后的一小段数据测试,然后滚动窗口。它的价值在于能检查「模型在不同市场环境下是否稳定」——如果策略在牛市窗口赚钱、熊市窗口巨亏,说明模型学到的是市场风格而不是底层规律;如果每个窗口都有正收益(哪怕不多),策略的可信度就高了很多。实盘运行时,你本来就需要每隔固定时间用最新数据重新训练模型,walk-forward 恰好模拟了这个过程。
参数体检是第二个必做项。把buy_threshold、num_leaves、min_child_samples分别上下浮动 20%,重新跑回测,看收益变化是否剧烈。真正的稳健策略,参数小幅变动时收益只有小幅波动;如果某个参数从 90 分位调到 85 分位,收益就从年化 20% 变成亏损,说明策略依赖于精确过拟合的参数点,实盘必翻车。
我的习惯是给每个策略维护一份自己的「使用说明」,内容包括:数据来源和复权方式、特征列名清单、模型参数、信号阈值、回测区间、手续费和滑点假设。这份说明同时服务于两个目的:一是三个月后自己回来能复现,二是换数据、换参数时有据可查,不用从零开始猜。源码包里那份使用说明是作者写的,而你必须有能力给自己的版本写出同样清晰的说明——这才是「高分项目」和「能落地项目」的分水岭。
说一个我自己的教训:早年做过一个策略,回测年化 40%,最大回撤只有 8%,当时兴奋得直接配了模拟盘资金。跑了两个月,亏了 8%。排查到最后,发现标签构造时shift(-5)之后少删了最后几行,模型在训练时看到了未来数据,回测当然漂亮。技术上的错误都能查到,怕的是你根本不知道哪个环节藏了坑。所以,每跑完一轮回测,都先问自己一句:这个收益里,到底有多少来自模型能力,多少来自回测漏洞?把这个问题刻在习惯里,机器学习量化这条路,你就已经超过了大多数只盯着收益曲线的人。希望帮到你。
本文还有配套的精品资源,点击获取