资讯动态

机器学习量化策略demo解析:特征工程与回测陷阱

发布时间:2026/10/14 19:44:28 来源:尧图企业网站定制
简介这是一份基于机器学习的量化投资策略演示程序面向有一定编程基础、想了解如何把人工智能技术用于炒股决策的入门人群。资源以中国股票市场为实际案例完整展示了从获取历史价格和交易量数据、清洗与特征构建、训练机器学习模型到最终进行策略回测评估的量化研究流程。压缩包内含十四个文件包括五个代码脚本、六张图表、两份文本说明及环境依赖配置整体体积仅约七百多千字节轻量且便于本地运行。其中代码模块依次承担数据读取、特征工程、模型训练、回测计算和主流程调度图片则直观呈现了价格走势、最大回撤、决策树预测涨幅等关键可视化结果帮助对照理解策略表现。该源码自带运行环境说明已有156人学习下载适合作为学习机器学习量化投资的第一份动手资料也可作为后续扩展更复杂策略的基础框架。1. 基于机器学习的量化投资策略demo能跑通但别指望它直接赚钱拿到一份「基于机器学习的量化投资策略demo源码」大多数人的第一反应是跑一遍、看收益曲线然后问为什么实盘不是这样。这个反应我见过太多次。这份demo的价值不在曲线多漂亮而在于它把机器学习应用流程完整走了一遍数据获取、特征工程、模型训练、回测评估。它适合两类人——还没完整写过一条量化流水线的初学者以及想从源码里摸清特征和回测边界的中级玩家。如果你期待的是一站式的量化投资助手demo至少能告诉你那类信号是怎么算出来的但要说结论demo能跑通、能复现离可实盘还差一个工程化距离。下面从数据开始把它一层层拆开讲。2. 数据获取与特征工程先把喂给模型的东西做对2.1 数据源选型先跑通日线再考虑分钟级这个demo里最不性感但最影响结果的部分是数据。不少人在数据源上反复纠结这个库免费、那个库字段全、另一个有复权因子。我的建议很直接——demo阶段用能稳定拿到日线前复权的免费接口就够先跑通流程再上分钟级。机器学习中的数据处理在量化场景里就是从一段行情变成一张特征矩阵的过程源头错了后面的模型和回测全是白搭。常见做法是先把原始行情落盘成 parquet 或 csv避免每次跑脚本都重新请求接口。下面这段是拉取单只股票日线并落盘的脚本import akshare as ak import pandas as pd from pathlib import Path def fetch_daily(stock_code: str, start: str 20190101, end: str 20241231) - pd.DataFrame: df ak.stock_zh_a_hist( symbolstock_code, perioddaily, # 日线demo 阶段够用 start_datestart, end_dateend, adjustqfq, # 前复权避免除权除息造成的价格跳空 ) # 接口返回中文列名统一重命名成英文后面写特征不折腾 df.columns [date, open, close, high, low, volume, amount, amplitude, pct_change, change, turnover] df[date] pd.to_datetime(df[date]) return df[[date, open, high, low, close, volume, amount, turnover]].sort_values(date) out_dir Path(./data/raw) out_dir.mkdir(parentsTrue, exist_okTrue) df fetch_daily(600519) df.to_parquet(out_dir / 600519.parquet, indexFalse) print(df.tail())这段代码的逻辑不复杂通过接口拿全字段统一列名、转日期、排序、落盘。参数里最值得说的是adjustqfq。不复权的话股票除权当天会出现一根突兀的假阴线模型会把它当成真实波动学进去前复权把历史价格按最新股本折算价格序列才连续。另一个容易踩的点是start_date和end_date接口要求 YYYYMMDD 格式传成带横杠的日期会直接报错。数据层面还有个常见误用直接拿接口原始数据开跑不检查缺失值和停牌日。停牌期间有的接口返回空记录有的返回上一交易日收盘价的重复行两种都会污染成交量类特征。我一般会在落盘前加一句df df.dropna(subset[close])并把成交量全零的行单独标记而不是默默删掉——停牌信息本身对策略有用只是不能直接喂进特征。2.2 特征构造动量、波动率、成交量异动特征决定模型的天花板。线性模型、树模型、神经网络都只是在不同程度逼近同一个上限而这个上限是特征画出来的。量化里最常用的一类特征是动量即过去 N 日的收益第二类是波动率通常是收益序列的滚动标准差第三类是量价配合比如当日成交量相对过去 20 日均量的倍数。下面这段代码把三类特征一次性算出来import pandas as pd import numpy as np def build_features(df: pd.DataFrame, close_col: str close) - pd.DataFrame: df df.copy() close df[close_col] # 动量类过去 5/10/20 日收益 df[ret_5] close.pct_change(5) df[ret_10] close.pct_change(10) df[ret_20] close.pct_change(20) # 波动率类过去 20 日日收益的滚动标准差 df[vol_20] close.pct_change().rolling(20).std() # 量比当日成交量相对 20 日均量的倍数 if volume in df.columns: df[volume_ratio] df[volume] / df[volume].rolling(20).mean() # 价格相对均线的偏离度 df[ma_bias] (close - close.rolling(20).mean()) / close.rolling(20).mean() return df.replace([np.inf, -np.inf], np.nan) df fetch_daily(600519) feature_df build_features(df) print(feature_df[[ret_5, ret_10, ret_20, vol_20, volume_ratio, ma_bias]].tail())每个特征都对应一个现实直觉ret_20捕捉中期趋势vol_20反映市场分歧度volume_ratio大于 2 往往对应放量突破或恐慌抛售ma_bias描述价格相对均线的拉伸程度。需要理解的是pct_change和rolling的对齐方式——pandas 默认右对齐当前行的值只用到当前及之前的数据这正好满足回测里只用过去信息的底线。最后一行replace([np.inf, -np.inf], np.nan)是必须的滚动窗口里除零会产生 inf不清理掉后面喂给 LightGBM 会直接报错。2.3 label 怎么打预测方向比预测点位靠谱特征算完下一步打标签。量化里 label 的常见做法有两种回归任务直接预测未来 N 日收益分类任务预测未来 N 日收益是否超过阈值。从我的实践看demo 阶段用分类更稳模型输出概率可以直接当信号强度用对 label 噪声的容忍度也更高。预测具体点位基本是玄学别在这个方向浪费时间。# label未来 5 日收益是否大于阈值二分类 horizon 5 df[future_ret] df[close].shift(-horizon) / df[close] - 1 df[label] (df[future_ret] 0.02).astype(int) # 阈值 2%过滤小波动噪音 # 去掉最后 horizon 行它们的未来收益不存在 df df.dropna(subset[future_ret])打 label 最容易翻车的是shift的方向。shift(-horizon)把未来第 5 天的收益拉回今天这一行这是对的写成shift(horizon)就是在用历史数据预测过去回测曲线漂亮得不像话实盘一碰就碎。阈值 0.02 不是固定的它决定正负样本平衡阈值越高正样本越少模型越倾向输出 0。我会先看一眼df[label].mean()让正样本比例落在 0.3 到 0.5 之间太偏就调阈值而不是让它自己跑偏。3. 模型选型与训练为什么 LightGBM 是量化 demo 的默认答案3.1 模型对比树模型在这个场景下的压倒性优势特征矩阵通常是几百行到几千行的表格数据这种场景下深度学习反而是劣势。下面是常用模型的横向对比模型非线性捕捉训练速度小样本鲁棒性demo 推荐度逻辑回归弱快中等可作基线随机森林中中较好可用LightGBM强快好首选MLP / LSTM强慢差容易过拟合起步不推荐选择模型的标准不是越先进越好而是迭代效率。量化特征是表格数据LightGBM 这类梯度提升树天然处理缺失值、对特征尺度不敏感还自带特征重要性方便你快速判断哪些因子在起作用。LSTM 看着浪漫但在日线级别的几百个样本上过拟合概率极高调试一轮的时间够 LightGBM 跑几十组参数了。深度学习可以留到你有分钟级数据和足够样本量之后再试。3.2 walk-forward 训练与验证代码时间序列场景不能随机打乱数据切训练验证集必须按时间顺序切分否则就是变相的数据泄漏。常见做法是用TimeSeriesSplit做 walk-forward 验证from sklearn.model_selection import TimeSeriesSplit import lightgbm as lgb feature_cols [ret_5, ret_10, ret_20, vol_20, volume_ratio, ma_bias] X feature_df[feature_cols].values y feature_df[label].values # walk-forward按时间切 5 折每一折用过去 80% 训练、未来 20% 验证 tscv TimeSeriesSplit(n_splits5, gaphorizon) params { objective: binary, metric: auc, learning_rate: 0.03, # 小学习率配合多轮迭代更稳 num_leaves: 31, # 叶子数控制模型复杂度 max_depth: 4, min_child_samples: 50, # 每个叶子最少样本数防过拟合 feature_fraction: 0.8, # 每轮随机抽 80% 特征 bagging_fraction: 0.8, # 每轮随机抽 80% 样本 bagging_freq: 1, verbose: -1, seed: 42, } for fold, (train_idx, val_idx) in enumerate(tscv.split(X)): train lgb.Dataset(X[train_idx], labely[train_idx]) val lgb.Dataset(X[val_idx], labely[val_idx], referencetrain) model lgb.train(params, train, num_boost_round500, valid_sets[val], callbacks[lgb.early_stopping(50)]) print(ffold {fold}: val auc {model.best_score[valid_0][auc]:.4f})关键点是gaphorizon。训练集和验证集之间要留出 label 的跨度否则训练集最后一个样本的 label未来 5 日收益会落到验证集的时间范围内造成轻微泄漏。num_leaves31、max_depth4、min_child_samples50这一组是对量化小样本比较稳的起点如果你的验证集 AUC 波动很大优先调大min_child_samples到 100 以上而不是去加特征。3.3 过拟合的早期信号训练集和验证集的剪刀差模型训完第一件事不是看收益而是看验证集 AUC 和训练集 AUC 的差距。如果训练集 AUC 0.75、验证集只有 0.52这就是过拟合的早期信号后面回测再好看都是幻觉。解决办法按优先级排减小num_leaves、增大min_child_samples、降低learning_rate并配合更早的 early stopping、减少特征数量。另一个值得养成的习惯是看特征重要性。LightGBM 的model.feature_importance(gain)会告诉你收益主要来自哪几个特征。如果某个月度动量特征贡献了 80% 以上的 importance这个策略本质上就是在赌单一动量因子模型只是给因子加了一层包装。对 demo 来说这不算错但你心里要有数策略的鲁棒性来源于特征之间的互补而不是单点依赖。4. 回测框架搭建别让未来函数和幸存者偏差偷走收益4.1 向量化回测的写法与局限回测是量化里最容易自我欺骗的环节。向量化回测用 pandas 对整个时间序列做矩阵运算代码短、速度快适合 demo它最大的局限是难以精细建模涨跌停排队和分批成交。下面是最小可用的回测核心# 信号模型输出的概率 0.6 则持有否则空仓 pred_prob model.predict(X) # 假设已经完成 walk-forward 预测 signal (pred_prob 0.6).astype(int) # 关键信号 shift(1)T 日收盘算出的信号T1 日才生效 position signal.shift(1).fillna(0) daily_ret position * df[close].pct_change() equity (1 daily_ret).cumprod()signal.shift(1)这一行是回测的生死线。T 日收盘后才能拿到当天的收益和特征进而算出信号最快也只能在 T1 日开盘成交如果你直接用当天的信号配当天的收益就是用未来信息交易回测里赚的每一分钱都是未来函数偷来的。向量化回测里没有显式的买卖点只有一个仓位序列这是它的简化之处——它假设你能按收盘价无缝切换仓位实盘达不到这个理想状态。提示如果想更贴近实盘可以记录每个调仓日的 signal 变化点单独生成一张交易清单再乘以手续费和滑点。4.2 手续费、滑点和涨跌停三个必须显式建模的参数demo 回测跑出漂亮曲线后第一件事永远是加上交易成本再看一遍。不加成本的 A 股日线策略年化收益被高估 10% 到 30% 都是正常的。下面是三个必须设的参数参数常见取值影响手续费双边万 2.5 ~ 万 3卖出另加印花税千 1调仓越频繁影响越大滑点1 ~ 2 个 tickA 股最小变动 0.01 元把理想成交价往不利方向推涨跌停主板 ±10%ST ±5%创业板/科创板 ±20%信号日可能买不进、卖不出实操上不用把成本模型做得很复杂按成交金额扣一次即可cost_rate 0.0003 # 手续费万 3含滑点估计 trade_points position.diff().fillna(position).abs() net_ret daily_ret - trade_points * cost_rate equity_with_cost (1 net_ret).cumprod()position.diff()算出仓位变化的位置在变化的那一天扣除成本。这个公式把滑点折算进了手续费里虽然粗糙但比完全忽略成本诚实得多。习惯上我会同时看两个版本零成本的理想曲线和含成本的净值曲线两条线的差距就是交易成本对你策略的真实威胁。4.3 评估指标年化、回撤、夏普、盈亏比怎么读回测跑完别只看累计收益至少算四个指标import numpy as np years len(df) / 252 annual_ret equity.iloc[-1] ** (1 / years) - 1 max_drawdown (equity / equity.cummax() - 1).min() sharpe daily_ret.mean() / daily_ret.std() * np.sqrt(252) win_trades ... # 根据交易清单统计 print(f年化: {annual_ret:.2%}, 最大回撤: {max_drawdown:.2%}, 夏普: {sharpe:.2f})我的读法是夏普小于 1 的策略不值得投入更多时间1.2 到 1.5 是可接受的 demo 目标大于 2 的日线策略要警惕是不是哪里写错了最大回撤比年化更重要30% 回撤的策略年化再高实盘你也很难拿住。还有一个容易漏的对照把同期沪深300指数的累计收益画在同一张图上你的策略年化 15%、回撤 25%但指数同期涨了 20%那这个模型实际上没有创造超额收益只是被动吃了 beta。5. 量化策略 demo 的常见坑与排查五个翻车现场5.1 回测曲线漂亮得不像话实盘一碰就碎数据泄漏现象回测年化 80%最大回撤不到 5%曲线平滑得像教科书。原因数据泄漏。最常见的是打 label 时shift方向搞反或者对全样本做了归一化——用未来数据计算均值和标准差再回填到历史特征里。解决自查三步。第一确认特征只包含 T 日及之前的数据第二确认 label 用的是 T 日之后的收益第三归一化必须在训练集上 fit、验证集上 transform禁止全样本一起 fit。5.2 训练报 input data is NaNAUC 异常低现象LightGBM 直接报错或者不报错但验证集 AUC 只有 0.5 上下像是随机猜。原因滚动窗口开头的若干行天然是 NaN除零产生了 inf模型要么拒绝训练要么把 NaN 当特殊值处理导致分裂质量变差。解决特征工程末尾统一执行replace([np.inf, -np.inf], np.nan)训练前dropna(subsetfeature_cols)。注意不要用全局df.dropna()那会把 label 缺失的尾部行也删掉影响样本分布。5.3 训练集 AUC 0.75验证集 AUC 0.52剪刀差现象训练和验证性能差距巨大且每折的验证 AUC 忽高忽低很不稳定。原因过拟合到训练集噪声或者特征里混入了某个只在特定时间段有效的伪因子。解决先加大min_child_samples到 100、num_leaves降到 15 重训一遍如果剪刀差缩小说明是复杂度问题。如果加了正则还是不行删掉重要性最低的几个特征再看。5.4 信号第二天开盘根本按不到回测价成交现象实盘成交价总比回测差一截交易越频繁差得越多。原因回测里用 T 日收盘价成交 T 日收盘产生的信号这是标准的未来函数。另一个可能是你的数据是收盘价但实际撮合发生在开盘和盘中价差被滑点吃掉了。解决回测里统一signal.shift(1)按 T1 日开盘价成交。如果拿不到开盘价至少按 T1 日收盘价成交并在成本模型里把滑点从 1 个 tick 加到 2 个 tick。5.5 选股池只留下现在还在上市的公司幸存者偏差现象回测选股命中率很高但把开始时间往前推十年很多信号对应的股票现在已经退市了。原因回测时用了当前的股票列表退市股票被排除在样本外。这些股票往往在退市前连续下跌正是模型应该学会规避的对象。解决用历史时点的成分股列表替代当前列表常见做法是拉取指数历史成分数据按回测日期切换到当时实际存在的股票池。至少做一个敏感性测试把选股池里最差的 10% 股票手动加回去看收益曲线是否还能接受。6. 把 demo 变成可持续验证的系统滚动训练、参数存档与日志留痕demo 跑通只是起点真正有价值的是让它能持续验证。我一般会把训练改成滚动式每 20 个交易日用截至当天的全部数据重新训练一次而不是训一次用半年。这个频次对日线策略足够也不会让训练任务拖垮迭代节奏。对应的代码骨架如下import json import joblib from datetime import datetime def retrain_and_save(X, y, as_of: str): model lgb.train(params, lgb.Dataset(X, labely), num_boost_round300) # 模型和参数必须同一次落盘否则三个月后你根本不知道这份 pkl 是怎么训出来的 joblib.dump(model, f./models/lgb_{as_of}.pkl) with open(f./models/lgb_{as_of}_params.json, w, encodingutf-8) as f: json.dump(params, f, ensure_asciiFalse, indent2) return model每个模型文件都带上数据截止日期这是后悔药策略失效时你能精确回溯到是哪一天、哪组参数、哪批特征开始恶化。日志留痕同样重要每次调仓都记录时间戳、股票代码、信号概率、成交价预估和当天的特征快照排查的时候不用靠记忆猜。我个人的习惯是每次改完特征先做一个未来数据自查随机把 label 前后挪动几天重训后看 AUC 是否明显变化。如果挪了之后 AUC 还在高位说明特征里藏着未来信息回测收益是偷来的。这个动作替我挡掉了至少三次数据泄漏级别的翻车也让每份回测结果拿出来时都底气更足。这些工作都不难难的是养成习惯。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑