简介:面向希望入门深度学习和量化选股的 Python 学习者,这是一份涵盖数据下载、因子选股与模型预测的完整股票分析项目。项目基于 baostock 获取上证50、沪深300、中证500日线数据,并集成回测模块,可按调仓周期选股后与指数对比收益;策略层既包含账面市值比、动量因子、换手率因子等传统思路,也提供 CNN、LSTM、GRU、ResNet18/34/50 等多种预测模型实现,适合作为毕设、课程设计或工程实训的起点。包内共 20 个文件,以 6 个 Python 脚本为核心,辅以 6 张预测结果图、3 个 CSV 数据文件及说明文档,整体仅 4.25MB,轻量易用。已有 284 人学习浏览,对想快速跑通“数据获取—策略训练—回测评估”全流程的读者有直接参考价值。通过对照不同模型的预测图和收益结果,可以直观理解深度学习在股票分析中的应用效果,同时为进一步改进因子组合与网络结构提供可扩展的代码基础。
1. 把深度学习用在股票分析预测上:先搞清楚模型在预测什么
把 python 深度学习这套技术栈用在股票分析预测上,最容易犯的错误不是模型不收敛,而是把回测写成未来函数集锦。这个 stock-prediction-with-DL 项目我拆过一遍,它最有参考价值的地方不在预测准确率,而在于把 data_downloader.py → stock_indicator.py → strategy.py → prediction.py → backtest.py 这条链路完整打通:先用 baostock 拉上证 50、沪深 300、中证 500 日线,再算动量、换手率、账面市值比等因子,然后用 CNN、LSTM、ResNet 对股票池打分排序,最后按调仓周期换仓,把组合收益和指数基准画在同一张图上。毕设、课程设计、工程实训,或者只是想看看别人怎么组织回测框架的进阶学习者,都能照着这套资源把流程整体复现一遍。
2. 数据底座:baostock 拉数、特征计算与 train_data 目录结构
2.1 数据源选型:为什么是 baostock
data_downloader.py 做的事情很纯粹:把日线数据落盘。项目选了 baostock 而不是 yfinance,原因很现实——baostock 免费、不需要注册 token、接口直接返回 pandas DataFrame,对 A 股的支持比海外源稳定得多。yfinance 也能拉 A 股,但字段经常缺、复权口径混乱,回测前要花大量时间清洗。baostock 返回的 date、open、high、low、close、preclose、volume、amount 都是标准命名,preclose 可以直接用来算涨跌幅,adjustflag 标记复权方式,整个清洗成本低不少。
data_downloader.py 的主循环大致是这样的:
import baostock as bs import pandas as pd bs.login() # hs300_stocks.csv 里存的是沪深300成分股代码,带交易所前缀 stocks = pd.read_csv("hs300_stocks.csv")["code"].tolist() for code in stocks: rs = bs.query_history_k_data_plus( code, "date,code,open,high,low,close,preclose,volume,amount,adjustflag", start_date="2019-01-01", end_date="2023-12-31", frequency="d", adjustflag="2", # 2 表示前复权,回测默认用它 ) df = rs.get_data() # 每只股票单独落盘,后续特征计算直接按文件读 df.to_csv(f"train_data/{code}.csv", index=False) print(code, df.shape) bs.logout()这里有几个参数要说明。adjustflag 是 baostock 的复权标记,1 是后复权,2 是前复权,3 是不复权;回测场景我一般用前复权,因为这样计算收益率不会因为除权跳空而失真。frequency 传 "d" 表示日线,想要周线或月线可以改成 "w" 或 "m"。start_date 和 end_date 决定了回测区间,区间越短样本越少,深度学习模型越容易过拟合,建议至少留三年以上日线。另外注意 code 的格式必须是 "sh.600009" 这种带交易所前缀的写法,hs300_stocks.csv 里存的就应该是这种格式,否则 query_history_k_data_plus 会直接查不到数据。
baostock 返回的字段里,有几个在后续模块里特别关键,列成一张表:
| 字段 | 含义 | 在项目里的用途 |
|---|---|---|
| date | 交易日 | 时间轴对齐,窗口切分 |
| open | 开盘价 | 回测调仓时的撮合价 |
| close | 收盘价 | 特征计算与持仓估值 |
| preclose | 昨收 | 涨跌幅计算的基准 |
| volume | 成交量 | 换手率因子 TR 的输入 |
| amount | 成交额 | 流动性过滤 |
| adjustflag | 复权标记 | 确认数据口径是否统一 |
一个实际的坑是:沪深300成分股列表不是一成不变的,每半年会调整一次。data_downloader.py 下载的 hs300_stocks.csv 是一个快照,时间一长股票池就和真实指数对不上了。项目里 train_data 目录下已经有现成的 CSV,但我自己复现的时候会记录下载日期,并且标明数据截止时间,否则后面所有回测结果都失去了“股票池是谁”这个前提。
2.2 特征计算与 train_data 的结构
stock_indicator.py 解决的是特征从哪来的问题。看完 data_downloader 的输出,train_data 里的每个 CSV 列名是统一的,但还不能直接喂给模型,因为原始行情只有价格和成交量,没有模型需要的动量、换手率、账面市值比这些因子。特征计算这一层就是把这些原始列转换成模型输入。
常见做法是写一个 compute_features 函数,把每只股票的 CSV 读进来,按日期排序后逐列生成特征:
def compute_features(df: pd.DataFrame, window: int = 20) -> pd.DataFrame: df = df.sort_values("date").reset_index(drop=True) # 当日收益率,pct_change 已经处理了 preclose 和 close 的差值 df["ret"] = df["close"].pct_change() # 动量因子 MF:过去 window 天的累计涨幅 df["mom"] = df["close"] / df["close"].shift(window) - 1 # 换手率因子 TR:成交量 / 流通股本,需要单独的股本表 df["turnover"] = df["volume"] / df["float_share"] # 账面市值比 BM:每股净资产 / 股价,用来做价值因子选股 df["bm"] = df["book_value"] / (df["close"] * df["total_share"]) # 未来 period 天收益率,作为监督信号 df["future_ret"] = df["close"].shift(-period) / df["close"] - 1 return df逻辑上需要注意几点。ret 用 pct_change 计算的是当日收益率,它依赖前复权价格,如果源数据是不复权价格,这里会算出一个异常值;所以数据下载时必须把 adjustflag 统一成前复权。mom 是动量因子,shift(window) 拿的是 window 天前的收盘价,window 建议和回测的调仓周期保持一致,比如调仓周期是 5 天,window 就设 5 或 10,不要让因子周期和持有周期错位。turnover 里的 float_share 流通股本在 baostock 的日线接口里拿不到,我一般会维护一张独立的股本信息表,或者从本地行情软件导出,实在拿不到就用 amount 代替成交量做近似。bm 里的 book_value 也不是日线接口自带的,需要从财务数据接口或者研报数据库导入。
train_data 目录的另一个作用是固定窗口切分。prediction.py 在读 train_data 里的特征文件时,会用滑窗把每只股票的历史序列切成固定长度的样本:每个样本的形状是 (days, n_features),其中 days 就是过去多少天,n_features 是特征列数,标签则是 future_ret 在未来 period 天的收益。窗口长度、特征数量和调仓周期这三个参数在项目里是耦合的,改其中一个就要同步改另外两个。数据落盘之后,我建议先随机抽一只股票看一眼 CSV 的列数和行数,确认没有 NaN 灌进训练集,再进入模型环节。
3. 选股策略与模型:从 BM/MF/TR 因子到 CNN/LSTM/ResNet 排序打分
3.1 三类传统因子策略:BM、MF、TR 各自的定位
strategy.py 里放了好几种选股策略,最前面的是价值因子 BM,也就是账面市值比选股。逻辑很简单:账面市值比高的公司,股价相对净资产更便宜,理论上应该买入。项目里明确标注了“效果差,由于 BM 在大市值公司的效果不佳”,这个结论和 Liu et al. (2019) 对中国市场的研究是一致的——在剔除小市值股票之后,BM 效应在大市值股票池里几乎消失。A 股的沪深300成分股恰好是大市值为主,所以 BM 在这里区分度很低,回测跑出来的组合很容易变成高杠杆银行股集合。
动量因子 MF 是另一条线:过去一段时间涨幅靠前的股票,在未来一段时间倾向于继续跑赢,这在 A 股是个比较稳健的现象,但窗口长度敏感。动量窗口太短容易陷入反转效应,太长又会吃到趋势末尾的回撤。换手率因子 TR 的思路参考了 Liu et al. (2019) 的 Size and Value in China,项目里修改了数据选取范围,把换手率当成一个截面因子来排序。换手率在 A 股散户主导的市场结构里,对短期内股票收益的解释力往往比 BM 强,因为高换手率反映的是情绪和流动性,而不仅仅是基本面。
这三类因子策略的共同点是:它们都不预测涨跌幅度,而是在每个调仓日对全股票池做截面排序,取前 N 名作为买入名单。这个框架和后面深度学习模型的用法完全一致,只是打分函数从因子值换成了模型输出。
3.2 深度学习模型选股:从预测涨跌改为排序打分
项目里真正的重头戏是用 CNN、LSTM、ResNet 这些模型来选股。这里有一个关键思路要和普通分类任务区分开:模型不是直接输出“明天涨还是跌”的二分类结果,而是对每只股票输出一个分数,分数代表未来一段时间收益的相对高低,然后按分数排序取前 N。这么设计的好处是绕开了涨跌预测的不确定性,把任务转化为排序问题,排序结果和调仓动作直接对应。
CNN 模型的输入是把每只股票过去 days 天的特征堆成一个二维矩阵,把特征通道放在第一个维度,类似图像的多通道输入。我按项目的思路写了一个简化的打分模型:
import torch.nn as nn class CnnScorer(nn.Module): def __init__(self, in_channels=5, days=20): super().__init__() self.conv = nn.Sequential( nn.Conv1d(in_channels, 32, kernel_size=3, padding=1), nn.ReLU(), nn.Conv1d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool1d(1), # 把序列压成一个向量 ) self.fc = nn.Linear(64, 1) # 输出一个分数 def forward(self, x): # x 的形状: (batch, in_channels, days) out = self.conv(x).squeeze(-1) return self.fc(out)这里 in_channels 对应特征列数,days 对应时间窗口长度。Conv1d 在时间维度上做卷积,kernel_size=3 表示每次看相邻三天的局部模式,padding=1 保证输出序列长度不变。AdaptiveAvgPool1d(1) 把整条序列压缩成一个向量,再接全连接层输出标量分数。之所以用池化而不是直接展开,是因为池化能保留主要的时序模式,同时大幅减少参数,降低过拟合风险。
LSTM 模型则用序列方式处理同样的窗口:
import torch.nn as nn class LstmScorer(nn.Module): def __init__(self, input_size=5, hidden=32): super().__init__() self.lstm = nn.LSTM(input_size, hidden, batch_first=True) self.fc = nn.Linear(hidden, 1) def forward(self, x): # x 的形状: (batch, days, input_size) out, _ = self.lstm(x) return self.fc(out[:, -1, :]) # 只用最后一个时刻的隐状态注意和 CNN 的输入维度顺序不同:CNN 是 (batch, channels, days),LSTM 是 (batch, days, features),这是新手最容易搞混的地方,维度错位会导致模型能跑但永远不收敛。LSTM 的输出取最后一个时间步的隐状态,因为它携带了整个序列的压缩信息。项目里还提到 reluRNN 和 tanhRNN,指的是循环单元里激活函数选择 ReLU 或 tanh;reluRNN 在部分场景收敛更快,而 tanhRNN 在长序列上更容易出现梯度饱和,这也是 GRU 与 tanhRNN 组合效果不佳、被项目放弃的主要原因。
3.3 模型与策略的取舍表
把 strategy.py 和 prediction.py 里涉及的策略汇总成一张对比表,更方便看到项目保留了哪些、放弃了哪些:
| 模型/策略 | 输入形式 | 状态 | 说明 |
|---|---|---|---|
| BM 价值因子 | 截面财务数据 | 保留但效果差 | 大市值股票池区分度不足 |
| MF 动量因子 | 过去 N 日价格 | 保留 | 窗口敏感,需调参 |
| TR 换手率因子 | 成交量/流通股本 | 保留 | 参考 Liu 2019,修改了数据选取 |
| CNN | 窗口特征矩阵 | 保留 | 把表格数据当图像特征处理 |
| LSTM + reluRNN | 序列特征 | 保留 | 循环模型中的稳定组合 |
| GRU + tanhRNN | 序列特征 | 已放弃 | 效果不佳 |
| ResNet18/34/50 | 窗口特征矩阵 | 保留 | 网络加深,但要注意过拟合 |
项目里“效果不佳已放弃”这句话很值得重视。很多课程设计会把所有模型都塞进最终报告,但一个真实项目里去掉不work的方案本身也是产出,说明作者认真跑过对比实验。ResNet18/34/50 在图像分类里表现好,但股票数据样本量远小于图像集,层数加深带来的收益很容易被过拟合吃掉,后面避坑章节我会专门展开讲这个问题。
4. 回测模块:调仓周期、开盘价撮合与基准对比的完整逻辑
4.1 backtest.py 主循环:调仓、打分、换仓
回测是整个项目里最容易被低估的部分。模型打分再准,回测逻辑写错,结果也全是虚的。backtest.py 的核心循环按“调仓周期”驱动:每隔 period 个交易日,站在当天对全股票池打分,选出分数最高的 top_n 只,然后在下一个交易日开盘价买入;持有到下一个调仓日,同样按开盘价卖出并换入新的组合。
我按项目的描述把主循环拆成伪代码,实际项目里的结构也大同小异:
def run_backtest(df_dict, model, top_n=10, period=5, start_cash=1_000_000): cash = start_cash holdings = [] equity_curve = [] trading_days = sorted(df_dict["sh.600009"].index) # 用任意一只股票的日期做时间轴 for i, day in enumerate(trading_days): if i % period != 0: # 只在调仓日做操作 equity_curve.append(current_value(cash, holdings, day)) continue scores = {} for code in df_dict: window = build_feature_window(df_dict[code], day) scores[code] = model.predict(window) # 模型输出分数 ranking = sorted(scores.items(), key=lambda x: -x[1])[:top_n] # 先卖出旧持仓,再按当日收盘后确定、次日开盘价买入 sell_old(holdings, day) holdings = buy_new(ranking, day, open_price=df_dict[code]["open"]) return equity_curve这段代码里有几个隐含假设要说明。period 是调仓周期,period=5 表示每周调仓一次,period=20 表示每月调仓一次。top_n 控制组合持股数量,我一般在沪深300股票池里取 10 到 30 只,太少会放大单只股票波动,太多会稀释模型选股效果。暂停了指数比较环节,每日先记录组合市值,最后整体和基准对比。
开盘价撮合是“暂定”的简化方案,项目注释里也写了“暂定”。实际运行时有两个边界情况需要处理:一是股票一字涨停时开盘价就是涨停价,根本买不进;二是停牌股票没有开盘价,buy_new 时遇到 NaN 会直接把回测跑崩。我一般在撮合前加一层过滤,把停牌和开盘即涨停的股票从候选列表里剔除,这样回测结果更接近真实可成交的情况。
4.2 收益计算、基准对比与结果图怎么读
回测输出的结果图分两类。一类是 sh.600009_predict.jpg、sh.600000_predict.jpg 这样的单只股票预测图,展示模型对个股未来收益的预测值和实际值的拟合情况,用来判断模型有没有学到有效模式;另一类是 result.jpg,这是组合收益曲线和基准指数的对比图,也是判断策略是否有效的核心依据。
收益计算的细节决定回测可信度。组合整体收益要按每日持仓市值加总计算,不能简单的把每日收益率平均。基准对比时项目用的是指数本身,常见的做法是下载沪深300指数的日线数据作为基准线,把基准的区间涨幅归一化到和组合同样的起点,再画在同一张图上。如果组合曲线长期在基准线之上,并且回撤可控,策略才算初步成立。
我常用的一组回测参数如下:
| 参数 | 常见值 | 说明 |
|---|---|---|
| 初始资金 | 1,000,000 | 不影响收益率,只影响绝对收益 |
| 调仓周期 | 5 / 10 / 20 天 | 越长交易成本越低,信号滞后越大 |
| 持股数量 | 10 ~ 30 只 | 太少波动大,太多信号被稀释 |
| 手续费 | 万 3 + 印花税 | 回测里可以按单边成本估算 |
| 基准 | 沪深300指数 | 与股票池对齐 |
还要提一点:深度学习模型在回测里很容易出现“看起来很强,实盘没法用”的情况,因为模型打分依赖的特征在回测区间内是已经发生的,而训练时如果用了全区间数据,模型就提前看到了未来。这一点直接关系到回测结果是否可信,我在避坑章节里会详细讲。
5. 避坑与排查:五个让回测业绩翻车的细节
5.1 用全样本训练模型:回测年化漂亮,实盘难复制
现象:训练集和回测区间重叠,模型在回测里年化收益 60%,之后拿到新数据验证,收益断崖式下跌。
原因:这是典型的未来数据泄漏。很多初学者把全部 train_data 丢进模型训练,再在同一个区间做回测,模型在打分时相当于已经看过答案。股票数据是时间序列,不能像图像分类那样随机打乱切分,因为未来信息会通过训练样本泄漏到回测打分里。
解决:按时间顺序切分。训练截止日必须早于回测开始日,比如用 2019 到 2022 的数据训练,2023 年做回测。调仓周期越长,这个边界越重要。我一般会再加一层验证:把训练集最后 10% 单独切出来做验证集,用早停控制训练轮数,确保模型没有把验证集也背下来。
5.2 复权口径不一致:某天持仓收益突然跳变
现象:回测曲线整体平稳,但某天持仓收益突然出现 +70% 或 -50% 的巨大跳变,随后又恢复正常。
原因:分红除权导致价格跳空。如果下载数据时用了不复权或者部分股票复权、部分不复权,除权日当天收盘价会人为下跳,按 close 算出来的收益率就是假的巨额亏损;反过来,如果只用后复权做特征、前复权做回测,两套数据拼接在一起也会出现同样的错位。
解决:从 data_downloader.py 这一步就统一复权口径。回测整体用前复权,特征计算也用同一份数据,不要在中间环节混用。另外检查 train_data 里每只股票 CSV 的 adjustflag 字段是否一致,批量下载时很容易因为某只股票请求失败导致漏掉复权参数。
5.3 没过滤 ST、停牌、次新股:回测买入了根本买不到的股票
现象:回测报告显示策略在某段时期大幅跑赢基准,打开持仓明细一看,买的全是 ST 和开盘一字涨停的股票。
原因:成分股列表里包含 ST 股,模型打分时也会给 ST 股分数;停牌股票在调仓日没有 open 价,但历史数据在回测中会“假装”可以成交。新股上市初期涨停板连续,模型很容易给高动量分数,实盘根本抢不到筹码。
解决:在数据预处理阶段就做过滤。常见做法是先剔除名称带 ST、*ST 的股票,剔除停牌日,剔除上市不足 60 天的次新股。过滤逻辑放在 build_feature_window 之前,而不是回测撮合阶段,因为漏进训练集的脏样本同样会污染模型。
5.4 BM 价值因子在大市值股票池里失效
现象:用账面市值比排序选股,回测跑赢不了沪深300基准,组合成分往往集中在银行、基建这类低估值板块。
原因:项目正文里已经写了“BM 在大市值公司的效果不佳”,这和 Liu et al. (2019) 的结论吻合。大市值公司本身估值分化小,账面市值比的截面区分度低,用它排序和随机选股差别不大。A 股的 BM 效应主要集中在剔除最小市值股票之后的 mid-cap 区间,直接套在沪深300上会失效。
解决:把 BM 从排序因子降级为过滤条件,比如只排除 BM 最低的 20% 股票,再用动量或模型打分排序。或者换用 EP(盈利收益率)、SP(营收市值比)等替代价值因子,它们在沪深300股票池里的分层效果通常比 BM 好。这类经验只适合 A 股,换到美股市场结论会不一样。
5.5 ResNet 深层模型在小样本上过拟合
现象:ResNet50 在训练集上 loss 降到很低,准确率接近 95%,但回测收益反而不如简单的 CNN 模型。
原因:股票数据即使拉五年,沪深300也才几百只股票,切出来的有效窗口样本不过几千条,而 ResNet50 的参数量在千万级,模型很容易把训练集的噪声背下来。深度学习在图像上的成功建立在百万级数据量上,股票数据达不到这个量级,深层网络的收益远小于过拟合的代价。
解决:优先用 ResNet18 这类浅层残差网络,并加 L2 正则化和 dropout。L2 权重衰减系数一般从 0.0001 起步,dropout 放 0.3 到 0.5。训练时设一个早停 patience,比如验证集 loss 连续 10 个 epoch 不下降就停止。如果浅层模型和深层模型回测差异不大,直接选浅层,省下来的训练时间可以用来调调仓周期。
6. 进阶用法:把单模型回测升级成滚动再训练框架
项目原始流程是训练一次模型,然后在回测区间里反复用这个模型打分。这样做有个隐患:模型的训练数据截止日距离回测后期越来越远,市场风格切换后模型的排序能力会衰减。我复现之后第一个改造就是把单次训练改成滚动再训练,这也是实盘模拟中最接近真实的做法。
滚动框架的核心思路是:每隔 retrain_every 个交易日,用截止到当天的所有历史数据重新训练一次模型,然后用这个刚训练好的模型对下一个调仓周期打分。这样每个调仓日用的模型,都只见过该日之前的数据,完全杜绝未来函数。
def rolling_train(all_data, train_start=500, retrain_every=20): results = [] for i in range(train_start, len(all_data), retrain_every): train_data = all_data[:i] # 只用截止到 i 的数据 model = train_model(train_data) # 每次重新训练 y_pred = model.predict(all_data[i:i + retrain_every]) # 预测未来一段 results.append(y_pred) return results参数上 train_start 决定第一次训练用多长历史,我一般设 500 个交易日以上,太短模型学不到稳定的跨周期模式。retrain_every 和调仓周期保持一致或取它的整数倍,比如调仓周期 5 天,retrain_every 可以设 20 天,减少重复训练的开销。这个改动会让回测年化收益比之前低,因为模型不再偷看未来数据,但换来的结果是每个数字都敢拿去模拟盘验证。
我自己的血泪经验是:第一次跑滚动框架时,回测收益比全样本训练低了将近一半,当时一度怀疑是不是代码写错了。后来把两个版本的持仓明细拉出来对比,全样本版本里模型在靠近训练集末尾的位置预测异常准确,明显是背了答案。从那以后,我每次改模型或改动因子,都强制先跑一遍滚动回测再谈优化,这个习惯帮我避开了至少三次“看起来很强、实盘没法用”的假信号。希望帮到你。
本文还有配套的精品资源,点击获取