机器学习量化投资策略:从特征工程到回测框架的完整实践指南
发布时间:2026/9/28 23:16:58 锦皓数字建站

简介这套量化投资策略项目面向毕业设计、期末大作业和课程设计场景适合有一定Python和机器学习基础、希望从零搭建完整策略流程的学习者。项目以股票行情数据为主线覆盖数据获取、特征工程、模型训练、策略回测与可视化分析等关键环节并配有细致的中文注释与使用说明手打获得98分导师认可度较高便于直接部署和二次开发。压缩包共15个文件核心为5个Python脚本分别承担数据接口管理、股票列表维护、特征提取、预测模型构建与回测执行等任务另含6张结果图表、1份手册文档及依赖清单整体仅1.14MB轻量易用适合在本地快速验证。目前已有286人学习下载通过该项目可完整观摩量化策略从因子构建到收益回测的落地过程获得可直接修改的数据处理模块、模型训练接口与回测框架对完成高分课程设计或入门量化研究很有帮助。1. 量化投资策略项目到底是什么先别急着换模型把地基打牢再说提到「Python 基于机器学习的量化投资策略项目」很多人第一反应是找一份高分源码改一改就交上去。我的经验是源码能跑是一回事能解释清楚为什么有效、回测口径是否自洽才是决定分数和实盘价值的关键。这类项目通常包含三块东西策略主代码、回测脚本和使用说明。真正拉开差距的不是模型用了多深的网络而是有没有处理好未来函数、数据切分和交易成本。这篇笔记按我搭建同类策略的标准流程来写先讲数据和特征再给一个直接能跑的向量化回测骨架接着把机器学习模型接进 walk-forward 流程最后列出回测里最容易让人白干三天的几个坑。适合正在做课程项目或想验证个人策略的 Python 开发者。2. 数据和特征工程量化投资策略进入机器学习前的地基机器学习算法本身并不理解“股票”是什么它只吃特征和标签。因此特征构造与标签定义直接决定了这个策略是学到规律还是学到噪声。2.1 数据源选择和后复权处理价格真实回测才可信在 A 股日线级别上做策略数据源一般从 baostock、akshare 或 tushare 里选。我一般用 baostock 拉后复权日线原因很朴素免费额度稳定字段口径统一。你也可以直接用 csv 文件作为数据入口但要注意复权方式这是第一个容易翻车的地方。不复权价格在除权除息日会留下跳空缺口直接在缺口上计算收益率会产生一段负的或者正的假收益。很多策略因子在不复权数据上会莫名失效比如均线突破信号可能在除权日被反复触发。后复权价格通过把分红送股折算回历史价格使收益率序列连续回测计算结果更接近真实交易的连续价格变化。常见量化数据接口里adjustflag 参数通常有后复权、前复权、不复权三种取值不同库的数值含义不一样跑之前先确认否则策略刚起步就输在数据上。下面的代码展示了用 baostock 获取后复权日线数据的写法import baostock as bs import pandas as pd # 登录数据源拿到一批股票日线数据 lg bs.login() rs bs.query_history_k_data_plus( sh.600000, date,open,high,low,close,amount,turn,pctChg, start_date2018-01-01, end_date2022-12-31, frequencyd, adjustflag1 # 1 表示后复权具体含义以接口文档为准 ) rows [] while (rs.error_code 0) rs.next(): rows.append(rs.get_row_data()) df pd.DataFrame(rows, columnsrs.fields) bs.logout()参数说明date到turn这些字段里amount是成交总额turn是换手率它们会作为后续特征工程的原料adjustflag1在这里取的是后复权。拿到数据后把 date 列转成 datetime 并排序然后检查是否有重复日期和停牌间隙。停牌期间没有真实交易不应该用前收盘价填充后强行参与回测。数据清洗上我习惯保留涨跌停标记而不是直接删除这些行。涨停时买不进、跌停时卖不出这是 A 股回测里必须被建模的现实约束。你可以用当日收盘价相对前收盘价的比例来标记涨跌停状态后面接入回测框架时再做过滤。2.2 特征工程从量价数据构造行为特征而不是喂裸 OHLC直接把 open、high、low、close 四列原始价格喂给模型是新手最容易犯的错误。原因有两个不同股票的价格区间差异很大模型会把绝对价格当成信号价格本身非平稳训练窗口一拉长分布就变了。正确做法是把原始价格转换成相对变化、波动率、成交量异常等行为特征。我常用的特征是下面这四类组合对日线数据足够形成一套初步策略短长均线比ma5 / ma20反映短期趋势相对长期趋势的强弱。量能 z-score当日成交额相对过去 20 日均值和标准差的距离用来识别放量或缩量。价格位置收盘价在近 20 日最高最低价区间里的位置取值 0 到 1 之间。波动率过去 20 日收益率的滚动标准差用来给风险状态分层。一个最小实现如下def make_features(df, short_win5, long_win20): df df.copy() df[ret] df[close].pct_change() df[ma_ratio] ( df[close].rolling(short_win).mean() / df[close].rolling(long_win).mean() ) df[vol_z] ( (df[amount] - df[amount].rolling(long_win).mean()) / df[amount].rolling(long_win).std() ) df[pos] ( (df[close] - df[low].rolling(long_win).min()) / (df[high].rolling(long_win).max() - df[low].rolling(long_win).min() 1e-9) ) df[volatility] df[ret].rolling(long_win).std() return df.dropna()参数说明short_win5和long_win20分别对应一周和一个月的回看窗口。这个大小不是越大越好日线策略里长窗口取 20 到 60 都比较常见。dropna()会把前 20 个没有完整特征的交易日删掉这意味着回测真正开始的日期会往后推 20 个交易日属于正常现象。特征构造顺序本身也有讲究。所有滚动统计只能使用当前时刻之前的数据pandas 的 rolling 默认就是窗口内到当前为止的历史数据不包含未来值这一点可以放心。但如果用了rolling(...).mean().shift(-1)这类操作就是把未来数据搬进特征里了后面会专门讲怎么排查。2.3 标签定义和时间切片训练集与测试集不能用随机打散监督学习必须有标签。量化策略里最常见的标签是“未来 n 日收益的方向”例如未来 5 个交易日收益率是否大于 0。构造标签需要小心一个细节如果直接用close.pct_change(5)然后 shift很容易把未来收益率算到当前 bar 上形成未来函数。下面这段代码是比较稳妥的标签构造方式horizon 5 df[label] ( df[close].shift(-horizon) / df[close] - 1 0 ).astype(int) # 删除最后 horizon 行因为它们没有完整的未来收益 df df.iloc[:-horizon]说明shift(-horizon)取未来第 5 日的收盘价除以当前收盘价并减 1得到未来 5 日收益率最后 5 行因未来数据不完整而被移除。如果标签定义为“未来 5 日涨幅是否超过 1%”只需把阈值从 0 改成 0.01同时 label 的正样本比例会降低模型训练时需要自行调阈值。数据切分也不能照搬 sklearn 的 train_test_split 随机打散。时间序列样本之间前后依赖随机打散会造成同一段行情既出现在训练集又出现在测试集等于让模型开卷考试。我一般按日期切分def split_by_date(df, train_end, test_start): train df[df[date] train_end] test df[df[date] test_start] return train, test此外训练集和测试集之间要留一段空白。比如训练集用到 2021 年底测试集从 2022 年 3 月开始中间留两个月的“安全区”可以避免因 horizon 长度导致标签跨边界泄漏。提示如果你的数据包含多只股票切分前必须确保同一时间点上所有股票样本处于同一边界否则会出现前一只股票的未来数据混入后一只股票的测试区间。3. 用 Python 把机器学习模型接回回测框架给一个能跑的骨架模型训练完之后还需要把预测结果转换成交易信号并回测成收益曲线。很多人把精力全花在调模型上结果回测代码里一个 shift 用错努力全白费。3.1 为什么先做向量化回测而不是直接上事件驱动框架事件驱动框架比如 backtrader 的 cerebro 或者自己写逐 bar 循环真实感强能精细模拟撮合、滑点和资金管理但开发成本明显更高。对日线级别的单标的策略向量化回测完全够用。向量化回测的核心是用 pandas 的 shift 和 cumprod 把整个净值曲线一次算出来逻辑清晰、便于检查。我的工作流是先用向量化回测验证策略逻辑是否成立再决定要不要搬到更重的框架。直接跳到 backtrader 会碰到事件循环、订单状态等一堆和策略无关的复杂度对课程项目或者新人验证思路来说性价比不高。3.2 向量化回测的参考实现信号滞后、手续费和滑点假设前一步模型产出了一个 signal 列取值是 1 表示满仓做多、-1 表示空仓或做空、0 表示空仓。把它接进一个最简回测函数import pandas as pd import numpy as np def vectorized_backtest(df, signal_col, fee1e-3, slippage1e-3): df df.copy() # 信号在 t 日收盘后产生最早也只能在 t1 日执行 df[position] df[signal_col].shift(1).fillna(0) df[market_ret] df[close].pct_change().fillna(0) # 换仓量只有仓位变化的部分才产生交易成本 df[turnover] df[position].diff().abs().fillna(0) df[cost] df[turnover] * (fee slippage) df[strategy_ret] df[position] * df[market_ret] - df[cost] df[nav] (1 df[strategy_ret]).cumprod() return df代码逻辑拆开看shift(1)是回测里最容易理解也最容易忽略的一步。它强制让当天收盘后产出的信号到明天才真正生效。如果不 shift直接用当天的 position 乘当天收益率等于用收盘信号参与了当天价格变动属于典型的未来函数。position.diff().abs()统计仓位变化。从 0 变成 1 产生一次开仓成本从 1 变回 0 产生一次平仓成本这比每天按持有市值扣费要合理得多。fee 和 slippage 的单位是比例默认各设千分之一已经比多数真实账户保守。多维度的机器学习模型接入时你可以再单独定义一个生成信号的函数def signal_from_proba(prob, buy_threshold0.55, sell_threshold0.45): signal pd.Series(0, indexprob.index) signal[prob buy_threshold] 1 signal[prob sell_threshold] -1 return signal参数说明buy_threshold和sell_threshold之间是一个死区。预测上涨概率不到 55% 不动低于 45% 才做空。这个死区能显著降低交易频率避免模型一有风吹草动就反复换仓。阈值不是对称的如果你发现策略交易太频繁就把 buy 调到 0.6sell 调到 0.4。3.3 滚动训练与预测用 walk-forward 替代标准 K 折交叉验证时间序列场景里我坚持用 walk-forward不用 K 折。理由是 K 折会把后面的样本折到训练集里模型提前看到未来。walk-forward 的做法是每次只用过去固定长度的交易日样本训练预测紧接着的一小段未来数据然后向前滚动。一个可运行的最小骨架from sklearn.ensemble import GradientBoostingClassifier feature_cols [ma_ratio, vol_z, pos, volatility] def walk_forward(df, feature_cols, train_days756, test_days126): df df.sort_values(date).reset_index(dropTrue) pred_proba pd.Series(indexdf.index, dtypefloat) for start in range(train_days, len(df), test_days): train df.iloc[start - train_days : start] test df.iloc[start : start test_days] if len(test) 0: break model GradientBoostingClassifier( n_estimators200, max_depth3, learning_rate0.05, subsample0.8, random_state42 ) model.fit(train[feature_cols], train[label]) pred_proba.iloc[test.index] model.predict_proba( test[feature_cols] )[:, 1] return pred_proba这段代码的关键点在于切片边界train是当前 start 之前train_days行test是紧接着的test_days行。训练集和测试集之间没有任何重叠而且测试集只有test_days行长度即使标签窗口有 5 日跨度也不会把训练样本的标签漏进测试集。参数选择上train_days756大约对应 3 年交易日test_days126大约半年。样本量小的时候可以把 train_days 缩短到 500但要注意模型容易过拟合样本量充足时训练窗口长一点对模型稳定性更有利。多股回测时还有一个组合层的事不要把所有股票的信号拼接在同一根日线上直接算单一净值。常见做法是每只股票单独跑一遍向量化回测得到各自的净值序列再按等权合成组合净值这样才能避免因为某只股票涨跌停导致整体资金被占用的问题。4. 回测避坑清单收益虚高、过拟合与手续费哪一条都能让你白干三天这一章专门用来排雷。如果回测收益看起来过于漂亮先别高兴多半是这几个地方出了问题。4.1 未来函数回测年化 30%模拟盘就翻车现象回测里策略收益非常稳定最大回撤小得离谱但一上模拟盘或者拉近期数据一跑曲线立刻变形。原因特征或信号里使用了当天才能知道的信息去参与当天价格的收益计算。最常见的两种写法一是没对信号做 shift直接用当日收盘信号乘当日收益率二是标签构造时 horizon 跨过了训练测试切分点导致模型偷看了未来。解决方法分三步。第一步回测代码里强制signal.shift(1)这一行就能挡掉大部分未来函数。第二步检查特征列里有没有shift(-n)或rolling(n).mean()后接shift(-1)的操作这些都是在向特征里灌未来数据。第三步可以做一个快速验证把信号列打乱成随机数再跑一次回测如果收益依然很高说明回测框架本身就有数据泄漏而不是策略真有能力。提示未来函数的表现不一定都是高收益。有些时候它会让回测结果起伏剧烈因为模型学会了“预知”大波动后再行动。4.2 过拟合训练集准确率 0.82样本外准确率 0.51现象模型在训练段表现优秀在 walk-forward 测试段上接近抛硬币换一组时间段结果又完全不一样。原因特征数量多、样本数量少、模型复杂度过高或者调参时看了太多测试集结果。尤其是用网格搜索反复选择参数本质上是把测试集信息一点点泄露进了模型选择过程。解决方案我一般用两条硬约束。一是特征数量不超过训练样本量的十分之一如果手上有 50 个候选特征先做相关性筛选把相关系数超过 0.8 的去掉一组再进入模型。二是把 walk-forward 的测试段藏起来只在所有参数确定后才跑一次在调参阶段用训练集内部再切一个验证集验证集和测试集必须严格分离。还有一个更粗糙但有效的办法固定随机种子改变训练集的起始日期多跑几遍看看指标方差是不是大到没法接受。4.3 手续费、印花税与涨跌停成本抠不干净策略等于自欺现象回测里的换手率很高但收益依然很强把手续费从万 2 改到千 1收益直接腰斩。原因策略逻辑本身高频交易赚的其实是价格波动但每次换仓的摩擦成本在回测中被低估。A 股交易成本包括佣金、印花税和冲击成本单边合计通常不低于千分之一。很多回测代码只在每天扣一次固定比例费用没有按实际换仓量计算低估了交易消耗。解决方式是把费用计算改成基于换仓量。前面代码里turnover position.diff().abs()就是为此设计的。另外A 股涨停时买不进、跌停时卖不出如果回测模型在涨停日大量买入实盘根本执行不了。我习惯在数据里加一列涨停标记再对买入信号做过滤df[prev_close] df[close].shift(1) df[limit_up] (df[close] df[prev_close] * 1.095) # 当天涨停则次日不追买信号置为 0 df.loc[df[limit_up], signal] 0参数说明这里1.095按 10% 涨跌停估算实际带 ST 和科创板会有不同标准用之前要按股票类型调整。涨停标记要 shift 一天再使用因为“当日涨停”也是收盘后才确定的信息。4.4 把行情上涨误当成策略能力缺少基准对比现象策略净值在上涨但同期沪深 300 涨得更多回测报告却只写了策略年化没提超额收益。原因策略持仓天然带有市场风险回测期间如果恰逢大牛市任何“买入持有”都能赚钱。只看绝对收益无法区分策略能力与市场环境。解决方法是固定一条基准线比如沪深 300 指数或同期的 wind 全 A计算超额净值。简单做法是在回测末尾用指数做归一化对比bench df[close] / df[close].iloc[0] strategy df[nav] excess strategy / bench - 1excess 序列仍然只有一条净值曲线但它能让人一眼看出策略有没有跑赢基准。如果 excess 是持续向下的说明策略的持仓风格在市场环境面前并没有优势只是 Beta 在赚钱。5. 高分项目验收使用说明、滚动验证和组合层评估技巧一个项目能不能拿高分很大程度上取决于验收的人能否快速复现。写使用说明时与其写一份逐行解释的流水账不如把入口和配置写成交互命令让执行者在几分钟内跑完训练、回测和评估。我建议 README 里固定一个统一的入口文件支持命令行参数。比如python run_backtest.py --train_start 2019-01-01 --train_end 2021-12-31 \ --test_start 2022-01-01 --test_end 2022-12-31 --model gbdt这样验收的人无需修改代码就能换时间段也方便你快速做多区间验证。为了可复现固定所有随机种子并把数据源版本和依赖版本写进 requirements.txt这比在说明文档里写“直接运行 main.py”要可靠得多。验证策略时我习惯每次多跑三个不同时间段而不是只跑一个完整年份。比如把样本分成 2019、2020、2021 三个年份分别做 walk-forward看收益和最大回撤的稳定性差异。如果某一年特别强、其余年份都一般我会把它判成过拟合而不是亮点。这种判断方式比任何单一夏普比率都诚实。最后给自己的项目加一道工序把每次回测的换仓明细导出到 csv检查有没有在涨停日开仓、有没有单日换手率超过 50% 的极端情况。我自己的血泪经验是某次“收益稳定”的策略最后查出每天满仓进出十几次全靠手续费为零才没暴露问题。从那以后每次跑完回测都先看最近 20 个交易日的持仓变化再做净值归因。这个习惯让我避开了很多看似高大上、实际没法落地的策略。如果你的目标是拿高分记住一点评估者最看重的不只是收益而是你有没有一套自洽的验证逻辑。把回测口径、未来函数防御、成本模型写清楚比一个神经网络的调参过程更打动人。希望这些踩坑记录能帮你少走几段弯路也希望你真正跑通后愿意把这段过程整理成自己的笔记。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。