ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

机器学习量化策略demo实战:从数据准备到回测避坑全解析

机器学习量化策略demo实战:从数据准备到回测避坑全解析 简介一份面向有一定Python基础但初入量化投资领域的机器学习实战演示源码包以A股市场为案例系统展示数据读取、特征工程、模型训练与回测评估的完整流程帮助读者快速搭建可运行的量化策略雏形。压缩包共十四个文件包含五个Python脚本、六张可视化图表及两个文本说明整体仅七百余KB结构精简便于按模块逐步拆解学习。已有一百五十六人参与学习下载。源码覆盖量化策略的典型环节包括数据清洗、特征构造、机器学习建模与回测验证并配有股价预测、K线走势、最大回撤等图表直观呈现策略表现同时通过多算法对比和依赖清单让读者不仅理解原理还能直接修改参数、更换数据源在复现基础上加入自己的想法形成个人量化研究起点。1. 机器学习量化策略demo先搞懂它到底在赌什么很多人拿到一份“基于机器学习的量化投资策略demo源码”第一反应是赶紧跑起来看那条收益曲线。但我建议你先冷静想清楚一个问题这个demo到底在赌什么它做的事情本质上很朴素——用历史K线、成交量这些数据构造特征训练一个分类模型去预测未来一段时间价格是涨还是跌再把模型的预测结果变成买入卖出信号送进回测框架里统计收益和回撤。它能解决的需求也很具体你有一个关于“量价关系能预测涨跌”的模糊想法但不知道该怎么验证。demo源码把这些步骤串成一条可运行的流水线让你在几十分钟内看到“想法变成策略”的完整链路。适合的读者是那种Python有基础、但没完整写过量化项目的人新手跟着改参数能跑通熟手能看出数据切分和成交假设上的边界。但请记住一个反直觉的结论回测曲线漂亮不是及格线。机器学习在量化里赌的是“规律在样本外不失效”而大多数demo最大的问题不是模型不够强而是数据用错了。2. 从数据到标签把demo的第一块地基压实2.1 拉K线先解决复权问题别让除权跳空变成假特征写量化demo的第一步不是调模型而是把数据拿对。常见做法是用akshare或tushare拉日K线两个库都能免费拿到沪深股票和指数数据。tushare要注册tokenakshare不需要所以本地调试我一般用akshare。import pandas as pd import akshare as ak def load_daily(symbol000300, start2018-01-01, end2023-12-31): # 拉取指数日Ksymbol为指数代码这里用沪深300 df ak.index_zh_a_hist(symbolsymbol, perioddaily, start_datestart, end_dateend) # akshare返回中文列名先重命名成统一字段 df.columns [date, open, close, high, low, volume, amount, amplitude, pct_change, change, turnover] df df[[date, open, close, high, low, volume]] df[date] pd.to_datetime(df[date]) df df.sort_values(date).reset_index(dropTrue) return df这里有几个参数和细节需要说明。perioddaily代表日线demo阶段不要一上来就碰分钟线分钟线的数据对齐、停牌、集合竞价处理会让流程复杂好几倍。symbol000300是沪深300指数用指数做demo有个好处不存在除权除息和停牌问题特征更干净。如果你换成个股比如sh600519akshare的stock_zh_a_hist接口需要额外指定adjustqfq做前复权否则除权日会出现一个巨大的价格跳空模型会把这个跳空当成可学习的规律实盘时根本没这个规律。指数数据虽然省去复权处理但沪深300成分股会定期调整相当于指数本身也在“换血”这个细节在深入研究时要注意demo阶段可以先忽略。数据拉回来后我习惯只保留开高低收量五列其余换手率、涨跌幅在特征工程里现算避免列名混乱。2.2 构造标签未来N日收益和分类阈值怎么定有监督学习的本质是给模型“标准答案”。在量化demo里标准答案是“未来5个交易日这笔仓位能不能赚钱”。常见做法是计算未来N日收益率然后设一个阈值收益率超过阈值记为正样本1低于另一个负阈值记为0。def make_label(df, horizon5, pos_th0.005, neg_th-0.005): # 未来horizon日收益率注意shift为负表示取未来数据 df[future_ret] df[close].shift(-horizon) / df[close] - 1.0 # 三分类1上涨0震荡-1下跌demo可以先只做多 df[label] 0 df.loc[df[future_ret] pos_th, label] 1 df.loc[df[future_ret] neg_th, label] -1 # 去掉最后horizon行因为它们没有未来收益 df df.dropna(subset[future_ret]).reset_index(dropTrue) return df这段代码的关键点是shift(-horizon)。shift(-5)表示把未来第5天的收盘价挪到当前行用来计算未来5日收益。这行代码本身没有问题它是监督学习的标准做法但它也是未来函数的高发区——如果后面构造特征时不小心也用了负数shift模型就等于提前看到了答案。阈值的选择直接影响样本均衡。pos_th0.005意味着5天涨0.5%就算正样本对沪深300来说这个阈值偏低正样本比例可能接近40%模型更容易学到信号。如果把阈值提到0.01正样本会明显减少模型会更保守但信号更少。我建议先打印df[label].value_counts(normalizeTrue)看看三类样本的分布。如果正样本比例低于15%你的分类器大概率会偏向多数类后面策略会长期空仓。这个现象在第四章避坑清单里会专门讲。2.3 时间序列切分不要用train_test_split的随机切分新手最常见的错误是拿sklearn.model_selection.train_test_split直接切数据还设置了random_state42。这对于普通分类问题没错但量化数据是时间序列随机切分等于让模型用2023年数据去预测2020年的行情这属于前视偏差——模型在训练时已经见过了测试段的统计特征。量化demo必须按时间顺序切分。def split_by_time(df, train_end2021-12-31, valid_end2022-12-31): train df[df[date] train_end].copy() valid df[(df[date] train_end) (df[date] valid_end)].copy() test df[df[date] valid_end].copy() print(ftrain {train[date].min()} ~ {train[date].max()}, rows{len(train)}) print(fvalid {valid[date].min()} ~ {valid[date].max()}, rows{len(valid)}) print(ftest {test[date].min()} ~ {test[date].max()}, rows{len(test)}) return train, valid, test这里train_end和valid_end是硬编码的日期字符串直观且不容易出错。为什么不用按比例切分因为行情存在明显的周期性2015年、2018年、2022年的市场结构完全不同按比例切分遇到极端行情会把模型带偏。在demo阶段我习惯按“训练3年、验证1年、测试1年”的节奏切分测试段是你最后才碰的数据相当于你的”模拟盘”。如果验证集上的表现都稳不住就别指望实盘了更不要反复去试不同的测试段直到找到一个漂亮的结果——那是在自欺欺人。3. 模型与回测用LightGBM和backtrader搭一个最小闭环3.1 为什么选LightGBM表格数据场景里最省心的选择特征和标签准备好之后下一步是选模型。很多人的第一反应是深度学习我劝你demo阶段先别碰。量化特征通常是几十列的中等维度表格数据样本量也就几千行LSTM、Transformer在这种规模下容易过拟合训练也慢。LightGBM这类梯度提升树模型在表格数据上依然是性价比最高的选择。import lightgbm as lgb from sklearn.metrics import accuracy_score, classification_report def train_model(train_df, valid_df, feature_cols): X_train train_df[feature_cols] y_train train_df[label].map({1: 1, 0: 0, -1: 0}) # demo先做多空二分类 X_valid valid_df[feature_cols] y_valid valid_df[label].map({1: 1, 0: 0, -1: 0}) model lgb.LGBMClassifier( n_estimators300, learning_rate0.05, num_leaves31, max_depth5, subsample0.8, colsample_bytree0.8, random_state42, verbose-1 ) model.fit( X_train, y_train, eval_set[(X_valid, y_valid)], eval_metricauc, callbacks[lgb.early_stopping(50), lgb.log_evaluation(0)] ) y_pred model.predict(X_valid) print(classification_report(y_valid, y_pred, digits3)) return model几个参数说明。n_estimators300是树的数量配了early_stopping(50)之后不用太精确模型会在验证集AUC不再提升时提前停止防止过拟合。learning_rate0.05是步长调小会让训练更慢但不一定更准。num_leaves31控制树的复杂度这个值比max_depth更值得调LightGBM的叶子节点数才是真正的复杂度上限。subsample和colsample_bytree都是0.8相当于让每棵树用80%的样本和80%的特征能显著降低方差。我也加了classification_reportdemo阶段不要只看准确率要看类别层面的精确率和召回率。注意这里我把标签map成了1和0把“震荡”和“下跌”合并成负类如果做三分类后续下单逻辑会复杂很多。3.2 滚动样本外预测回测时不要用全量数据训练如果现在直接用训练好的模型去预测测试集然后拿预测结果计算收益逻辑上是通的但没有踩中量化交易里最重要的一个习惯滚动训练。因为市场的规律会变两年前有效的因子今年可能完全失效。真正可落地的策略要定期用新数据重训模型。def rolling_predict(df, feature_cols, train_days504, step63): # train_days504近似2年交易日step63近似1个季度 df df.reset_index(dropTrue) df[signal] 0.0 for start in range(0, len(df) - train_days - step, step): end start train_days train df.iloc[start:end] pred_start end pred_end min(end step, len(df)) if pred_end pred_start: break y_train train[label].map({1: 1, 0: 0, -1: 0}) model lgb.LGBMClassifier( n_estimators200, learning_rate0.05, num_leaves31, max_depth5, subsample0.8, colsample_bytree0.8, random_state42, verbose-1 ) model.fit(train[feature_cols], y_train) prob model.predict_proba(df.iloc[pred_start:pred_end][feature_cols])[:, 1] df.loc[pred_start:pred_end - 1, signal] prob return df这段滚动的逻辑是每季度末用过去两年数据重新训练一次对之后一个季度的每个交易日给出买入概率。train_days504和step63是经验值不是固定标准你可以改成train_days756, step126做更慢的重训也能改成step21按月重训。关键是模型永远只用预测时刻之前的数据训练不偷看未来。predict_proba输出的第二列是正类概率我直接把它当作信号强度而不是简单转成0/1。为什么要概率而不是分类结果因为概率给你一个灵活的调参空间你可以说概率超过0.7才买也可以说超过0.6就买后面在backtrader里通过阈值控制交易频率。3.3 backtrader里的最小策略类把预测信号变成订单数据、模型、预测信号都有了接下来要让信号变成成交量。我一般用backtrader它足够轻量最核心的优点是策略类写法直观你只需要实现next方法框架会在每个bar上调用它。import backtrader as bt class MLStrategy(bt.Strategy): params dict( buy_threshold0.65, # 买入概率阈值 hold_days5, # 最长持有天数 stop_loss0.05 # 止损线 -5% ) def __init__(self, signal_series): # signal_series是与回测dates对齐的预测概率 self.signal signal_series self.order None self.entry_price None self.bar_held 0 def next(self): date_str self.data.datetime.date(0).isoformat() if date_str not in self.signal: return # 无持仓且信号达标才买入 if not self.position and self.signal[date_str] self.params.buy_threshold: self.buy(size100) self.entry_price self.data.close[0] self.bar_held 0 # 持仓后做止损和超时退出 if self.position: self.bar_held 1 if self.data.close[0] self.entry_price * (1 - self.params.stop_loss): self.close() elif self.bar_held self.params.hold_days: self.close()这段策略做了三件最小的实事信号达标买入、跌到止损线卖出、持有超过5天强制平仓。buy_threshold0.65意味着模型给出的上涨概率必须超过0.65才动手这个值决定了交易频率也是后面章节会重点讲的核心参数。stop_loss0.05是回撤保护。很多人做demo只看最后收益忽略了这行止损逻辑结果回测时出现单笔亏损超过20%的情况还浑然不觉。止损线设置要根据标的波动率调整沪深300设5%偏紧个股设8%到10%更常见。接下来把它们放进Cerebro引擎跑起来def run_backtest(df, signal_series, cash100000): cerebro bt.Cerebro() # 把pandas DataFrame转成backtrader的feed data bt.feeds.PandasData(datanamedf, datetimedate, openopen, highhigh, lowlow, closeclose, volumevolume) cerebro.adddata(data) cerebro.addstrategy(MLStrategy, signal_seriessignal_series) cerebro.broker.setcash(cash) cerebro.broker.setcommission(commission0.001) # 单边千分之一 cerebro.addsizer(bt.sizers.FixedSize, stake100) strat cerebro.run()[0] cerebro.plot() return strat.broker.getvalue()setcommission(0.001)这一步非常重要很多demo源码里干脆不设佣金最后收益曲线漂亮得吓人。A股双边佣金加印花税单边千分之一到千分之二才是合理区间。FixedSize定投100股避免仓位管理把demo复杂化。backtrader默认的成交逻辑是当前bar的next里下单按下一根bar的开盘价撮合。这比当日收盘价成交更接近实盘因为你的信号是收盘后算出来的最早也要到次日开盘才能执行。留意不要开启set_coc(True)那一行会改成“当日收盘价成交”表面上收益更高实际上是给回测打了兴奋剂。4. 回测漂亮但实盘翻车5个常见问题与避坑排查4.1 未来函数标签列被无意当成了特征现象回测胜率高达70%收益曲线几乎没有像样回撤但把同一套代码放到另一个时间段就完全失效。原因构造特征时不小心使用了未来数据。最常见的是close.shift(-5)这类代码出现在特征工程里导致模型在预测时不仅看到了当前价格还看到了未来5天的收盘价。另一个隐蔽场景是特征列里混入了future_ret本身模型直接读取答案。解决训练前打印特征列名逐个检查有没有负的shift。我习惯在构造特征时统一加前缀例如过去收益叫ret_1、ret_5未来收益只允许出现在标签代码里命名成future_ret。训练前跑一行断言确认future_ret不在特征列表中assert future_ret not in feature_cols, 未来收益混入特征4.2 数据泄漏归一化用到了整个时间段的统计量现象回测AUC很高但把策略放到模拟盘时表现急剧下滑甚至不如随机猜测。原因做特征缩放时用StandardScaler对全量数据fit_transform训练段和测试段共享了均值、方差。测试段统计信息暗含了未来信息模型在训练时没有真正见过“标准化的测试数据分布”回测自然虚高。解决先切分时间再对训练集fit然后用同一个scaler去transform验证集和测试集。更省事的做法是——树模型不影响梯度下降完全不需要归一化直接把原始特征喂给LightGBM。如果你的特征里既有量纲差异大的字段又用了线性模型那才需要做标准化并且严格按照上面的顺序执行。4.3 成交价太理想没有滑点和涨跌停限制现象回测显示年化收益25%但按同样信号手动模拟交易一个月实际收益只有一半。原因回测假设只要发出信号就一定能按收盘价或次日开盘价成交不扣滑点。A股实际交易中模型信号大多出现在放量突破的日子次日开盘经常会高开实际成交价比回测价高。如果买的是小盘股冲击成本和涨跌停更是能直接把策略打废。解决用一个保守的滑点估算。我一般在backtrader里将佣金设为0.002相当于单边千分之二的额外成本。模型信号越频繁滑点吞噬的利润越明显。如果调高佣金后策略从盈利变成亏损那不是参数问题是这个策略的边际收益本来就不够覆盖交易成本。4.4 参数过拟合同一个验证集被反复调参现象调参时验证集AUC从0.55慢慢涨到0.62你觉得找到了黄金参数但把参数移植到另一段历史行情上立刻失效。原因你反复用同一个验证集做模型选择验证集变成了新的训练集。LightGBM的num_leaves、learning_rate、阈值这些参数都有很大的弹性优化算法总能在特定时间段上找到一组看起来优秀的参数但那组参数只是记住了那段行情的噪声。解决把数据切成三段训练段、调参段、最终测试段。调参只能看训练段和调参段early_stopping的验证集就是调参段。最终测试段在整篇文章里只允许跑一次走了就没了。更进阶的做法是滚动窗口参数搜索每季度滚动重训把所有参数搜索结果拼起来看平均表现而不是盯着一小段时间的最优值。4.5 样本不平衡模型几乎不发出买入信号现象回测期间策略空仓率超过80%人还在钱没花出去。原因标签构造时正样本比例过低。假如未来5日上涨超过1%的日子只占全部交易日的12%而模型默认把概率阈值定在0.5那么模型输出的概率很少能达到0.5自然不会有买入信号。解决两个方向。第一是调整标签阈值把pos_th从0.01降到0.005甚至0.003提高正样本比例第二是不要用默认0.5作为下单阈值先画出预测概率的分布看分位数在哪。我一般会把正样本控制在25%到40%之间如果仍偏低就用scale_pos_weight让模型更关注少数类同时在策略里把buy_threshold调低到0.45到0.55之间试跑。5. 让demo真正可信先做成本敏感度测试再谈收益走到这一步你已经有了一个完整可运行的机器学习量化策略demo。最后我想分享一个我每次写demo都会固定跑一遍的验证方法成本敏感度测试。方法很简单写一个循环把佣金和滑点从0开始按0.0005的步长逐步加到0.003观察策略年化收益和最大回撤的变化。单边成本年化收益最大回撤交易次数0.000018.2%11.5%840.000515.7%12.1%840.001013.1%13.0%840.00208.4%15.6%84看到这个表格你就明白为什么有的策略回测跑得风生水起实盘却利润稀薄。交易次数固定时成本每提高0.1%年化收益就掉一截。如果策略换手极高比如每年交易200次以上那它对成本就极度敏感这类策略在散户环境里基本没法做。这个测试的意义不在于找到一个“够低”的成本参数而是让你知道策略的真实利润边际。如果成本加到0.002时策略已经亏损那说明它的信号质量并不能覆盖摩擦成本哪怕回测曲线再好看也只是纸面富贵。我现在的习惯是任何一个新demo先跑成本敏感度测试再决定要不要继续投入精力。这也直接影响策略迭代方向——如果成本敏感我会优先降低交易频率而不是去调模型的num_leaves。量化投资里模型只负责信号交易成本才是决定策略能不能活下来的那只看不见的手。这个习惯帮我避开了不少回测陷阱。我不止一次被一条漂亮的回测曲线吸引最后发现把成本调到真实水平后收益直接归零。从那以后我经手的每个demo都会先过成本敏感度这一关也希望这个顺序能帮你在验证自己的策略时少走弯路。希望帮到你。本文还有配套的精品资源点击获取
返回列表