简介:这是一份基于 Python 机器学习的量化投资策略项目源码,完整覆盖数据下载、特征工程、模型训练与历史回测四大环节,适合有一定编程基础、希望入门量化交易或金融建模的开发者学习和二次开发。压缩包共 15 个文件,以 5 个 Python 脚本和 6 张可视化图表为主体,另含依赖清单、股票列表、Markdown 说明文档及 gitignore 配置,整体仅 735KB,结构紧凑,可快速对应数据、特征、模型、回测各模块。目前已有 267 人浏览学习。项目通过 tusare 获取股票历史行情,可自定义股票列表来扩充数据源;随后生成模型所需高级特征,采用 LightGBM 训练策略模型。回测时会记录每日买入标的与收益,并计算累积收益、最大回撤、夏普率等量化投资核心指标,帮助使用者系统评估策略表现;配合使用说明,能够轻松复现从原始数据到回测结果的全流程,并在调整股票池或参数的过程中加深对量化投研各环节的理解。
1. 机器学习量化策略不是玄学:这套源码+回测到底能帮你走到哪一步
很多刚接触机器学习的交易者,拿到一份“量化投资策略项目源码”后的第一反应,是把回测跑出来看一眼收益率,然后盯着那条陡峭的净值曲线问:这能直接上实盘吗?我见过太多回测年化翻倍、实盘几个月就熄火的案例,问题大多不在模型,而在数据对齐、标签构造和回测设置上。这里面的核心链条——Python数据清洗、机器学习特征与模型训练、回测框架验证——恰恰是标题里源码、回测和使用说明三者要一起解决的。这套方案适合已经会写基础Python、想做机器学习选股但不想从零搭轮子的人,也适合被股票预测模型坑过、想搞清楚“为什么回测和实盘差这么多”的人。先接受一个前提:源码能跑通只是起点,回测结果可信才是这个标题里真正值钱的另一半。
2. 先把数据喂对:特征工程与标签构造决定策略上限
拿到这类源码包,我建议第一件事不是运行 main.py,而是先花半小时把使用说明或 README 读透,确认三件事:数据接口用的是什么、特征在哪个文件里生成、回测脚本的入口在哪。多数人翻车都是因为跳过了数据准备,直接用别人处理好的特征跑模型,出了问题根本不知道去哪查。
2.1 数据源选型与清洗:后复权、缺失值、涨跌停先处理
常见做法是用免费或低成本的行情接口拉日线数据,国内可选 akshare、baostock,国外教程里常出现 yfinance。自己拉数据最需要注意的是复权方式。回测必须用后复权价格,因为前复权会改变历史价格间的比例关系,导致用历史数据训练的特征在回测时失真。
下面是一段用 akshare 拉取某只股票日线并做基础清洗的示意代码,具体接口参数以你装的 akshare 版本为准:
import akshare as ak import pandas as pd # 拉取股票日线,adjust="hfq" 表示后复权 df = ak.stock_zh_a_hist( symbol="000001", period="daily", start_date="20150101", end_date="20231231", adjust="hfq" ) # 统一列名,后面特征和回测都靠这几个字段 df = df.rename(columns={ "日期": "date", "开盘": "open", "收盘": "close", "最高": "high", "最低": "low", "成交量": "volume" }) df = df[["date", "open", "close", "high", "low", "volume"]].copy() df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) # 检查缺失和停牌日(成交量为0的交易日直接过滤) df = df.dropna() df = df[df["volume"] > 0] print(df.head())这段代码的逻辑是:先拿到后复权日线,统一字段名,再按时间排序并剔除停牌和异常空值。adjust="hfq"是后复权,回测和特征计算都应该用它,不要用未复权数据,否则除权除息日会出现虚假的暴跌。停牌日通常成交量为 0 或直接缺失,这类样本会影响特征计算的连续性,过滤掉比填充更干净。
另外提醒一句:不同数据接口的复权字段差异不小,有的接口默认前复权,有的返回未复权。使用说明里如果写了数据来源,就按它来;如果没写,自己务必确认一下复权口径。
2.2 特征怎么构造:动量、波动率与量价因子的一组可跑代码
特征工程在量化里有一个特殊约束:只能用当前时刻及之前的数据,不能混进未来信息。常见的做法是把原始价格序列转成几类因子——动量类、波动类、量价类,然后用滚动窗口计算。下面这段代码构造了 5 个基本面因子之外的高频量价特征:
# 基础收益率 df["ret"] = df["close"].pct_change() # 动量因子:过去5日、20日累计涨幅 df["mom5"] = df["close"] / df["close"].shift(5) - 1 df["mom20"] = df["close"] / df["close"].shift(20) - 1 # 波动率因子:过去10日收益标准差 df["vol10"] = df["ret"].rolling(10).std() # 量比因子:当日成交量相对20日均量的放大程度 df["vol_ratio"] = df["volume"] / df["volume"].rolling(20).mean() # 乖离率:收盘价相对20日均线的偏离 df["bias20"] = (df["close"] - df["close"].rolling(20).mean()) / df["close"].rolling(20).mean() # 删除开头因为滚动窗口产生的NaN行 df = df.dropna().reset_index(drop=True)pct_change()计算的是相对前一天的收益率,shift(5)取 5 天前的收盘价,两者结合得到 5 日动量。rolling窗口会产生前 N 行为 NaN,最后统一 dropna 是标准操作。这里的关键点是所有计算都只依赖过去的数据,没有用到未来值。
如果你跑的是多股票池,还需要做截面标准化。纯时序因子在不同股票之间不可比,比如平安银行的波动率和一只小盘股的波动率不在一个量级。常规做法是按日期分组,在每个交易日内对因子做 z-score 或排名。这个细节决定了机器学习模型能不能真正学到“横截面选股”规律。
2.3 标签构造与按时间切分:别把未来数据装进训练集
标签构造是这类项目里最容易被忽略但最影响结果的一步。常见的做法是用未来 N 日收益率构造分类标签,比如未来 5 日收益超过 2% 记为 1,否则为 0。但这里有一个经典的坑:用shift(-5)取未来数据时,最后 5 行会得到 NaN,很多人不处理直接丢给模型,训练时没报错,预测时却可能出现莫名其妙的结果。更严重的是,如果切分训练集和验证集时用了随机抽样,同一段行情会同时出现在训练集和验证集里,相当于透题。
# 未来5日收益作为标签 df["future_ret"] = df["close"].shift(-5) / df["close"] - 1 df["label"] = (df["future_ret"] > 0.02).astype(int) # 删除末尾5行NaN标签 df = df.dropna().reset_index(drop=True) # 按时间顺序切分,不能用随机切分 cutoff1 = int(len(df) * 0.7) cutoff2 = int(len(df) * 0.85) train = df.iloc[:cutoff1] valid = df.iloc[cutoff1:cutoff2] test = df.iloc[cutoff2:]这段代码的标签逻辑是:未来 5 日涨幅超过 2% 才算正样本。2% 这个阈值可以根据标的不同调整,指数和个股差异很大。切分比例我一般用 7:1.5:1.5,或者更保守的 6:2:2。注意dropna()必须在切分前完成,否则标签泄露到训练数据里。
除了时间顺序切分,还有一个常被忽略的点:验证集和测试集之间要留一个间隔,避免紧挨着的行情数据产生时序相关性。比如训练集到 2022 年 6 月,验证集从 2022 年 9 月开始,留出两三个月的缓冲,评估结果会更接近真实部署表现。
3. 用LightGBM训练选股模型:从基线到可复现的调参流程
在机器学习选股这个方向,模型选型最稳妥的不是深度学习,而是梯度提升树模型,尤其是 LightGBM。这个判断来自它的实际表现和数据特性,表格型特征加非线性关系是它的主场。
3.1 表格型特征为什么先选LightGBM,而不是LSTM
量化投资里的输入特征大多是表格形态:每行是一个股票在某一天的因子值,列是动量、波动率、估值等。这类数据有两个特点:特征之间可能有复杂交互,但样本量远小于自然语言或图像;同时噪声极高,股价短期走势很大程度受市场情绪和资金面影响,模型很容易过拟合。LightGBM 对这类表格数据有天然优势:训练快、自带正则化、能处理缺失值、特征重要性可以直接输出,方便你做因子筛选和策略解释。LSTM 等序列模型适合的是长程依赖明显的任务,而日频选股特征并不具备强序列依赖。从性价比角度看,先从 LightGBM 起步,跑通全流程后再考虑深度学习也不迟。
3.2 一份能直接跑的LightGBM训练与验证脚本
特征是上一章构造的量价因子,标签是二分类。训练时我用早停避免过拟合,并把验证集指标打印出来:
import lightgbm as lgb from sklearn.metrics import roc_auc_score features = ["mom5", "mom20", "vol10", "vol_ratio", "bias20"] X_train = train[features] y_train = train["label"] X_valid = valid[features] y_valid = valid["label"] train_set = lgb.Dataset(X_train, y_train) valid_set = lgb.Dataset(X_valid, y_valid, reference=train_set) params = { "objective": "binary", "metric": "auc", "learning_rate": 0.02, "num_leaves": 31, "feature_fraction": 0.7, "bagging_fraction": 0.8, "bagging_freq": 1, "verbose": -1 } model = lgb.train( params, train_set, num_boost_round=2000, valid_sets=[valid_set], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)] ) valid_pred = model.predict(X_valid) print("验证集AUC:", roc_auc_score(y_valid, valid_pred))这里几个参数值得细说。learning_rate=0.02是学习率,越小越不容易过拟合,但要配更多的迭代轮数;num_leaves=31控制树的复杂度,叶子数越大模型越容易记住噪声;feature_fraction=0.7表示每棵树只用 70% 的特征,相当于特征层面的随机性,能抑制过拟合;bagging_fraction=0.8是行采样,每棵树只用 80% 的样本。early_stopping(100)的意思是验证集 AUC 连续 100 轮不提升就停止,这个数值要和学习率配合,学习率越低,早停的耐心轮数应越大。
3.3 调参三板斧:学习率、num_leaves与早停
调参最忌一上来就 grid search,耗时且容易过拟合。我的顺序一般是先固定学习率为 0.01 到 0.03,通过早停确定大概的迭代轮数,然后调num_leaves,从 16 试到 64,观察验证集 AUC 的变化。num_leaves越大,训练集 AUC 越高,但验证集 AUC 可能开始回落,这个拐点就是合适值。最后微调feature_fraction和bagging_fraction,一般 0.6 到 0.9 之间。还有一个容易忽略的参数是min_data_in_leaf,默认 20,对股票这种噪声大的数据我常调到 200 以上,强制每个叶子至少有几百个样本,能显著减少极端预测值,回测资金曲线更平顺。
评估模型不止看 AUC。量化里更常用 IC(信息系数),即预测分数与未来收益的秩相关系数,一般要求 IC 均值在 0.03 以上才值得继续。可以把验证集预测结果按分数分 10 组,看最高组的平均收益是否明显高于最低组,这个分组的单调性是判断策略是否有真实预测力的重要依据。如果分组收益没有单调性,就算 AUC 再高,回测大概率也是过拟合的产物。
4. 用backtrader把预测变成净值曲线:单股与多股回测配置
模型训练完,下一步是把预测分数接进回测框架。backtrader 是目前 Python 生态里用得最多的开源回测框架之一,支持多股回测、自定义手续费和滑点,对这份源码项目来说足够用。这里最关键的思路是:回测脚本里的交易信号必须依赖预测分数,而预测分数必须由训练好的模型在“当时”生成,不能提前算出整段历史的预测值再喂给回测。
4.1 从预测分数到交易信号:阈值、仓位与换手控制
模型输出的是一个 0 到 1 的预测概率,不能直接当信号。我一般先看训练集预测分数的分位数分布,比如取 70% 分位数作为买入阈值,分数高于阈值才买入,低于 40% 分位数时清仓。这样做的目的是控制交易频率,避免模型概率一波动就频繁进出,手续费会吃掉利润。回测里常见的换手率控制在月度 30% 到 100% 之间比较合理,过高说明信号不稳定。
4.2 backtrader最小回测工程:手续费、滑点与资金设置
下面是一个最小可用的 backtrader 策略类,策略只在预测分数超过阈值时买入,低于另一个阈值时卖出:
import backtrader as bt class MLStrategy(bt.Strategy): params = ( ("buy_th", 0.6), ("sell_th", 0.4) ) def __init__(self): # 假设数据馈送里已有一列score,由模型预测生成 self.score = self.datas[0].score def next(self): if not self.position and self.score[0] >= self.p.buy_th: self.buy(size=100) elif self.position and self.score[0] < self.p.sell_th: self.close() cerebro = bt.Cerebro() # 假设data是已经按日期排序好的数据馈送 cerebro.adddata(data) cerebro.addstrategy(MLStrategy) cerebro.broker.setcash(100000.0) cerebro.broker.setcommission(commission=0.0003) cerebro.addanalyzer(bt.analyzers.Returns, _name="returns") results = cerebro.run() print("期末总资金:", cerebro.broker.getvalue())buy_th和sell_th是买卖阈值,size=100是每次买入股数。setcommission(commission=0.0003)设置的是单边万三的手续费,这已经是比较理想化的费率。真正实盘还有印花税和滑点,回测至少要再加滑点,backtrader 里通过cerebro.broker.set_slippage_perc(perc=0.001)设置按成交价的千一滑点。很多源码包的回测脚本默认没有滑点,跑出来的收益会虚高,使用说明里如果没提,建议自己加上。
4.3 backtrader多股回测:多标的同时交易时的资金分配
多股回测是另一个高频需求,热搜里的 backtrader 多股回测关注度很高。backtrader 支持向 Cerebro 添加多个 data feed,策略通过self.datas访问所有标的。常见做法是每个交易日对所有股票打分,选出分数最高的前 N 只持有,等权重分配资金:
class MultiStockStrategy(bt.Strategy): def __init__(self): self.datas_len = len(self.datas) def next(self): # 收集当日所有标的的预测分数 scores = [(i, self.datas[i].score[0]) for i in range(self.datas_len) if not self.datas[i].position] scores.sort(key=lambda x: x[1], reverse=True) # 买入前3名 for idx, score in scores[:3]: cash_per_stock = self.broker.getvalue() / 10 size = int(cash_per_stock / self.datas[idx].close[0]) self.buy(data=self.datas[idx], size=size)这段代码里,self.datas[i]是第 i 只股票的数据流,score[0]表示当前 bar 的预测值。买入前 3 名股票且控制单只仓位为总资金的 10%,是为了分散风险。多股回测的坑在于资金分配和停牌处理,如果某只股票不在交易日或涨停买不进,backtrader 默认按下一根 bar 成交,这个假设在实盘中不成立。处理办法是回测前把停牌日和涨跌停日的预测分数置为无效。
5. 回测避坑指南:结果漂亮却实盘翻车的4个根源
回测曲线漂亮而实盘亏损,是量化项目里最常见的翻车模式。下面这几个坑是这类源码项目里反复出现的,每条都按现象、原因、解决的顺序说清楚。
5.1 未来函数:净值曲线在预测发布日跳涨
现象:回测净值曲线在某个日期突然跳升,然后在下一个交易日又开始正常波动。原因:预测分数在计算时使用了当日收盘后才知道的数据,但信号却在当天开盘时就执行了交易,交易时间点比数据可用时间更早。解决:将信号推迟一个交易日。backtrader 里可以通过self.datas[0].close[0]拿到当天已确认的收盘价,但买入动作要在下一个 bar 开盘执行,也就是用next()里的self.datas[0].close[-1]生成信号并buy(),确保信号不会偷看未来。
5.2 幸存者偏差与涨跌停买不进
现象:回测收益里包含了很多如今已经退市或表现极差的股票,并且在上涨时总能以接近开盘价买入。原因:选股池用的是当前仍然上市的股票,过去已经退市的股票被过滤了,这就是幸存者偏差。同时回测默认股票在涨停板也能买入,实际根本买不进。解决:选股池按历史快照的成分股构造,至少也要在回测脚本里做流动性过滤,把当日涨停或成交额过低的股票排除在可买列表外。
5.3 过拟合:在同一段测试集上反复调参
现象:验证集 AUC 很高,但换了新的时间段后表现断崖式下跌。原因:在同一个测试集上反复调整参数,模型和调参者一起记住了这段行情的噪声。这不是模型的问题,是评估流程的问题。解决:训练集、验证集、测试集严格分离,测试集只能在最终阶段使用一次。更稳妥的做法是滚动训练,见下一章的 walk-forward 流程,每个时间段都重新训练再验证。
5.4 手续费与滑点设成0
现象:回测年化收益很高,但交易次数也异常多,程序化实盘后收益大幅缩水。原因:回测把交易成本设为零,高频交易产生的摩擦成本完全没有体现。A 股双边手续费加印花税、滑点合计约千二到千三,高频策略可能更高。解决:回测参数至少设置双边千二交易成本和一波滑点,然后再看收益。用 backtrader 的话,setcommission(commission=0.0003)只是单边佣金,还需要在评估时把滑点和印花税估算进去,或者直接调高佣金比例模拟总成本。
6. 让策略经得起检验:Walk-forward滚动训练与绩效指标验收
6.1 Walk-forward滚动训练:模拟真实部署节奏
回测里最容易骗自己的方式是拿 2015 到 2023 全量数据训练,然后在同一段时间内“验证”。实盘不是这样运行的,模型需要不断重新训练。常见的做法是 walk-forward:每 6 个月用过去 3 年数据重新训练一次模型,然后只对下一个月的行情做预测和回测,逐段滚动。这个过程可以用时间序列交叉验证实现:
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5) for train_idx, test_idx in tscv.split(X): X_train_fold = X.iloc[train_idx] y_train_fold = y.iloc[train_idx] X_test_fold = X.iloc[test_idx] y_test_fold = y.iloc[test_idx] # 每一折都重新训练LightGBM并记录绩效TimeSeriesSplit按时间递增切分,确保测试数据永远在训练数据之后。每一折的模型绩效分别记录,最后看平均表现是否稳定,而不是盯着某一折的高收益。这个方法会显著拉长回测时间,但它是判断策略是否值得投入的最可靠手段。
6.2 绩效指标怎么读:年化、回撤与夏普之外的三张表
最后验收策略时,不要只看年化收益率和夏普比率,我一般会额外看三个指标:最大回撤、胜率、盈亏比。最大回撤超过 20% 的策略需要慎重配置仓位,因为实盘回撤会影响执行纪律;胜率低于 35% 但盈亏比高的策略可以接受,但必须确认连续亏损次数在可承受范围内。把回测脚本输出的每笔交易记录导出,逐年统计表现,观察是否集中在某一年赚钱。如果收益全部来自单一年份的特定行情,那这个策略在下一个市场环境下大概率失效。
我自己做这类项目有个习惯:回测结果保存后隔一周再回来看一遍,如果还是觉得值得投,才做小资金实盘验证。量化投资里最贵的不是模型训练时间,而是对回测结果的过度信任。希望这些从数据准备到回测验收的细节,能帮你在拿到源码后少走一段弯路。
提示:以上所有代码和参数基于常规开源工具链,实际使用时请以你本地环境和源码包内使用说明为准。
本文还有配套的精品资源,点击获取