简介:这是一份基于深度学习的股票量化交易完整项目,适合深度学习、人工智能方向的毕业设计或课程设计,也适合希望将RNN、LSTM等时序模型应用于金融数据的学习者。项目围绕历史行情数据的处理、股价预测与交易策略生成展开,包含数据下载与预处理、多套深度学习模型(如NLinear、DLinear、TFT、TSMixer等)、策略回测与止损逻辑、模型监控脚本及单元测试,结构清晰,便于二次开发与论文实验。资源压缩包共49个文件,以42个Python脚本为主,另有2个Jupyter Notebook演示流程、1个Markdown说明文档及环境配置、测试配置、启动脚本等文件,整体大小仅2.93MB,轻量且易于部署。代码覆盖从数据加载到模型训练、评估与实盘监控的完整链路,并包含pytest测试用例,帮助验证项目可靠性。目前已有91人学习该资源,可作为股票量化入门与深度学习实践的可运行参考。
1. 基于深度学习的股票量化交易:先别急着调网络,这三点决定毕设能不能过
2019年我第一次用深度学习跑股票预测时,模型在训练集上表现近乎完美,换到模拟盘却持续亏损。后来才发现,问题根本不在网络结构,而在数据复权、特征延迟和验证集划分上。这套基于深度学习的股票量化交易资源,就是围绕LSTM序列预测、特征工程、回测框架做出来的一套可复现项目,定位是Python方向毕业设计或课程设计。它把“股价预测—交易信号—策略回测”整条链路串了起来,拿到的不是孤立的模型文件,而是一套能跑通全流程的代码骨架。适合三类人:需要用深度学习做毕业设计的同学,刚入门量化交易还没头绪的从业者,以及想快速验证“深度学习能不能预测股票”的好奇家。下面我从数据、模型、验证、回测一路拆开讲清楚,并附上可以直接抄作业的代码和参数。
2. 数据与特征:量化交易的“底座”也是深度学习最容易翻车的地方
2.1 数据源选型:tushare 和 akshare,复权为什么是第一条红线
做量化交易,第一步不是搭模型,而是把行情数据摸清楚。A股日线数据常用的两个Python库是tushare和akshare。tushare Pro接口返回字段规范,但高频接口和部分历史数据需要积分;akshare完全免费,接口直接读公开网页数据,对毕设来说够用。我一般建议用akshare起步,因为零成本、无token,跑通全流程后再考虑换tushare补专业字段。
真正决定成败的是复权。股票会除权除息,如果直接用不复权价格,你会发现历史价格在除息日出现人工“跳空”,模型会把这种跳空当成真实波动,训练出来的预测结果在实盘里几乎必然翻车。常见的做法是使用前复权或后复权数据。前复权保持最新价格真实,历史价格被调整;后复权保持历史价格不变,把分红配股折算进去。我一般在训练模型时使用后复权,因为它的历史价格连续、可比;但回测成交时要注意后复权价格不能直接用于计算实际盈亏,需要转换口径,所以更保险的做法是全部统一成前复权,保证训练和回测看到的价格完全一致。
下面这段代码用akshare拉取日线数据,并重命名为标准格式。
import akshare as ak import pandas as pd # 拉取A股日线数据,前复权 df = ak.stock_zh_a_hist( symbol="000001", period="daily", start_date="20100101", end_date="20231231", adjust="qfq" ) # 统一列为标准格式 df.columns = [ "date", "open", "close", "high", "low", "volume", "amount", "amplitude", "pct_change", "change", "turnover" ] df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) print(df.head())逻辑说明:symbol传股票代码,start_date和end_date传字符串日期,adjust指定复权方式。akshare返回的原始列名是中文,我会在拿到数据后立刻重命名为英文,避免后面pandas链式操作里反复处理中文字段。排序和时间格式转换是为了保证后续生成滑动窗口样本时时间顺序正确。
这里的几个参数值得记一下:adjust="qfq"是前复权,adjust="hfq"是后复权,adjust=""是不复权。如果做预测和回测,全程只用一种复权,不要训练用后复权、回测用前复权,这是很多毕设翻车的第一根导火索。另外,akshare返回的数据未必严格按时间升序排列,sort_values这步不能省,序列模型对顺序极其敏感。
2.2 特征工程:把行情数据变成模型能吃的张量
拿到行情数据后,下一件事是构造特征。深度模型本身有特征提取能力,但股票数据信号极弱、噪声极大,合理的特征能显著降低过拟合。我一般做三类特征:基础行情特征,比如收益率、振幅、量比;技术指标特征,比如均线、RSI;滑动窗口统计特征,比如过去N日的最高最低价。注意所有特征只能使用过去已经发生的数据,任何用到“当天收盘以后才能知道”的全局值都会造成未来函数。
这里先用pandas计算一批基础特征。收益率单独保留,因为它后面要作为预测标签;均线、量比、高低价范围都做滚动窗口统计。
def calc_rsi(series, window=14): delta = series.diff() gain = delta.clip(lower=0).rolling(window).mean() loss = (-delta.clip(upper=0)).rolling(window).mean() rs = gain / (loss + 1e-10) return 100 - 100 / (1 + rs) def build_features(df): df = df.copy() # 标签:下一个交易日收益率,后面在生成样本时再赋值 df["ret"] = df["close"].pct_change() df["ma5"] = df["close"].rolling(5).mean() df["ma10"] = df["close"].rolling(10).mean() df["ma20"] = df["close"].rolling(20).mean() df["vol_ratio"] = df["volume"] / df["volume"].rolling(5).mean() df["high_low_range"] = (df["high"] - df["low"]) / df["close"] df["rsi"] = calc_rsi(df["close"], 14) return df.dropna().reset_index(drop=True)逻辑说明:pct_change()计算当日相对前一日的涨跌幅,本身自带一阶差分,能去掉价格序列的非平稳性。rolling(5).mean()是过去5天的均值,输出对齐到当前日期,但只用到了当前及之前的历史数据,没有未来信息。vol_ratio衡量量能变化,是A股短线交易里敏感性较高的特征。calc_rsi里我加了1e-10防止除零,这在RSI高位钝化时很常见。
参数方面,均线周期5、10、20对应短中长趋势;RSI窗口14是标准参数。如果你做的是分钟级数据,这些窗口要缩短;日线数据用这套已经足够。特征不是越多越好,我见过有人塞进50个特征,模型训练慢、过拟合严重,最后调半天不如用10个特征加一层Dropout。
有了特征列后,需要把数据切成滑动窗口样本。假设用过去20个交易日的特征预测下一个交易日的收益率,样本形状要为三维:(样本数, 20, 特征数)。
def make_sequences(df, feature_cols, seq_len=20): arr = df[feature_cols].values X, y = [], [] for i in range(len(arr) - seq_len): X.append(arr[i:i + seq_len]) y.append(df["ret"].iloc[i + seq_len]) return np.array(X), np.array(y) feature_cols = [ "close", "ret", "ma5", "ma10", "ma20", "vol_ratio", "high_low_range", "rsi" ] X, y = make_sequences(df, feature_cols, seq_len=20) print(X.shape, y.shape)逻辑说明:arr[i:i+seq_len]取的是第i天到第i+seq_len-1天的特征,标签是第i+seq_len天的收益率,也就是说用前20天预测第21天的涨跌。这一步最容易出现标签泄露:如果你把第i+seq_len天的close也塞进了特征,模型相当于直接看到了答案。所以我生成样本时,特征只到i+seq_len-1,标签单独取下一行。feature_cols里特意没有放未来收益率。
参数说明:seq_len是最重要的超参数之一。日线数据用20基本覆盖一个月的交易日,分钟数据可以设60到120。这个值不要太小,太小模型学不到趋势;也不要太大,太大模型结构要加深,训练时间成倍上涨,而且A股风格切换快,过长的历史窗口反而引入噪声。
到这里,数据准备已经完成,下一步就要考虑模型选型和对比实验设计。
3. 模型选型与结构设计:用LSTM/GRU/MLP三组对比撑起毕设
3.1 为什么是LSTM:时序依赖和梯度消失
股票数据是一个严格的时间序列,前后交易日之间不是独立同分布的。MLP把每个时间点的特征当作独立输入,完全看不到时间顺序;CNN虽然能用卷积核捕捉局部模式,但卷积核的平移不变性在时间维度上并不总适用,比如“第1天暴涨后第3天回调”和“第10天暴涨后第12天回调”,在不同时间位置,CNN会强行认为它们是同一种模式。LSTM则通过门控机制逐步更新记忆状态,天然保留序列顺序,可以学到“连续缩量后放量突破”这类依赖时间组合的模式。
LSTM能长期记忆的代价是参数量大、训练慢,所以实际实现中第一层LSTM会设置return_sequences=True,让每个时间步都输出隐藏状态,作为下一层LSTM的输入,这样第二层就能继续捕捉更高层的时序模式。GRU是LSTM的变体,把三个门简化成两个门,参数量更小,在小数据集上往往更稳。Transformer目前是深度学习里的热门方向,但在股票这种短序列、低信噪比数据上,注意力机制很容易被噪声带偏,需要更多数据和更长的训练时间,对毕设来说性价比不高。
3.2 对比实验设计:用同一个数据接口切换三种模型
给毕业设计写对比实验时,最忌讳的是三个模型各自写一套独立的代码,特征、切分、标准都不一样,最后结果无法归因。我更常用的做法是写一个通用的模型工厂函数,只改网络结构,其他全部复用。这样调参和整理实验对比都省心。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Dropout, LSTM, GRU, Flatten def build_model(kind, input_shape): model = Sequential() if kind == "lstm": model.add(LSTM(64, input_shape=input_shape, return_sequences=True)) model.add(Dropout(0.2)) model.add(LSTM(32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(16, activation="relu")) elif kind == "gru": model.add(GRU(64, input_shape=input_shape, return_sequences=True)) model.add(Dropout(0.2)) model.add(GRU(32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(16, activation="relu")) elif kind == "mlp": model.add(Flatten(input_shape=input_shape)) model.add(Dense(128, activation="relu")) model.add(Dropout(0.3)) model.add(Dense(64, activation="relu")) model.add(Dropout(0.3)) model.add(Dense(16, activation="relu")) else: raise ValueError(f"unknown kind: {kind}") model.add(Dense(1)) model.compile(optimizer="adam", loss="mse") return model lstm_model = build_model("lstm", (seq_len, num_features)) gru_model = build_model("gru", (seq_len, num_features)) mlp_model = build_model("mlp", (seq_len, num_features))逻辑说明:三个模型的输入形状完全相同,都是(seq_len, num_features),输出都是单个标量。LSTM和GRU都用了两层循环网络加两层Dropout,MLP则先Flatten把(20, 8)的二维序列拉平成(160,)的一维向量,再走全连接层。这样的对比能直接看出循环结构相对全连接结构到底带来了多少提升,而不是因为参数数量不同导致的差异。
参数说明:input_shape传的是(seq_len, num_features),Keras会忽略第一维的batch大小。LSTM和GRU的隐藏单元都保持64和32,MLP的中间层用了128和64,这是为了让参数量大致在同一量级,避免“模型A参数多所以效果更好”这种解释不清的情况。Dropout(0.2)和Dropout(0.3)的差异来自经验:MLP没有时序结构,更容易过拟合,所以dropout稍微调大一点。compile统一用Adam和MSE损失,保证优化器差异不影响结果。
写完这组代码后,你会得到三份逻辑完全一致、只有网络结构不同的模型。后面训练、验证、回测都用同一套数据,论文里可以理直气壮地写“在相同数据集和超参数下对比了LSTM、GRU和MLP”。这也是深度学习毕设里比较稳妥的实验组织方式。
训练部分需要单独说,因为那里有量化项目最大的一个隐性坑:未来函数。
4. 训练与验证:时间序列切分和标准化是未来函数的重灾区
4.1 按时间切分,不要随机打乱
通用深度学习教程倾向于随机打乱样本再切分训练集和测试集,在量化项目里这是最典型的未来函数来源。股票样本之间存在时间关联,随机打乱后模型可能会在训练时看到未来某段行情的中间片段,然后在测试时用训练阶段记住的模式去“预测”过去,结果虚高。正确的做法是严格按照时间先后切分:训练集最早,验证集居中,测试集最后。
split = int(len(X) * 0.8) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:] val_split = int(len(X_train) * 0.8) X_train, X_val = X_train[:val_split], X_train[val_split:] y_train, y_val = y_train[:val_split], y_train[val_split:]逻辑说明:X是按时间升序排列的样本序列。先切出最后20%作为测试集,保证它时间上最晚;再把训练集最后20%切出来做验证集。验证集晚于训练集、早于测试集。这三段在时间上没有任何重叠,也不存在未来信息。
需要注意:这里的split是基于样本索引,因为样本是按时间顺序生成的,所以索引顺序天然对应时间顺序。如果你在数据预处理阶段把DataFramereset_index过,样本顺序就更可控了。毕设里常见的错误是直接对全部样本随机shuffle,然后loss和accuracy都好看,实际一上模拟盘就原形毕露。
4.2 标准化:训练集fit,验证集和测试集只做transform
标准化也是未来函数的高发区。很多人在预处理阶段对X整体做StandardScaler.fit_transform,这等于用测试集的均值和方差去调整训练集的数据,测试集的信息在训练前就暴露给了模型。正确的做法是只在训练集上fit,然后把训练集、验证集、测试集都交给同一个训练好的scaler去transform。
from sklearn.preprocessing import StandardScaler def reshape_back(data): return data.reshape(data.shape[0], data.shape[1], data.shape[2]) scaler = StandardScaler() X_train_2d = X_train.reshape(-1, X_train.shape[-1]) scaler.fit(X_train_2d) X_train = reshape_back(scaler.transform(X_train_2d)) X_val = reshape_back(scaler.transform(X_val.reshape(-1, X_val.shape[-1]))) X_test = reshape_back(scaler.transform(X_test.reshape(-1, X_test.shape[-1])))逻辑说明:StandardScaler会计算每个特征维度的均值和标准差。reshape(-1, last_dim)把三维序列样本展平成二维矩阵,每个特征一列,这样scaler才能按特征维度计算统计量。然后用scaler.transform分别处理三段数据。训练集仅参与了fit,验证集和测试集都没有参与,所以测试集的均值和标准差不会泄露到训练阶段。
参数说明:这里没有额外参数,但请留意reshape的顺序。(样本数, seq_len, 特征数)展平后变成(样本数*seq_len, 特征数),transform后再恢复成原来的三维形状,顺序不能搞错。这个坑我踩过,恢复形状时把seq_len和特征数写反,训练出来的模型loss很高,检查很久才发现是数据形状错位。
4.3 训练回调与评估指标:早停、学习率衰减、方向准确率
训练部分的老规矩是早停加学习率衰减。对股票数据,我还会额外看方向准确率,也就是预测涨跌方向和实际涨跌方向的一致性。MSE只能告诉你误差大小,但判断策略能不能赚钱,方向比幅度更接近本质。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau callbacks = [ EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True), ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=5, min_lr=1e-5) ] history = lstm_model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=callbacks, verbose=1 ) import numpy as np pred_val = lstm_model.predict(X_val).flatten() direction_acc = np.mean((pred_val > 0) == (y_val > 0)) print("validation direction acc:", direction_acc)逻辑说明:EarlyStopping连续10个epoch验证集loss不降就停,restore_best_weights=True让模型回到最佳权重。ReduceLROnPlateau在验证集loss连续5个epoch不降后把学习率减半,避免在局部最优点附近震荡。方向准确率的计算很简单,把预测值和真实值都映射成“涨/跌”两个方向,然后比较一致性;0.5是随机水平,一般能做到0.52到0.55就算有一定预测能力。
参数说明:patience设10是针对日线数据量较小的选择,如果你用分钟级数据,样本量很大,可以设20,让训练更充分。batch_size=64在日线级别比较稳,太大(比如256)会让梯度更新平滑但收敛慢,太小(比如16)训练噪声大。epochs=100是上限,实际训练中触发了早停一般50到70个epoch就结束。方向准确率不是越高越好,如果超过0.6,你要警惕是不是数据泄露了,而不是模型真的变强了。
模型本身跑通之后,还需要把预测值变成交易信号和回测结果。这一步才是毕设真正落地的地方。
5. 深度学习量化交易常见问题与避坑:五个真实踩坑记录
5.1 数据和特征层面的坑
踩坑记录一:复权方式不统一
现象:模型预测信号看起来有规律,回测时收益率曲线却和预测方向对不上,甚至信号在除权日前后出现突变。
原因:训练集用的是akshare前复权数据,回测时却拿了另一个接口的不复权数据,除权日价格断层被当成了真实波动。
解决:从数据获取到回测结束,全程只使用同一种复权参数。我习惯把训练和回测统一为前复权,因为前复权的最新价格就是真实成交价,回测结果可直接对比。
踩坑记录二:特征里混入了未来数据
现象:验证集方向准确率高达0.68,模拟盘一个月却持续亏损。
原因:构造样本时把当天的close直接当作当天的特征,当天收盘价只能收盘后才知道,如果用当天收盘价计算信号并在当天开盘时交易,就是典型的未来函数。
解决:信号一律延迟一天。具体做法是使用t日收盘后的数据计算特征,生成t+1日交易信号;回测时对模型预测结果做shift(1),让信号在下一根K线才生效。
踩坑记录三:全数据集标准化
现象:训练集和测试集loss都很低,模型在原来那只股票上表现不错,换一只股票重训完全失效。
原因:对全部X做了fit_transform,测试集的均值和方差参与了训练集标准化,模型提前接触了未来的分布信息。
解决:只在训练集上fit``StandardScaler,再transform验证集和测试集。如果要换股票,重新训练时必须重新拟合scaler。
5.2 模型和回测层面的坑
踩坑记录四:直接预测价格,模型偷懒复制前一天收盘价
现象:MSE很低,但把预测值画出来看,基本等于前一天的收盘价,完全没有抓住涨跌方向。
原因:价格序列非平稳,模型发现最优解就是把当前值复制到下一个时刻,这会让loss变得很小,却没有任何可交易信息。
解决:把标签改为下一日收益率或价格差分。第2章代码里ret列就是干这个的,输出层不加激活函数,直接回归收益率。
踩坑记录五:回测里没有扣手续费和滑点
现象:策略在回测中年化收益60%,放到实盘或者仿真环境里变成了亏损。
原因:每次调仓都有佣金、印花税和滑点,日频调仓一年几百笔,交易成本会吃掉大部分收益。
解决:在回测函数里加入双边成本。我的习惯是手续费按万二点五、滑点按单边千分之一估算,调仓越频繁成本越高,这部分必须提前写进回测逻辑。
这五个坑基本覆盖了从数据到回测的完整链路,也是量化毕设里最常见的翻车点。下面把回测逻辑完整展开,给你看真正可用的信号生成和收益计算怎么写。
6. 策略回测与落地:把预测收益率变成可执行交易信号
6.1 从预测到信号:阈值、持仓周期和信号延迟
模型输出的是下一日收益率预测值,不能直接拿去交易。实际中我设一个阈值,只有预测收益率超过阈值才开仓,避免被微小噪声反复触发。A股又不支持当日买卖,所以信号天然要延迟一天执行,这也顺带阻断了很多未来函数。
def signal_from_pred(pred, threshold=0.005): return (pred > threshold).astype(int)逻辑说明:threshold=0.005表示预测下一日收益率超过0.5%才产生持仓信号。这个阈值不是固定的,如果预测值普遍偏低,可以改成0.002;如果你做的是空头策略,还可以加一个负阈值生成反向信号。我一般用验证集的方向准确率来定阈值,尽量让信号触发频率在20%到30%之间,避免频繁换仓。
6.2 回测指标计算与无未来函数自检
回测的核心是两条:信号不能提前执行,成本必须计入。下面这段代码把预测收益率、真实收益率、信号和手续费放在一起,生成每日账户收益序列。
def backtest_returns(pred, y, threshold=0.005, fee=0.0003): from pandas import Series pred = Series(pred, index=y.index) signal = (pred > threshold).astype(int) # 信号延迟一天,防止用当日预测做当日交易 position = signal.shift(1).fillna(0) # 当日收益 = 当日真实收益率 * 持仓状态 strategy_ret = y * position # 双边成本:每次仓位变化都按费用扣除 trade_turnover = position.diff().abs().fillna(0) net_ret = strategy_ret - trade_turnover * fee * 2 return net_ret, position def calc_metrics(net_ret): final_equity = (1 + net_ret).prod() years = len(net_ret) / 252 annual_return = final_equity ** (1 / years) - 1 if years > 0 else 0 annual_vol = net_ret.std() * (252 ** 0.5) sharpe = annual_return / annual_vol if annual_vol != 0 else 0 equity = (1 + net_ret).cumprod() peak = equity.cummax() drawdown = (equity - peak) / peak max_drawdown = drawdown.min() return { "annual_return": annual_return, "sharpe": sharpe, "max_drawdown": max_drawdown }逻辑说明:signal.shift(1)是整个回测里最关键的一行。预测结果产生在t日收盘后,最早只能执行t+1日交易,所以持仓状态必须是从前一天的信号推导出来的。trade_turnover * fee * 2模拟了买入和卖出两次成本,仓位不变时为零,只有调仓时才扣钱。calc_metrics里用252个交易日作为一年的基准,换算年化收益、夏普比率和最大回撤。
参数说明:fee是单边手续费率,A股实际佣金、印花税还要考虑资金量,这里用0.0003是保守估计。threshold和fee是回测里最敏感的两个参数,你要记得做敏感性分析,调大调小都试一下,而不是只看一组参数下的漂亮曲线。回测出来如果年化收益超过30%,大概率是数据泄露或者成本没扣够,我会回头检查这三件事:是否用了前复权、信号是否shift了、成本是否计入了。
这个项目的最后,我习惯再用一个无未来函数自检:把信号日期和持仓日期打印出来,确认每一条信号都比持仓早一个交易日。如果是日线级别,你还可以用当天的close预测第二天的收益,信号却在第二天open才执行,这样从时间戳上就没有任何模糊空间了。从那以后,我每次做量化模型都要强制走一遍“复权统一—特征延迟—先切分后标准化—回测扣成本”这套流程,踩过的坑基本都能提前拦住。希望这些落地细节能帮你在毕设或实盘路上少走一段弯路。
本文还有配套的精品资源,点击获取