☰
Python机器学习量化投资策略源码拆解:从特征工程到LightGBM回测
2026/10/3 3:18:54 网站建设 项目流程

简介:面向量化投资与机器学习实践者,这是一个可运行的项目源码,演示了从行情数据获取到策略回测的完整流程。项目围绕梯度提升树模型展开,涵盖数据抓取、特征工程、模型训练与历史回测四个核心环节,可输出每日建议买入的股票及对应收益,并计算累计收益、最大回撤、夏普率等常用量化指标;同时提供一键执行入口、依赖安装清单和说明文档,适合希望快速搭建策略原型、理解量化回测流程的开发者参考。资源包共十五个文件,主体是五个脚本,另有六张效果图、两个文本清单、说明文档和配置文件,压缩包仅七百三十五KB,结构简洁、依赖清晰,便于直接运行与二次修改。目前已有两百六十七人学习,配套依赖列表和使用说明能帮助新手迅速上手。

1. 用 Python 机器学习做量化投资策略:这份源码到底能让你少走多少弯路

手里有一套 Python 机器学习量化投资策略的完整源码,从数据抓取、特征生成、lightGBM 训练到回测和评价指标,四步流程加一个一键执行的 main.py 全给串起来了。拆完这个项目,你收获的不只是一段能跑的代码,而是“特征文件怎么存、模型怎么留档、回测记录怎么读”这一整条数据链路。适合想第一次把机器学习量价策略跑通的新手,也适合已经手动写过单标的预测、想看看完整选股框架的熟手——文末我会把真正影响回测可信度的几个坑单独列出来。先别急着调参数,跟着数据流走一遍,你就知道这套源码每一条命令是在干什么。

2. 数据准备:把历史行情以统一格式落盘

2.1 tusare 数据源怎么接:字段、目录与复权

运行 data.py 之前要确认一件事:file/stock_list.txt 里的每一支股票,都能在 file/data/ 下对应一个干净的 CSV。源码里写的是 tusare 调用,我按 tushare 这一类免费行情接口来理解;不同版本接口名有差异,但落盘结构一致,每支股票一个文件,字段至少包含 open、high、low、close、vol 这类量价信息。main.py 的作用只是按顺序调用后面四个脚本,正式排错时建议还是手动分步跑,每一步的产物都能单独检查。

我一般会在循环里加一个简单重试,避免免费接口有频率限制时中断整批数据。下面是 data.py 里常见写法:

# data.py 的核心结构(以我本地版本为例,tusare 具体接口按你安装的版本替换) import time from pathlib import Path stock_list = Path('file/stock_list.txt').read_text().strip().splitlines() data_dir = Path('file/data') data_dir.mkdir(parents=True, exist_ok=True) for code in stock_list: # fetch_daily_price 是占位函数,替换成 tusare 实际拉行情返回 DataFrame 即可 df = fetch_daily_price(code, start='2018-01-01', end='2023-12-31') if df is None or df.empty: continue df['code'] = code df.to_csv(data_dir / f'{code}.csv', index=False) time.sleep(0.5) # 控制请求频率,免费接口容易被限流

这段逻辑里有两个参数要留意:时间范围 start/end 决定样本窗口,窗口越长特征越丰富,但也要小心早期交易制度和涨跌停规则跟现在不同;sleep 时间不是玄学,是给免费接口留缓冲,本地演示 0.5 秒足够。数据拉到本地后做一次常规体检:文件是否有内容、日期列是否被统一成同一格式、是否需要前复权。复权这步最容易忽略,遇到除权除息日,原始价格会出现人为跳空,模型会把这种跳空当成真实涨跌信号,回测结果立刻失真。常见做法是拉前复权数据,保证历史序列连续。

2.2 股票清单与数据目录:demo 十支股票够不够

file/stock_list.txt 决定你的研究范围。demo 只放了 10 支股票,是为了让整套流程在几分钟内跑完,不是为了证明选股效果。实战里股票池尽量放几十支以上,跨行业、跨风格,模型才能学到“为什么选 A 而不是选 B”,而不是在 10 支里矮子拔高个。数据目录结构如下表:

路径内容说明
file/stock_list.txt股票代码清单每行一个代码,数量决定股票池大小
file/data/{code}.csv单支股票历史行情data.py 输出的中间产物
file/feature/特征文件目录feature.py 生成的 pickle 二进制文件
file/model.lgb.txt训练好的模型文本model.py 保存的轻量级模型文件
file/record.csv回测交易记录backtest.py 输出,逐日持仓与收益

这里有个我踩过的小地方:拉完数据要顺手检查空文件。停牌时间长的股票,CSV 可能只有几十行,后面算 rolling 窗口时全被 dropna 丢掉,等于白跑一遍。可以在命令行快速扫一遍:

# 检查是否存在空文件,以及每个文件的大致行数 find file/data -name '*.csv' -empty wc -l file/data/*.csv | sort -n | head

第一行如果什么都没输出,说明没有空文件;第二行按行数排序,行数异常少的代码要单独决定是保留还是剔除。我的习惯是给 stock_list 留一个黑名单概念,回测时把上市不足一年、长期停牌的股票过滤掉,避免它们白白消耗特征窗口。数据落地这一步不需要高深技巧,但目录和字段约定一旦混乱,后面每个环节都得回头查,代价最大。

3. 特征生成:把量价数据变成模型能学的输入

3.1 为什么不能把收盘价直接丢给模型

机器学习模型默认认为输入特征对标签有稳定解释力,而原始价格序列不满足这个前提:不同股票股价区间差异大,白酒和银行不能直接比绝对价格;价格本身是非平稳序列,直接训练容易让模型学到“价格上涨所以未来上涨”这种缺乏依据的结论。所以 feature.py 存在的意义,是把 file/data 下的基础行情转换成一组相对量:涨跌幅、均线乖离、波动率、量比、动量。这些特征剔除了绝对价格的影响,保留的是“当前价格相对于近期均线高了多少、量能是否放大”这类横截面对比信息。

标签设计也是特征工程的一部分。我常用的做法是给每支股票计算未来 N 日收益,N 取 5 或 10,然后转成二分类标签:未来 5 日收益为正记 1,否则记 0。这个窗口不是拍脑袋,它对应的是“持有 5 个交易日后卖出”的调仓周期。注意,特征只能使用 T 日及之前的信息,标签用的是 T 日之后的信息,两者之间一旦交叉,后面回测一定翻车,这部分我在第 5 章展开。

3.2 feature.py 落地:滚动窗口、分组计算与 pickle

特征计算的细节在于“按股票分组”而不是把所有股票拼成一个表滚动,否则前一股票的最后几行会跟后一股票开头几行混在一起计算,算出来的全是无效特征。以下是常见实现:

# feature.py 的核心逻辑:读取 CSV,分组构造特征,落盘 pickle import pandas as pd from pathlib import Path data_dir = Path('file/data') feature_dir = Path('file/feature') feature_dir.mkdir(parents=True, exist_ok=True) frames = [] for csv_file in sorted(data_dir.glob('*.csv')): df = pd.read_csv(csv_file) df['trade_date'] = pd.to_datetime(df['trade_date']) df = df.sort_values('trade_date').reset_index(drop=True) # 涨跌幅是模型最基础的输入之一 df['ret'] = df['close'].pct_change() # 均线乖离:当前价格离 20 日均线的偏离程度 df['ma20'] = df['close'].rolling(20).mean() df['bias20'] = df['close'] / df['ma20'] - 1 # 量比:当日成交量相对 20 日均量的倍数 df['vol_ratio'] = df['vol'] / df['vol'].rolling(20).mean() frames.append(df) all_df = pd.concat(frames, ignore_index=True) all_df = all_df.dropna(subset=['ret', 'bias20', 'vol_ratio']) all_df.to_pickle(feature_dir / 'feature.pkl')

这段代码有三个地方值得拆开说。第一,sort_values('trade_date')是硬性前置条件,很多 CSV 从接口拿回来并不是严格时间升序,不排序的话 rolling 窗口的方向就是乱的。第二,rolling(20)这类窗口参数要跟调仓周期对齐,5 日调仓用 5 日均线、20 日调仓用 20 日均线都说得通,但固定用 20 却按 5 日频率调仓,特征和标签的周期就错位了;常见做法是同时生成短周期和长周期两组特征。第三,dropna会把每个股票前 20 个交易日的数据丢掉,这是滚动窗口的正常代价,数据量足够大时不用心疼。

存储格式我特别要说一下:all_df用to_pickle而不是to_csv。pickle 是二进制格式,读取速度比 CSV 快一个量级,而且能保留 DataFrame 里列的数据类型——日期还是 datetime、数值还是 float,存进去什么样读出来就是什么样。CSV 一旦日期列被读成字符串,后面训练前又要做一轮转换,这个转换往往成为 bug 源头。文件放在 file/feature 下,后续 model.py 直接用read_pickle读回,省去重复加工。

4. 训练 lightGBM 分类器:参数怎么设,模型文件怎么留

4.1 选型理由:表格特征场景,树模型比深度网络更顺手

量化选股的特征基本上都是表格数据,行数是交易日,列是量价派生特征。这类场景里 lightGBM 是优先级靠前的选择:训练速度快,几万行数据在 CPU 上几十秒能出结果;自带对缺失值的处理,不需要提前做复杂的填充;支持特征重要性输出,方便后续删减无效特征。对比深度学习,树模型还有个实际优势是调参维度少,即使参数不全优,默认值也不会差到完全不能用。

这套源码把模型训练放在 model.py,最终模型保存为 file/model.lgb.txt。保存成 txt 而不是二进制.model,对复盘很友好:模型文件是文本格式,可以直接打开看树结构、叶子节点权重,也能方便地 diff 两个版本的差异。生产环境为了加载速度可能用二进制,但这个 demo 保留 txt 本身就是教学意图。

4.2 训练流程:标签、数据切分、参数与早停

训练环节的代码我拆成标签生成、切分、训练三块讲,因为这三个环节的错误,比参数没调好更容易导致回测失真。

# model.py 的核心逻辑:读特征、造标签、按时间切分、训练保存 import lightgbm as lgb import pandas as pd feature_df = pd.read_pickle('file/feature/feature.pkl') feature_df['trade_date'] = pd.to_datetime(feature_df['trade_date']) # 标签:未来 5 日收益为正则记为 1 feature_df['future_ret'] = feature_df.groupby('code')['close'].shift(-5) / feature_df['close'] - 1 feature_df['label'] = (feature_df['future_ret'] > 0).astype(int) feature_cols = ['ret', 'bias20', 'vol_ratio'] # 实际请补足你在特征阶段生成的全部列 feature_df = feature_df.dropna(subset=['label']) # 按时间切分,杜绝用未来数据训练 last_date = feature_df['trade_date'].max() cutoff = last_date - pd.Timedelta(days=120) train_df = feature_df[feature_df['trade_date'] <= cutoff] valid_df = feature_df[feature_df['trade_date'] > cutoff] params = { 'objective': 'binary', 'metric': 'auc', 'learning_rate': 0.05, 'num_leaves': 31, 'feature_fraction': 0.8, } d_train = lgb.Dataset(train_df[feature_cols], train_df['label']) d_valid = lgb.Dataset(valid_df[feature_cols], valid_df['label']) model = lgb.train(params, d_train, num_boost_round=500, valid_sets=[d_valid], early_stopping_rounds=50) model.save_model('file/model.lgb.txt')

标签生成用groupby('code')再shift(-5),是为了只拿同一支股票的“未来 5 个交易日”。shift 在原始行情上是按行移动,如果不分组,上一支股票的最后 5 行会和下一支股票的前 5 行拼出一个假未来。切分这里我特意留了最后 120 天做验证集,而不是随机打乱——时间序列随机切分是建模里最典型的错误,模型可能在训练时已经见过验证集的邻居。一个默认可跑的参数组合如下:

参数示例值作用
objectivebinary二分类,标签为未来收益正负
metricauc评价排序能力,比准确率更适合不平衡样本
learning_rate0.05学习率,越小越稳但需要更多轮数
num_leaves31控制树复杂度,过大容易过拟合
feature_fraction0.8每棵树随机抽 80% 特征,增加多样性
num_boost_round500最大迭代轮数,配合早停使用
early_stopping_rounds50验证集指标连续 50 轮不提升则停止

这些参数不是最优解,但足够完成第一版闭环。刚开始调参时不要一上来搜索几百组,先固定这批参数跑通,再单独动 learning_rate 和 num_leaves。如果训练集 AUC 和验证集 AUC 差距很大,优先减少 num_leaves 或调小 feature_fraction,而不是加更多轮数。

训练完记得看两个东西:验证集 AUC 有没有明显高于 0.5,以及模型输出的特征重要性。如果某一个特征的重要性占比异常高,比如只有 bias20 一家独大,要怀疑这个特征的构造里是否包含了未来信息,或者它只是在股票池失效时的幸存者。我一般会在 model.py 后加几行打印特征重要性,做成习惯,而不只盯着最终 AUC 数字。

5. 回测与常见问题排查:三指标怎么算,坑在哪

5.1 回测记录与三指标计算逻辑

回测是裁决模型能不能用的环节。backtest.py 的职责是模拟每天的组合动作:用训练好的模型对全市场股票打分,按分数买入得分最高的股票,然后记录当天的实际收益,逐日累计写入 file/record.csv。每行代表一天的结果,包含哪个 code 被买入、当天收益率、可能还有前一日持有的标记。这样的设计本质上和 backtrader 这类多股回测框架做的事一样,只是用最小代码量把底层数据流显式暴露出来。

三指标的计算逻辑,核心代码如下:

# backtest.py 中指标计算的伪代码,完整逻辑以源码为准 import pandas as pd record = pd.read_csv('file/record.csv') record['trade_date'] = pd.to_datetime(record['trade_date']) record = record.sort_values('trade_date') # 净值从 1 开始,逐日累乘 record['nav'] = (1 + record['daily_return']).cumprod() record['drawdown'] = record['nav'] / record['nav'].cummax() - 1 total_return = record['nav'].iloc[-1] - 1 max_drawdown = record['drawdown'].min() sharpe = record['daily_return'].mean() / record['daily_return'].std() * (252 ** 0.5) print(f'累积收益: {total_return:.2%}') print(f'最大回撤: {max_drawdown:.2%}') print(f'夏普率: {sharpe:.2f}')

逻辑顺序我建议记牢:先排序,再算净值,最后回撤。daily_return 是每一天策略组合的实际收益,已经是十进制小数(0.01 表示 1%),所以 nav 累乘用 1 + rate 的写法。最大回撤这里的 drawdown 是负数,取 min 得到最深的坑;如果画净值曲线,回撤就是曲线从高点到后续低点的落差。夏普率用日收益率均值除以日收益率标准差再乘 252 的平方根,252 是 A 股一年大约的交易天数;这个指标衡量的是“每承担一份波动换来多少收益”,不是越高越好,要跟最大回撤放一起看:一个夏普 2 但回撤 40% 的策略,和夏普 1.2 但回撤 15% 的策略,实际持有人体验完全不同,后者可能更容易拿住。

回测环节还有一层容易忽略的口径:调仓频率。demo 是每天打分、每天换仓的模式,换仓意味着手续费和滑点。backtest.py 的第一版通常不含成本,这样得到的收益是理想摩擦为零的收益。看回测时先别急着兴奋,我会把累积收益和换手次数连起来算一笔账,这个放到最后一张做。

5.2 常见问题排查:现象、原因与处理

以下五条,全部来自我自己跑这类源码的血泪经验,按出现频率排序。

1. 训练集 AUC 很高,回测净值却一路阴跌。现象:训练和验证阶段指标都过得去,但 record.csv 里的实际收益惨不忍睹。原因:最常见的是特征泄漏,比如用未来 N 日数据构造了某个特征,或者切分时没有按时间切。解决:回到第 3、4 章的逻辑,检查每一个特征是否只用了 T 日及之前的数据,并确认训练集和回测区间在时间上严格不重叠。

2. record.csv 里出现大段 NaN 或只有少数几天有值。现象:回测跑完,记录文件里很多行是空值,净值曲线中断。原因:股票停牌、上市日期晚于回测起点,或者数据文件本身不完整,导致回测日期配对失败。解决:在 data 阶段就剔除长期停牌标的,回测循环里遇到 NaN 行情跳过或沿用最近收盘价,不要让 NaN 参与净值累乘。

3. 最大回撤算出来接近 0,怎么看都不对。现象:三指标里回撤数字几乎为 0,但净值曲线明显有大坑。原因:drawdown 计算前没有按 trade_date 排序,或者 daily_return 列取错,把原始股价当成了收益率。解决:先看代码里 nav 是否从 1 开始、是否累乘,确认 daily_return 是当日涨跌幅而不是收盘价数值,排序后再算 cummax。

4. 回测赚钱,模拟盘却差得远。现象:回测年化收益可观,实盘或模拟盘表现大幅缩水。原因:没有计手续费和滑点,假设每天卖出再买入,实际佣金和价格冲击很快把收益吃掉。解决:在收益上扣掉双边成本,佣金加印花税按万五到千一量级,每次换仓扣一次;模型换手越频繁,成本越不可忽视。

5. 十支股票的回测结果波动极大,每次跑都不一样。现象:新增或替换一两支股票,累积收益就从正变负。原因:股票池太少,样本代表性不足,模型学到的是个别股票的噪声规律。解决:扩大 stock_list,并增加行业分散度;如果训练成本可控,还可以把买一支持仓改成买得分最高的前五支等权组合,单票波动对净值的影响立刻降低。

这五条里,第一条和第四条直接决定回测有没有参考价值,其余几条决定你排错的方向。遇到诡异结果时,我习惯从数据流动的方向逐层排查:文件有没有、特征对不对、标签有没有泄漏、回测有没有算成本,而不是一上来就改模型参数。

6. 进阶:给策略加成本与滚动训练,让回测结果更可信

6.1 滚动训练与交易成本:回测可信度的两个基本盘

把 demo 往实盘方向推进,我不会先加更复杂的模型,而是先做两件朴素但决定生死的事情:把交易成本写进回测,把一次性训练改成滚动训练。

交易成本落地很简单,每次换仓时从当日收益中扣掉双边成本。比如佣金加滑点合计 0.001,那么当日净收益就不是 daily_return,而是 daily_return - 0.001,前提是当天发生了调仓;没有调仓的日子不该扣。这一步把理想收益变成可执行收益,效果往往立竿见影——模型换手越勤,净值缩水越明显,这时候你才会认真考虑降低调仓频率或者给买入增加置信度阈值。

滚动训练解决的是模型时效性问题。一次性用 2018 到 2023 的历史训练、再回测同区间,本质上是“用同一段历史既出题又答题”,容易高估模型能力。常见做法是 walk-forward:每 30 个交易日向前滚动一次,用前面一段时间训练,预测接下来 30 天,然后重复。

# 滚动训练示意:按日期切成多个阶段,逐步前移 for i in range(total_days // 30): train_slice = feature_df[feature_df['trade_date'] < date_windows[i]] test_slice = feature_df[ (feature_df['trade_date'] >= date_windows[i]) & (feature_df['trade_date'] < date_windows[i] + pd.Timedelta(days=30)) ] model = train_model(train_slice, params) preds[i] = model.predict(test_slice[feature_cols])

核心参数是两个窗口:回看窗口决定训练样本量,一般不低于 120 个交易日;预测窗口跟调仓周期对齐,5 日调仓就只用未来 5 天的样本验证。改成滚动训练后,回测结论更接近真实操作。

回想我做第一版策略的时候,满脑子都是把夏普率调高,结果换了 window 参数后回测曲线忽好忽坏,后来才发现问题根本不在参数,而在数据切分和成本口径。从那以后,我每次拿到这类源码,第一步永远是跑通最小数据闭环,第二步加成本看真实收益,第三步才谈调参。希望这份拆解能帮你在量化这条路上少踩几个我踩过的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询