ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python机器学习量化投资策略源码拆解:从特征工程到LightGBM回测

Python机器学习量化投资策略源码拆解:从特征工程到LightGBM回测 简介面向量化投资与机器学习实践者这是一个可运行的项目源码演示了从行情数据获取到策略回测的完整流程。项目围绕梯度提升树模型展开涵盖数据抓取、特征工程、模型训练与历史回测四个核心环节可输出每日建议买入的股票及对应收益并计算累计收益、最大回撤、夏普率等常用量化指标同时提供一键执行入口、依赖安装清单和说明文档适合希望快速搭建策略原型、理解量化回测流程的开发者参考。资源包共十五个文件主体是五个脚本另有六张效果图、两个文本清单、说明文档和配置文件压缩包仅七百三十五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(parentsTrue, exist_okTrue) for code in stock_list: # fetch_daily_price 是占位函数替换成 tusare 实际拉行情返回 DataFrame 即可 df fetch_daily_price(code, start2018-01-01, end2023-12-31) if df is None or df.empty: continue df[code] code df.to_csv(data_dir / f{code}.csv, indexFalse) 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(parentsTrue, exist_okTrue) 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(dropTrue) # 涨跌幅是模型最基础的输入之一 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_indexTrue) 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(days120) 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_round500, valid_sets[d_valid], early_stopping_rounds50) 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(days30)) ] model train_model(train_slice, params) preds[i] model.predict(test_slice[feature_cols])核心参数是两个窗口回看窗口决定训练样本量一般不低于 120 个交易日预测窗口跟调仓周期对齐5 日调仓就只用未来 5 天的样本验证。改成滚动训练后回测结论更接近真实操作。回想我做第一版策略的时候满脑子都是把夏普率调高结果换了 window 参数后回测曲线忽好忽坏后来才发现问题根本不在参数而在数据切分和成本口径。从那以后我每次拿到这类源码第一步永远是跑通最小数据闭环第二步加成本看真实收益第三步才谈调参。希望这份拆解能帮你在量化这条路上少踩几个我踩过的坑。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表