☰
机器学习股票量化投资:从源码到回测的实战避坑指南
2026/10/10 10:59:44 网站建设 项目流程

简介:这份资源是面向计算机、人工智能、大数据及金融数学等专业学生的机器学习股票量化投资研究项目源码包,适用于课程设计、期末大作业与毕业设计等场景,也可供具备一定编程基础的技术学习者参考。压缩包共14个文件,约30.29MB,以6个Python脚本和6个CSV数据文件为主,脚本涵盖数据获取、模型训练与交易模拟等环节,CSV则提供单只及多只股票的历史行情数据,另含少量项目配置文件。项目按单只股票预测与多只股票预测两条主线组织,包含数据抓取、特征处理、模型训练与回测交易等完整流程,代码经过调试,下载后可直接运行,便于读者理解量化策略从数据到信号的实现思路。目前已有210人学习下载,适合作为入门量化投资与机器学习实战的参考案例。

1. 从一份「算法源码+项目说明」压缩包说起:机器学习做股票量化到底能不能落地

很多人第一次拿到「基于机器学习的股票量化投资研究算法源码+项目说明.zip」这类压缩包,第一反应是解压、找main.py、pip install -r requirements.txt、然后python main.py,结果要么报缺数据,要么跑出来一条几乎水平的净值曲线。问题不在代码,而在于你把它当成了一个「能直接赚钱的程序」,而它本质上是一套研究框架:把行情数据、特征工程、监督学习模型、回测引擎四件事串起来的最小闭环。

这篇笔记面向两类人:一是想用机器学习入门量化、手里已经有类似源码包但不知道怎么读怎么改的工程师;二是做过传统规则策略、想看看机器学习模型在股票横截面预测上到底能带来多少增量的人。我会按「数据怎么进、特征怎么造、模型怎么选、回测怎么防未来函数、参数怎么调」这条线,把这类项目从能跑通到能验证讲清楚。先给结论:这类项目值得做,但价值在流程和验证,不在那份源码本身。源码是脚手架,真正决定结果的是你对标签定义、滑点假设和样本外切分的理解。

2. 拆开压缩包先看什么:数据流、标签与回测骨架

2.1 先画数据流,再决定改哪一层

拿到源码别急着跑,先花二十分钟把目录结构和数据流理清楚。常见做法是三层:data/放原始行情或已经落地的 parquet/csv,features/或factor/做特征计算,model/训练与预测,backtest/做组合回测。你要确认的第一件事是:标签(label)是在哪一层生成的。很多项目把未来收益率直接算在特征表里,这是最隐蔽的未来函数来源。

我一般会先搜这几个关键词定位核心逻辑:

# 在项目根目录执行,快速定位关键逻辑所在文件 grep -rn "shift(-" . --include="*.py" # 找未来函数高发区 grep -rn "train_test_split\|TimeSeriesSplit" . --include="*.py" # 看样本切分方式 grep -rn "commission\|slippage\|fee" . --include="*.py" # 看回测成本假设 grep -rn "def backtest\|def run_backtest" . --include="*.py" # 找回测入口

逻辑说明:shift(-n)是构造未来标签的常用写法,本身没错,但如果它出现在特征计算函数里就是灾难;train_test_split默认随机切分,用在时间序列上会直接把未来信息泄漏进训练集;commission/slippage如果搜不到,说明回测成本被忽略了,收益会被系统性高估。参数上,shift(-1)通常代表用 T+1 收益做标签,shift(-5)是周频,周期越长信噪比越低但换手也越低,这个要跟你的调仓频率对齐。

2.2 标签定义决定了整个项目的上限

机器学习做股票,标签就是你要预测的东西。常见三类:绝对收益(未来 N 日收益率)、超额收益(减去指数或行业均值)、排序标签(横截面分位数或排名)。源码包里如果只写了绝对收益,你在 A 股这种高波动市场里会很难做,因为模型会花大量精力去拟合大盘涨跌,而不是选股能力。

我一般会把标签改成横截面排序,具体做法是对每个交易日做 groupby 再 rank:

import pandas as pd import numpy as np def make_label(df, price_col="close", horizon=5, method="rank"): """ df: 含 date, stock_code, close 的长表 horizon: 预测未来几个交易日的收益 method: 'raw' 绝对收益, 'rank' 横截面排名(0~1) """ df = df.sort_values(["stock_code", "date"]).copy() # 未来 horizon 日收益,注意 groupby 防止跨股票串行 df["fwd_ret"] = df.groupby("stock_code")[price_col].shift(-horizon) / df[price_col] - 1 if method == "rank": # 每个交易日内部做百分位排名,天然去除市场beta df["label"] = df.groupby("date")["fwd_ret"].rank(pct=True) else: df["label"] = df["fwd_ret"] # 最后 horizon 天没有未来数据,必须丢掉,否则标签是 NaN 混进训练 df = df.dropna(subset=["label"]) return df

逻辑说明:groupby("stock_code").shift(-horizon)保证不会把 A 股票的价格错位到 B 股票上,这是新手最常翻车的地方。rank(pct=True)把标签压到 0~1,模型输出可以理解为「这只股票在当日横截面中的相对强弱」,回测时按预测值排序取 top 组即可。参数上horizon=5对应周频调仓,horizon=1是日频,日频信噪比极低、换手极高,除非你有分钟级数据否则不建议一上来就做。

2.3 回测骨架:先确认它是不是「真」回测

很多源码包的回测就是signal * return累加,没有调仓、没有成本、没有涨跌停处理。你要检查三件事:调仓频率是否和标签 horizon 一致、是否按次日开盘价成交、是否扣了双边成本。如果项目里回测函数只有十几行,基本可以判定是玩具级,需要你自己补。

一个最小可用的回测循环长这样:

def simple_backtest(df, pred_col="pred", ret_col="fwd_ret", top_pct=0.2, cost=0.0015): """ 每个调仓日按预测值排序,做多 top_pct,等权 cost: 单边成本,双边合计约 0.0015~0.003 """ records = [] for date, g in df.groupby("date"): g = g.dropna(subset=[pred_col, ret_col]) if len(g) < 20: # 当日股票太少,跳过,避免极端权重 continue n = max(1, int(len(g) * top_pct)) top = g.nlargest(n, pred_col) gross = top[ret_col].mean() # 组合等权收益 turnover = 1.0 # 简化假设每日全换仓 net = gross - cost * turnover * 2 # 买卖双边 records.append({"date": date, "ret": net}) return pd.DataFrame(records).set_index("date")

逻辑说明:nlargest取预测值最高的股票,top_pct=0.2表示做多前 20%。cost是单边成本,乘以 2 是因为一次调仓既有卖出也有买入。真实场景里 turnover 应该根据前后两期持仓重叠度计算,这里为了讲清楚逻辑做了简化。参数上,A 股单边成本(佣金+印花税+冲击)保守取 0.0015 到 0.003,如果你回测出来年化 30% 但成本一扣只剩 5%,说明策略换手太高,需要拉长 horizon 或加换手惩罚。

3. 特征工程与模型选型:别一上来就上深度学习

3.1 特征从哪来:量价、基本面还是另类数据

源码包里通常只带量价特征,因为公开数据好拿。常见的有动量(过去 N 日收益)、波动率(过去 N 日收益标准差)、换手率、均线偏离、RSI 等。我的建议是:先把量价特征做扎实,再考虑加基本面。量价特征计算简单、更新频率高、和标签周期匹配,是机器学习选股的基本盘。

一个容易忽略的点是特征的横截面标准化。不同股票的价格量级差异巨大,直接喂给模型会让大市值股票主导损失函数。正确做法是每个交易日内部做 z-score 或 rank:

def cross_section_norm(df, factor_cols): """对每个交易日做横截面 z-score,防止量级差异主导模型""" out = [] for date, g in df.groupby("date"): g = g.copy() for col in factor_cols: mu, sigma = g[col].mean(), g[col].std() # sigma 为 0 说明当日该因子无区分度,直接置 0 g[col] = (g[col] - mu) / sigma if sigma > 1e-8 else 0.0 out.append(g) return pd.concat(out, ignore_index=True)

逻辑说明:横截面标准化是量化里最基础也最容易被跳过的一步。sigma > 1e-8的判断是为了防止某天所有股票该因子取值相同导致除零。参数上,如果你用的是树模型,标准化不是必须的,但 rank 变换仍然有用,因为它能削弱极端值影响;如果用的是线性模型或神经网络,标准化是必须的。

3.2 模型选型:从 LightGBM 开始,别碰 LSTM

热搜里「机器学习实战 基于scikit-learn、keras和tensorflow」这类内容很多,但在股票横截面预测这个场景,梯度提升树(LightGBM/XGBoost)是性价比最高的起点。原因有三:表格型特征树模型天然占优、训练快方便迭代、特征重要性可解释。LSTM/Transformer 不是不能用,但它们对数据量、调参和算力要求高得多,新手很容易陷入「调了三个月还不如线性回归」的困境。

用 LightGBM 做横截面回归的最小代码:

import lightgbm as lgb from sklearn.metrics import mean_squared_error # 按时间切分,前 70% 训练,后 30% 验证,绝不能随机切 dates = sorted(df["date"].unique()) split = dates[int(len(dates) * 0.7)] train = df[df["date"] < split] valid = df[df["date"] >= split] feature_cols = [c for c in df.columns if c.startswith("f_")] params = { "objective": "regression", # 标签是 rank 也可用 regression "metric": "rmse", "learning_rate": 0.05, # 股票信噪比低,学习率要小 "num_leaves": 31, # 控制复杂度,防止过拟合 "min_data_in_leaf": 200, # 单叶最少样本,股票场景要调大 "feature_fraction": 0.8, # 列采样,增加多样性 "bagging_fraction": 0.8, "bagging_freq": 1, "verbose": -1 } dtrain = lgb.Dataset(train[feature_cols], label=train["label"]) dvalid = lgb.Dataset(valid[feature_cols], label=valid["label"]) model = lgb.train( params, dtrain, num_boost_round=500, valid_sets=[dvalid], callbacks=[lgb.early_stopping(50)] # 50 轮不提升就停 )

逻辑说明:min_data_in_leaf=200是股票场景的关键参数,因为单日横截面可能有几千只股票,叶子太细会记住噪声。early_stopping(50)防止过拟合,验证集必须是时间上靠后的数据。learning_rate=0.05配合 500 轮是比较稳的组合,如果你发现验证集 loss 很早就反弹,说明模型复杂度太高,优先降num_leaves而不是加数据。

3.3 特征重要性和 IC 验证:模型好不好,先看这两个数

训练完不能只看 RMSE,要看IC(信息系数),也就是预测值和未来收益的横截面相关系数。IC 均值能到 0.03 以上、ICIR(IC均值/IC标准差)能到 0.3 以上,就算有实用价值。计算方式:

def calc_ic(df, pred_col="pred", label_col="label"): """按日计算横截面 Spearman 相关,返回 IC 序列""" ic_list = [] for date, g in df.groupby("date"): if len(g) < 30: continue ic = g[pred_col].corr(g[label_col], method="spearman") ic_list.append({"date": date, "ic": ic}) ic_df = pd.DataFrame(ic_list) print(f"IC均值: {ic_df['ic'].mean():.4f}") print(f"ICIR: {ic_df['ic'].mean() / ic_df['ic'].std():.4f}") return ic_df

逻辑说明:用 Spearman 而不是 Pearson,是因为我们关心的是排序能力而非线性关系。IC 均值 0.03 看起来很低,但在股票里已经能用,因为横截面股票数量多,微弱优势通过分散化可以放大。ICIR 衡量稳定性,低于 0.2 说明模型时好时坏,实盘会很难受。

4. 避坑与排查:这类项目最容易翻车的五个地方

4.1 现象:回测净值一路向上,实盘完全不行

原因:最常见的是未来函数。特征里混入了未来数据,比如用当日收盘价算了特征又用当日收盘价成交,或者标准化时用了全样本均值。解决:把所有特征计算限制在 T 日及之前,成交价用 T+1 开盘,标准化改成滚动窗口或横截面当日计算。检查方法是在特征表里随机抽一天,手动核对每个特征是否只用了当天及之前的数据。

4.2 现象:训练集 loss 很低,验证集 loss 很高

原因:过拟合,或者训练集和验证集分布不一致。股票数据本身信噪比低,模型很容易记住噪声。解决:先降num_leaves到 15 以下,提高min_data_in_leaf到 500,加lambda_l2正则。如果还不行,检查特征里有没有未来函数导致训练集「作弊」。另一个可能是验证集时间段市场风格和训练集差异大,这时候要看滚动 IC 而不是单次切分。

4.3 现象:换手率极高,成本吃掉全部收益

原因:标签 horizon 太短,或者模型预测值日间波动大。解决:把 horizon 从 1 拉到 5 或 10,或者在回测里加换手惩罚,比如只在预测值变化超过阈值时才调仓。实操中我一般会统计日均单边换手,超过 50% 就要警惕,超过 100% 基本没法实盘。

4.4 现象:某些股票权重异常大

原因:预测值没有做横截面标准化,或者极值没有截断。解决:对预测值做横截面 rank 或 winsorize,限制单票权重上限(比如 2%)。代码上可以在回测前加一步pred = pred.clip(lower=pred.quantile(0.01), upper=pred.quantile(0.99))。

4.5 现象:不同随机种子结果差异巨大

原因:数据量太小或模型太复杂,导致结果不稳定。解决:用多个种子训练取平均(ensemble),或者换更简单的模型。如果换种子 IC 从 0.05 掉到 0.01,说明模型在拟合噪声,不要用。

5. 把研究推到可验证:滚动训练、组合约束与实盘前检查

5.1 滚动训练才是接近实盘的验证方式

单次时间切分只能看一个市场阶段,滚动训练(walk-forward)才能看出策略在不同环境下的稳定性。做法是:用前 N 年训练,预测下一个月,然后窗口向前滚动。这样你得到的是每个月的样本外预测,拼起来就是接近实盘的净值曲线。

def walk_forward(df, train_years=3, retrain_freq="M"): """滚动训练:每次用过去 train_years 数据训练,预测下一个月""" df = df.sort_values("date") dates = df["date"].dt.to_period("M").unique() preds = [] for i in range(train_years * 12, len(dates)): test_month = dates[i] train_end = dates[i - 1].to_timestamp("M") train_start = dates[i - train_years * 12].to_timestamp("M") train = df[(df["date"] >= train_start) & (df["date"] <= train_end)] test = df[df["date"].dt.to_period("M") == test_month] if len(train) < 1000 or len(test) < 100: continue model = lgb.train(params, lgb.Dataset(train[feature_cols], train["label"]), num_boost_round=300) test = test.copy() test["pred"] = model.predict(test[feature_cols]) preds.append(test) return pd.concat(preds, ignore_index=True)

逻辑说明:train_years=3是经验值,太短模型学不够,太长会包含过时市场结构。retrain_freq="M"表示每月重训一次,也可以按季度。这个循环跑完拿到的pred列,再送进第 2 章的回测函数,得到的才是相对可信的样本外结果。

5.2 组合约束:让回测更接近真实交易

真实组合有约束:单票权重上限、行业中性、不能买停牌股。回测里至少要加单票上限和剔除停牌。如果源码包里没有,自己补:

def apply_constraints(g, pred_col="pred", max_weight=0.02, top_pct=0.2): """在每日横截面上做权重约束""" g = g.dropna(subset=[pred_col]) g = g[g["volume"] > 0] # 剔除停牌/无成交 n = max(1, int(len(g) * top_pct)) top = g.nlargest(n, pred_col).copy() top["weight"] = 1.0 / len(top) top["weight"] = top["weight"].clip(upper=max_weight) top["weight"] = top["weight"] / top["weight"].sum() # 重新归一 return top

逻辑说明:max_weight=0.02限制单票不超过 2%,防止模型对某只股票过度自信。volume > 0剔除停牌,避免用不可交易的价格成交。归一化保证权重和为 1。这些约束会拉低回测收益,但拉低的部分才是真实交易里你拿不到的。

5.3 实盘前的最后检查清单

在把任何模型推向实盘前,我会过一遍这张表:

检查项合格标准不合格怎么办
样本外 IC 均值> 0.02检查特征和标签,别加模型复杂度
ICIR> 0.2看滚动 IC 是否稳定,不稳定就降换手
日均单边换手< 50%拉长 horizon 或加换手惩罚
成本后年化跑赢基准检查成本假设是否太乐观
最大回撤可接受加止损或降仓位
不同种子 IC 差异< 30%用 ensemble 或简化模型

这张表不是让你追求每个指标都漂亮,而是让你知道哪个指标不合格对应哪个动作。IC 不够就回去改特征,换手太高就改标签周期,回撤大就加约束。最怕的是所有指标都「还行」但说不出哪里强,这种策略实盘通常活不过三个月。

5.4 一个我自己的习惯

我现在拿到任何「算法源码+项目说明」的压缩包,第一件事不是跑,而是把backtest和label两个文件从头到尾读一遍,确认没有未来函数、成本假设合理、样本切分正确。这三件事没问题,模型再差也能通过迭代改进;这三件事有问题,模型再好也是自欺欺人。机器学习做股票量化,验证比预测重要,流程比模型重要。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询