简介:面向具备一定Python基础、但对炒股与量化投资零概念的人群,这份demo源码演示了基于机器学习构建A股量化策略的完整路径:从行情与交易数据获取,到特征筛选、模型训练,再到策略回测和风险控制,帮助读者快速搭建最小可用框架。压缩包共14个文件、大小727KB,以5个Python脚本为核心,覆盖数据预处理、特征工程、模型构建与回测评估;6张图表直观展示K线、最大回撤、决策树预测涨幅等结果;另有2个文本用于环境依赖与股票列表配置,方便对照运行效果理解代码。目前已有157人学习,适合作为从理论到实战的过渡素材。通过阅读源码和运行示例,可掌握tushare数据接口、pandas处理、常见机器学习算法在择时/涨幅预测中的应用,以及回测中最大回撤等指标的意义,为后续开发更复杂策略打下基础。
1. 基于机器学习的量化投资策略demo到底在做什么、适合谁
一个机器学习模型在历史回测里跑出年化50%、最大回撤只有8%的漂亮曲线,可一旦放进模拟账户,三个月就把两年的回测利润全部亏回去。这种剧情在量化圈并不稀有,问题通常不在模型本身,而在数据、特征和回测流程里藏着的一堆侥幸假设。这套“基于机器学习的量化投资策略demo”,要解决的就是从数据清洗到模型训练再到诚实验证这一整条链路上最容易踩坑的环节,让样本外表现尽量接近回测表现,而不是靠运气。
它的核心逻辑不是预测股价涨到多少,而是用树模型学习“未来一段时间内,哪些股票相对更强”,再据此生成交易信号。适合有Python和pandas基础、想把机器学习用进日频选股的开发者,也适合被传统规则因子折腾到瓶颈、想换一种建模思路的从业者。下面章节所有代码都可以直接复现,数据源换成你自己手头的数据也能跑。
2. 从传统因子到机器学习量化:建模框架、数据准备与标签设计
2.1 为什么机器学习能补上传统量化的短板
传统量化策略通常走“因子 + 线性加权”的路线:先构造PE、动量、波动率等因子,再按固定权重打分排序,最后取前N只买入。这套框架优点是逻辑透明、可解释性强,但它天然假设因子与未来收益之间的关系是线性的、可加的。现实中,一个因子单独看可能毫无区分度,与另一个因子组合起来却会产生明显的交互效果,线性模型很难捕捉这种结构。
机器学习模型,尤其是梯度提升树这类非线性模型,能自动学习因子之间的交互和高阶关系。比如“低波动 + 高动量”在特定市场阶段有效,在另一些阶段失效,树模型可以通过分裂结构自行逼近这种条件关系,不需要人工提前指定。这也是这个demo选择树模型作为主模型的原因:对表格型金融数据,树模型往往比神经网络更容易训练、更快见效,也不需要对特征做复杂的非线性变换。
但要泼一盆冷水:机器学习能补上的只是“从特征到收益”的映射能力,它不能解决数据本身的问题。如果标签里混进了未来信息,或者股票池存在幸存者偏差,再强的模型也只是在拟合噪声。这个demo把问题拆成了三个明确子任务——数据清洗、特征构造、诚实回测,每一步都做了约束,就是为了让模型学习的是真实规律而不是数据漏洞。
2.2 数据源选择与清洗:复权价格是底线
日频策略的输入通常包括行情数据和基本面数据。行情数据至少要包含开高低收、成交量、成交额,基本面数据可以后续接入。但无论数据源是什么,第一步都是统一交易日历和复权价格。未复权的价格遇到分红送股会出现断崖式跳空,算出的收益率全是假的,这个坑新手很容易踩。
获取数据后,需要依次处理三件事:剔除停牌日、剔除涨跌停无法成交的样本、对齐历史成分股。下面是一段清洗代码,按常见做法把停牌和涨跌停过滤掉:
import pandas as pd def clean_market_data(df: pd.DataFrame) -> pd.DataFrame: # df 包含字段: symbol, date, open, high, low, close, volume, amount # 1. 剔除停牌日:成交量或成交额为0的记录直接删除 df = df[(df["volume"] > 0) & (df["amount"] > 0)].copy() # 2. 剔除一字涨跌停:开盘即涨停或跌停,普通资金买不进卖不出 # 用当日开盘价与昨收价近似判断,幅度阈值取 9.5% df = df.sort_values(["symbol", "date"]) prev_close = df.groupby("symbol")["close"].shift(1) open_gap = df["open"] / prev_close - 1 df = df[(open_gap.abs() < 0.095)].copy() # 3. 只保留有足够历史数据的股票:至少连续240个交易日 df["count"] = df.groupby("symbol")["date"].transform("count") df = df[df["count"] >= 240].drop(columns=["count"]) return df这段代码里最关键的是第3步。很多新手直接拿正在交易的股票做全样本回测,没有剔除历史上市值过小或已经退市的股票,导致回测结果被幸存者偏差严重高估。这里按“每只股票必须有足够长历史”做了过滤,算是一个基础但有效的缓解手段。
参数说明:涨跌停阈值9.5%适用于普通A股主板,创业板和科创板是20%涨跌幅,ST股票是5%,如果换市场必须同步调整。停牌和涨跌停的过滤逻辑是硬性约束,宁可少几个样本也不要让模型学到“成交不了的真实收益”。
2.3 标签如何定义:用未来N日收益还是用排序分位数
数据准备好之后,最关键的决策是“模型学什么”。常见做法有两种:回归未来N日收益率,或者把截面收益排序映射成分类标签。这个demo选择的是“截面排序分位数”方式,核心原因是金融收益噪声极大,预测精确数值没有意义,预测相对排序才更稳健。
标签构造的代码如下:
def make_label(df: pd.DataFrame, horizon: int = 5) -> pd.DataFrame: # 计算每只股票未来 horizon 日的累计收益 df = df.sort_values(["symbol", "date"]) df["future_ret"] = df.groupby("symbol")["close"].shift(-horizon) / df["close"] - 1 # 按每个交易日做截面排序,取排名百分比 # 排名越接近1,说明该股在未来一段时间相对越强 df["label_rank"] = df.groupby("date")["future_ret"].rank(pct=True) # 过滤标签缺失的行(最后 horizon 天没有未来数据) df = df.dropna(subset=["future_ret", "label_rank"]).copy() return df这里有两个细节值得注意。第一,shift(-horizon)是严格用未来数据构造标签,特征里如果也用了未来数据就会被污染,后面会在训练集切分时做隔离。第二,用rank(pct=True)把收益转成0到1的相对强度,一方面消除了不同市场状态下整体涨跌的影响,另一方面让极端收益的影响被压缩——某天全市场暴涨,绝对收益模型会被拉偏,排序模型则不受影响。
换到其他市场或者更低频的策略,horizon要重新设。日频策略5到10天是常见区间,太短噪声大,太长信号衰减明显。这个参数建议纳入后面的前向验证一起调,不要单独拍脑袋定。
3. 特征工程与训练集构造:把行情数据变成模型能吃的样子
3.1 量价特征的计算:动量、波动率与成交额因子的正确姿势
模型能学到什么,上限由特征决定。这个demo主打量价数据,所以特征也围绕动量、波动率、成交活跃度、流动性几个维度展开。核心是用滚动窗口将历史行情转化成横截面特征,再配上涨跌停后的恢复效应等辅助特征。
下面是基础特征计算的代码:
import numpy as np def add_price_features(df: pd.DataFrame, windows=[5, 10, 20, 60]) -> pd.DataFrame: df = df.sort_values(["symbol", "date"]) # 对数收益率:金融数据里比简单百分比更稳定 df["log_ret"] = np.log(df["close"]).groupby(df["symbol"]).diff() for w in windows: # 动量:过去 w 日的累计收益率 df[f"ret_{w}"] = df.groupby("symbol")["close"].transform( lambda x: x.pct_change(w) ) # 波动率:过去 w 日对数收益率的滚动标准差 df[f"vol_{w}"] = df.groupby("symbol")["log_ret"].transform( lambda x: x.rolling(w).std() ) # 成交额相对均值:当前成交额 / 过去 w 日均值,衡量资金关注度 df[f"amount_ratio_{w}"] = df.groupby("symbol")["amount"].transform( lambda x: x / x.rolling(w).mean() ) # 最高价相对位置:过去 w 日内当前价格处于什么分位 df[f"close_pos_{w}"] = df.groupby("symbol")["close"].transform( lambda x: x / x.rolling(w).max() ) return df这段代码有几个参数值得展开。windows=[5, 10, 20, 60]覆盖了短、中、长三个周期,日频选股里20日和60日是两个关键窗口,20日捕捉月度反转效应,60日捕捉季度趋势。窗口太短噪声大,太长信号滞后,现有设置属于比较保守的起步配置。
实现上要注意groupby + transform的组合,它保证了每个窗口的计算都是按股票独立完成的,不会出现把不同股票的收益混在一起算波动率的问题。这个错误在初学阶段非常常见——直接对整个DataFrame做rolling,结果所有股票的窗口数据串成了一锅粥。
3.2 训练集、验证集、测试集按时间切分,而不是随机打散
很多通用机器学习教程都会用train_test_split随机切分数据,但金融数据绝对不能这么干。随机切分会让模型在训练时看到未来信息——比如测试集中某一天的样本,它的特征来自训练集时间段内的统计量,信息泄漏会直接导致回测虚高。这个demo采用纯时间序列切分:训练集在最前,验证集居中,测试集最后。
def split_by_time(df: pd.DataFrame, train_end: str, val_end: str): train = df[df["date"] <= train_end] val = df[(df["date"] > train_end) & (df["date"] <= val_end)] test = df[df["date"] > val_end] return train, val, test时间切分只是一个开始,还有一个容易被忽略的问题叫“标签重叠”。标签是未来5日的收益,那么第T日和T+1日的样本,其标签窗口有4天是重叠的,这会导致训练集内样本并非独立。模型会反复学习到相似的标签模式,造成过拟合。
缓解手段是引入embargo窗口,即在训练集与验证集、测试集之间空出一段缓冲期:
def split_by_time_with_embargo(df: pd.DataFrame, train_end: str, val_end: str, embargo: int = 10): train_end_dt = pd.Timestamp(train_end) - pd.Timedelta(days=embargo) val_end_dt = pd.Timestamp(val_end) - pd.Timedelta(days=embargo) train = df[df["date"] <= train_end] val = df[(df["date"] > train_end_dt) & (df["date"] <= val_end)] test = df[df["date"] > val_end_dt] return train, val, testembargo大小的经验值是标签窗口的1到2倍。这里标签是5日,所以embargo取10日,可以基本消除训练集与验证集之间的信息重叠。如果不加这一步,模型在验证集上的表现会虚高5%到10%甚至更多,之后上实盘才会现出原形。
3.3 特征去极值与标准化:树模型也要做,但目的不同
金融特征里经常出现极端值。比如某只股票突然停牌复牌,单日收益率能到50%以上,这种离群点会影响模型分裂点的选择。去极值有两个常见方案:MAD(绝对中位差)和分位数截断。这个demo用MAD,因为它对极端值更稳健:
def winsorize_by_mad(df: pd.DataFrame, features: list, n: int = 5) -> pd.DataFrame: df = df.copy() for feat in features: median = df[feat].median() mad = (df[feat] - median).abs().median() lower = median - n * mad upper = median + n * mad df[feat] = df[feat].clip(lower, upper) return dfn=5意味着超过中位数5倍绝对中位差的数值会被截断到边界。MAD方法相比z-score方法的一大优势是它以中位数为基准,单个极端值很难拉偏整体分布。因为前文提到特征是横截面构造的,不同股票的同类特征可能量纲有差异,所以标准化这一步建议按每个交易日单独做:
def standardize_by_cross_section(df: pd.DataFrame, features: list) -> pd.DataFrame: df = df.copy() for feat in features: # 按日期分组做 z-score,每个交易日截面内均值为 0、标准差为 1 df[feat] = df.groupby("date")[feat].transform( lambda x: (x - x.mean()) / (x.std() + 1e-8) ) return df为什么要强调按截面标准化?因为股票特征在时间上存在趋势和漂移,统一用全体数据的均值方差标准化,会把“历史整体水平”当成信号混进特征里,模型在测试集上遇到市场风格变化时就会失效。横截面标准化让每个交易日内部相对可比,这是量化特征处理和普通机器学习特征处理最核心的差异。
树模型本身不做距离计算,对特征的绝对尺度不敏感,所以标准化并非必需。但后面如果切换到线性模型或小规模神经网络,标准化就成了硬性要求。这个demo统一做了处理,换取后续换模型时的灵活性。
4. 模型训练、信号生成与最小回测:可直接复现的完整代码链
4.1 用LightGBM做日频截面预测:训练脚本与核心参数
模型选型上,梯度提升树(GBDT)是金融表格数据里最实用的一类模型。相比深度学习,它对特征量纲不敏感,对小样本更稳健,训练速度快,而且自带正则化。这个demo使用开源的LightGBM实现,参数设置以稳健为主,避免过度调参。
import lightgbm as lgb from sklearn.metrics import roc_auc_score feature_cols = ["ret_5", "ret_10", "ret_20", "ret_60", "vol_5", "vol_10", "vol_20", "vol_60", "amount_ratio_5", "amount_ratio_20", "close_pos_10", "close_pos_20"] model = lgb.LGBMRegressor( n_estimators=300, learning_rate=0.05, max_depth=5, num_leaves=31, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_alpha=0.5, reg_lambda=5.0, random_state=42, n_jobs=-1 ) model.fit( X_train[feature_cols], y_train, eval_set=[(X_val[feature_cols], y_val)], callbacks=[lgb.early_stopping(stopping_rounds=50)] )参数选择逻辑:n_estimators=300配合learning_rate=0.05,先用早停让模型自己决定最优轮数,而不是拍脑袋定死树的数量。max_depth=5和num_leaves=31限制了模型复杂度,防止树模型学出太多噪声分裂。subsample=0.8降低每轮训练的数据方差,colsample_bytree=0.8则强制每棵树只看80%的随机特征,这两者配合能显著缓解过拟合。reg_alpha和reg_lambda的正则项在金融噪声大、特征相关性高的场景里几乎必须开,否则验证集AUC和测试集AUC的差距会迅速拉大。
注意这里用的是LGBMRegressor回归到排序标签,而不是分类器。因为标签是0到1的排序分位数,回归可以将序数信息完整保留,分类则会丢失中间档位的信息。早停的验证集使用前面切分好的时间验证集,不用打散后的随机验证集,这一点跟第3章的切分逻辑保持一致。
4.2 从预测结果到交易信号:排名和阈值怎么设
模型输出的是每个股票在未来5日相对强弱的预测分,但预测分不能直接用来下单一——预测值有系统性偏差,整体市场上涨时所有股票的预测分都会偏高。正确的做法是把预测分在截面内排序,再取头部股票,按排名分配资金权重。
def make_signal(pred_df: pd.DataFrame, top_k: int = 20) -> pd.DataFrame: # pred_df 包含 date, symbol, pred 三个字段 # 每个交易日按 pred 排序取前 top_k 只 pred_df["rank"] = pred_df.groupby("date")["pred"].rank(ascending=False) signal = pred_df[pred_df["rank"] <= top_k].copy() # 等权配置:每只入选股票权重相同 signal["weight"] = 1.0 / top_k return signal[["date", "symbol", "weight"]]简单的等权配置策略在初期完全够用,它避免了组合权重优化的额外复杂度。如果你想测试不同头寸管理方式,可以把weight改成按排名线性递减,比如第1名权重最高、第20名最低,但等权方式的换手率更低,对滑点的敏感性也更低,新手阶段更友好。
4.3 最小向量化回测:用两天shift撑起一个诚实回测框架
回测框架的核心原则是不能使用未来信息。最常见的未来函数是把当天收盘后生成的信号、当天就去成交,实际交易中这绝无可能。这个demo的回测用向量化方式实现,核心就是通过shift(1)让信号晚一天生效:
def vectorized_backtest(signal: pd.DataFrame, daily_ret: pd.DataFrame, cost_rate: float = 0.001): # signal: date, symbol, weight,由预测结果生成 # daily_ret: date, symbol, close/trade 产生的日收益 # 先将信号展开成宽表(date为行,symbol为列) signal_wide = signal.pivot(index="date", columns="symbol", values="weight") ret_wide = daily_ret.pivot(index="date", columns="symbol", values="ret") # 信号 shift(1):今天用昨天的信号,明天用今天的信号 # 永远不知道今天的收益再买入 portfolio = signal_wide.shift(1).fillna(0) # 组合当日收益 = 前一日持仓权重 * 今日个股收益 port_ret = (portfolio * ret_wide).sum(axis=1) # 换手率:持仓权重的绝对值变化之和 turnover = signal_wide.diff().abs().sum(axis=1).fillna(0) net_ret = port_ret - turnover * cost_rate # 累计净值 equity = (1 + net_ret).cumprod() return equity, port_retshift(1)是整个回测框架的灵魂。portfolio = signal_wide.shift(1)保证了当日持仓完全由前一天的信号决定,与当天所有行情无关。turnover = signal_wide.diff()计算的是调仓时买入卖出的交易量,乘以cost_rate就得到交易成本。cost_rate=0.001是单边千分之一的近似模拟,包含佣金、滑点和冲击成本,实盘更保守可以调到0.002。
向量化回测的代码量很小,但已经能回答大部分核心问题:策略年化收益多少、回撤多大、换手多高、对成本敏不敏感。如果回测净收益很高但换手率极大,真实交易中你会被成本和滑点吃干净,这种情况在向量化框架里可以一眼看清。
5. 避坑:量化模型从回测到实盘最常见的5个翻车现场
5.1 标签泄漏:用了未来数据还浑然不觉
现象:回测集AUC高达0.8以上,预测完美,一上实盘立刻崩溃。最典型的是当天生成特征、当天生成标签,而特征中某个字段实际是当天收盘后的数据。回测里“知道”当天行情后预测当天收益,当然准,实盘却等不到那个“已知”的一刻。
原因:特征与标签的时间轴没有严格隔离,或某个特征不小心用了shift()方向错误。比如计算周涨幅用了close除以一周后的close,就会把未来信息偷渡进来。
解决:所有特征一律用历史窗口生成,标签一律用未来窗口生成,中间通过embargo隔离。写代码时统一遵守“特征生成时只用截至当日的数据,包括当天的收盘价也只作为历史信息参与计算”,并且在训练前做一次泄漏检查:把某一天的数据单独拿出来,确认当天的特征不包含当天之后的任何信息。
5.2 过拟合回测:参数调到历史曲线完美
现象:通过不断调参,回测年化从30%一路调到80%,最大回撤越来越漂亮。但参数换一个时间段就崩溃,或者测试集收益断崖。
原因:验证集被反复使用,本质上变成了模型选择的一部分。你看到的“最优参数”只是在验证集上过拟合的结果,而不是真正稳定的规律。这个问题的隐蔽性在于,它不像泄漏那样有明确的bug,而是渗透在“多调几次参数、再看一眼效果”的日常操作里。
解决:训练集、验证集、测试集三段分离,验证集最多允许看2到3次整体效果,测试集只允许看一次。要调参时先用训练集做K折交叉验证或者更保守的滚动前向验证,不要在验证集上反复试。
5.3 幸存者偏差:股票池里全是活到今天的优等生
现象:回测股票池听起来很合理——“用沪深300成分股”,但细看发现成分股列表是今天的,而回测时间是2015到2018年。那些2018年还在但后来退市或暴雷的股票,被无声地排除在回测之外,策略实际是“只买活下来的股票”,收益自然虚高。
原因:获取历史成分股数据成本高,很多人图省事直接用了当前列表回测历史。这种偏差在高波动的中小盘股上尤其严重。
解决:使用历史某一时点的成分股快照,退市股票也要保留在池子里。如果数据有限,至少要在清洗阶段加入一条规则:回测开始时已退市的股票也要纳入,直到其最后一个交易日。这个demo里的clean_market_data已经做了长度过滤,但更彻底的做法还是直接使用历史时点数据。
5.4 滑点与手续费:回测赚10%,实盘亏成负数
现象:策略在回测里收益曲线平滑,等权20只、月度调仓,看起来完美。投入模拟盘后,收益大幅缩水,某些调仓频繁的月份亏损严重。
原因:回测没有扣除交易成本,或者成本设得太低。20只股票等权、月频换手的策略,单次调仓换手约5到10个百分点,双边成本千分之二时,一年下来成本耗损非常可观。如果再叠加低流动性股票,成交滑点远超常规费率。
解决:成本参数按至少双边千分之三到千分之五设定,并在回测输出里同时看一眼“换手率”,如果换手率高但超额收益低,说明策略的交易成本敏感度过高。把cost_rate从0.001调到0.002,看收益衰减了多少,这一测试能直接暴露策略对成本的依赖程度。
5.5 模型漂移:上季度有效,这个季度失效
现象:同一个模型参数,年初回测和当前数据回测的结果差异巨大,预测排名开始倒挂——模型看好的股票普遍跑输模型不看好的股票。
原因:市场风格切换、行业结构变化、交易者结构变化都可能导致特征与收益的关系发生漂移。树模型学到的映射关系是历史数据的产物,当市场环境脱离训练分布时,外推能力很弱。这个现象在量价特征上尤其明显:某段时间小盘股因子失效,模型的ret_20特征权重就会急剧变化。
解决:建立滚动重训练机制,比如每个月用最近两年的数据重新训练一次模型,并监控预测IC(信息系数)。当IC连续低于0.02时,主动降低策略仓位,而不是硬扛。这种动态更新机制比追求单次模型的绝对精度更接近实战。
6. 把demo变成可用策略:三个进阶验证技巧
6.1 用滚动前向验证替代一次性回测
一次性回测只告诉你“过去三年这个策略好”。滚动前向验证模拟的是真实交易节奏:每季度训练一次模型,预测下一季度,然后把这个季度的预测结果累计起来。这种方法既扩大了样本外覆盖范围,又天然规避了单一时段的运气成分。
实现上,把第4章的训练和预测逻辑封装成函数,按季度循环调用。每次训练只用截至当期的数据,预测未来20个交易日,再把所有预测拼接起来统一回测。这样得到的收益曲线和换手率,比一次性回测更接近未来实盘表现。
6.2 让模型告诉你预测可不可信
LightGBM输出的是预测均值,并不自带置信度。但我们可以利用树模型的结构特征:每个样本最终落入一个叶子节点,叶子节点的样本量可以作为置信度的近似指标。样本量太少意味着该区域数据稀疏。在生成信号时,把叶子节点样本量低于某个阈值的预测全部置为无效,可以过滤掉相当一部分尾部风险。这个技巧在测试集中可以减少极端亏损日的数量。
6.3 组合再平衡:信号只是入场的扳机
买入信号给了你选股名单,但不是每天都要调仓。我见过太多人拿到预测结果恨不得每天换一遍持仓,结果收益没上去,交易成本吃掉一大块。常见做法是设定一个调仓周期,比如每周或每两周调一次仓,只在交易日当天按最新信号重新分配权重。这个demo里的diff().abs()回报就是换手率监控指标,可以以此为依据调整调仓频率,找到收益与成本的平衡点。
我自己在跑这类策略时,吃过最多的亏不是模型不够聪明,而是流程不够克制。数据一点不干净、回测偷偷用了一点未来信息、参数多调了几次,每一项都在给回测结果注水。后来养成一个习惯:每次回测前先问自己,这套流程如果放到模拟盘上,哪些部分会失效?逐条排查完才敢开始调参。希望帮到你。
本文还有配套的精品资源,点击获取