☰
Python深度学习股票预测:数据、模型与回测避坑指南
2026/10/1 5:38:45 网站建设 项目流程

简介:基于Python的深度学习与股票分析预测项目,面向希望跨领域学习量化选股与深度学习技术的小白及进阶学习者,可作为毕设、课程设计、大作业或工程实训选题。资源共20个文件,含6个Python脚本、6张预测结果图、3个CSV数据文件及说明文档等,压缩包4.25MB,目录结构清晰,便于按模块拆解学习。目前已有284人学习下载。项目提供数据下载、回测与策略三大核心模块:通过baostock获取上证50、沪深300、中证500日线数据;回测模块按调仓周期选股并以开盘价买卖、计算收益对比指数绘图;策略部分覆盖价值因子(BM)、动量因子(MF)、换手率因子(TR)等传统选股思路,同时实现CNN、LSTM、GRU、ResNet18/34/50多种深度学习预测模型,并对各因子与模型效果给出对比注释,便于理解不同方法在大市值股票上的适用性与局限。

1. 这篇标题在讲什么:用 Python 深度学习做股票预测,真正能落地的边界在哪

把“基于 Python 的深度学习与股票分析预测”拆开看,其实就是两条技术线的交汇:一条是 Python 生态里的数据获取、特征工程、模型训练与回测,另一条是深度学习在时间序列上的拟合能力。大多数教程卡在“跑通一个 LSTM 模型”就结束了,但真正干过这行的人都知道,预测准确率只是入场券,特征怎么构造、标签怎么打、数据会不会泄漏、回测为什么看着赚钱实盘却亏钱,这些才是决定方案能不能用的关键。这篇笔记面向想用 Python 把深度学习落到股票分析上的工程师和量化初学者,不预设你有金融背景,但默认你写过 Python、跑过深度学习分类任务。读完你会知道环境怎么搭、数据怎么取、模型怎么选、回测怎么设计,以及哪些地方属于“看着有道理、实盘就翻车”的玄学区。

2. 数据先行:把行情数据变成监督学习的输入

2.1 取数:先用免费数据源把日线、指数和分钟线拉齐

做股票预测的第一步不是搭模型,而是搞定数据。常见做法是用akshare或tushare这类免费 Python 库拉日线行情,再配合pandas做清洗。下面这段代码以akshare为例,拉取某只股票的日线数据,并转成模型需要的格式:

import akshare as ak import pandas as pd # 拉取 A 股日线,复权方式用前复权,避免除权跳空 df = ak.stock_zh_a_hist( symbol="000001", period="daily", start_date="20150101", end_date="20241231", 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) # 只需要模型真正用到的列,降低 IO 和内存压力 df = df[["date", "open", "close", "high", "low", "volume", "amount"]] print(df.head())

这段代码的核心是ak.stock_zh_a_hist这个接口,symbol是股票代码,adjust="qfq"表示前复权。前复权的重要性在于:如果股票发生分红或送股,不复权的价格会产生人为跳空,模型会把这个跳空当成真实价格波动去学习,属于典型的“数据噪音”。period="daily"拉的是日线,如果后续要做更短周期的预测,可以改成"60"、"30"等分钟线参数,但分钟线的数据量和噪声都会明显增大,建议新手先从日线开始。

如果你更熟悉tushare,它的pro_bar接口也能做类似的事,但需要 token 权限,积分门槛对新手不友好。我的建议是先用akshare把全流程跑通,确认模型和回测逻辑没问题,再切换到商业数据源。

2.2 标签:用未来收益还是未来方向

有监督学习必须有标签。股票预测里最常见的标签有两种:回归型标签(未来 N 日收益率)和分类型标签(未来 N 日涨/跌/平)。二选一的核心依据是:你要模型输出“涨多少”,还是只输出“涨的概率”。实盘中,概率比具体数值稳定得多,因为价格回归的噪声太大,精确预测收益率的难度远高于预测方向。

import numpy as np df["future_close"] = df["close"].shift(-5) # 未来第 5 个交易日收盘价 df["ret_future"] = df["future_close"] / df["close"] - 1 # 分类标签:未来 5 日收益 > 0.5% 记为 1,< -0.5% 记为 0,中间丢弃 df["label"] = np.where(df["ret_future"] > 0.005, 1, np.nan) df["label"] = np.where(df["ret_future"] < -0.005, 0, df["label"]) df = df.dropna(subset=["label"]) # 只保留到倒数第 5 行,因为最后 5 行没有未来收益 df = df.iloc[:-5].reset_index(drop=True) print(df["label"].value_counts())

标签设计里最容易踩的坑是阈值的选择。0.5%这个值看起来随意,但它关系到类别平衡:如果你把阈值设得太低,比如0.1%,绝大多数样本会被标记为 1,模型学到的只是“市场大部分时间在微涨”这个先验;设得太高如3%,正样本会变得极少,模型训练不稳定。我通常先画出ret_future的分布,再选择能让正负样本比例接近 1:1 到 1:1.5 的阈值,这样模型才能学到真正的区分特征,而不是靠样本不平衡蒙混过关。

2.3 特征泄漏防线:标准化的顺序决定回测真实性

特征工程前先要解决数据泄漏问题。最典型的错误是用全部数据计算均值和标准差再标准化,这等于让训练阶段看到了验证集和未来数据的分布信息。正确做法是只用训练集的统计量去标准化所有数据,测试集和未来数据都必须复用训练集的参数。

from sklearn.preprocessing import StandardScaler feature_cols = ["open", "close", "high", "low", "volume", "amount"] # 先按时间排序,再按前 80% 作为训练段 train_size = int(len(df) * 0.8) train_df = df.iloc[:train_size] test_df = df.iloc[train_size:] scaler = StandardScaler() # 在训练集上 fit,在测试集上只 transform scaler.fit(train_df[feature_cols]) df.loc[:train_size - 1, feature_cols] = scaler.transform(train_df[feature_cols]) df.loc[train_size:, feature_cols] = scaler.transform(test_df[feature_cols])

这里必须强调:特征工程里的技术指标计算也要遵循同样原则。比如计算过去 20 日的移动平均线,这个指标本身只使用历史数据,不会泄漏未来信息;但如果用未来窗口的均值做平滑,就会把未来信息带进来。凡是用到滚动窗口的特征,必须确保窗口只向过去看。另外一个被很多人忽略的点是:删除 NaN 行的时机要放在切分之后,不能先 dropna 再切分,否则训练集和测试集的边界会错位。

3. 特征工程与模型选型:从 LSTM 到梯度提升,哪条路线适合你

3.1 两类特征体系的差异:表格特征 vs 序列特征

股票数据可以建模成两种形态。第一种是表格形态,每行是一天的特征,包括价格、成交量、技术指标、截面排名等,模型以 LightGBM、XGBoost 这类梯度提升树为主力;第二种是序列形态,把连续 N 天的数据叠成一个三维张量,交给 LSTM、GRU 这类循环神经网络处理。

很多人一看到“深度学习”就默认选 LSTM,但实际项目中梯度提升树往往表现不差,甚至在中小样本上更稳。原因在于股票数据信噪比极低,深度学习模型的容量大,容易把噪声也拟合进去;树模型对特征交互的建模更直接,且不需要精细调整序列长度和网络结构。我通常的做法是两条路线都跑一遍,用同一套回测框架对比,而不是凭直觉押注某一种。

如果最终目标是部署上线,可以先看 LightGBM 的结果,它训练快、特征重要性可以直接输出,对数据要求低。LSTM 的优势在于能处理变长序列和复杂的时序依赖,但代价是训练时间长、超参数敏感、复现困难。下面给出两种模型的训练骨架。

3.2 用 LightGBM 快速建立大盘预测基线

import lightgbm as lgb from sklearn.metrics import accuracy_score # 假设 df 已经包含 label 和原始特征列 features = ["close", "volume", "amount", "ret_5", "ma5", "ma20", "vol_ratio"] # 切分时按时间,不打乱顺序 train_x = df.iloc[:train_size][features] train_y = df.iloc[:train_size]["label"] test_x = df.iloc[train_size:][features] test_y = df.iloc[train_size:]["label"] model = lgb.LGBMClassifier( n_estimators=500, learning_rate=0.05, num_leaves=31, max_depth=5, subsample=0.8, colsample_bytree=0.8, random_state=42, ) model.fit( train_x, train_y, eval_set=[(test_x, test_y)], eval_metric="binary_logloss", callbacks=[lgb.early_stopping(50)], ) pred = model.predict(test_x) print("accuracy:", accuracy_score(test_y, pred)) print("feature importance:", list(zip(features, model.feature_importances_)))

这段代码的价值在于快速验证特征有效性。early_stopping(50)表示验证集损失连续 50 轮不下降就提前终止,防止过拟合。subsample和colsample_bytree是随机采样比例,能提升模型泛化能力。如果连 LightGBM 都跑不出明显超过 50% 的方向准确率,那大概率是特征或标签有问题,这时候花更多时间调 LSTM 参数只会浪费时间。

特征工程部分,ret_5是过去 5 日收益,ma5、ma20是均线,vol_ratio是当日成交量与 20 日均量的比值。这些特征的计算必须严格只用历史数据,实现时用rolling(window).mean()即可,注意shift(1)防止用到当日收盘后的信息。

3.3 LSTM 训练:用滑窗构造三维序列,checkpoint 保命

LSTM 的输入要求是(样本数, 时间步长, 特征数)。时间步长的选择直接影响模型容量:步长太短学不到中期趋势,太长容易记住太多噪声。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping def build_sequences(data, label, lookback=20): X, y = [], [] for i in range(lookback, len(data) - 5): X.append(data[i - lookback:i]) # 过去 lookback 天 y.append(label[i + 5]) # 未来第 5 天标签 return np.array(X), np.array(y) X_train, y_train = build_sequences(train_df[features].values, train_df["label"].values) X_test, y_test = build_sequences(test_df[features].values, test_df["label"].values) model = Sequential([ LSTM(64, return_sequences=True, input_shape=(X_train.shape[1], X_train.shape[2])), Dropout(0.3), LSTM(32, return_sequences=False), Dropout(0.3), Dense(16, activation="relu"), Dense(1, activation="sigmoid"), ]) model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"]) checkpoint = ModelCheckpoint("best_lstm.h5", monitor="val_accuracy", save_best_only=True, verbose=0) early_stop = EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True) model.fit(X_train, y_train, epochs=50, batch_size=128, validation_data=(X_test, y_test), callbacks=[checkpoint, early_stop], verbose=1)

这里有个容易困惑的地方:特征标准化必须在构造序列之前完成,否则序列数据里如果混入不同量纲的特征,LSTM 前期收敛会非常慢。lookback=20大约是 A 股一个交易月,是一个合理起点。第一层 LSTM 设置return_sequences=True是为了把整个时间步的信息传给第二层 LSTM,如果只有一层 LSTM,则return_sequences=False就行。

关于随机种子:TensorFlow 的复现需要同时设置random.seed、np.random.seed和tf.random.set_seed,缺一个都会导致结果漂移。Keras 的层内部初始化如果用了 GPU,还可能受 cuDNN 影响,通常需要在安装时禁用tf.config.optimizer.set_experimental_options({"auto_mixed_precision": False})或在环境变量里设置TF_DETERMINISTIC_OPS=1。我的经验是,不要追求 100% 复现,只要保证同一个随机种子下主结论稳定就行。

4. 训练与验证:回测是一项防过拟合工程,不是按回车看曲线

4.1 时间序列切分:随机 KFold 是灾难

分类任务常用的train_test_split默认随机打乱样本,这在股票预测里是致命的,因为它会把 2015 年的数据混进训练集、2023 年的数据混进测试集,模型本质上是在做“跨年份的信息泄露”。时间序列问题必须用TimeSeriesSplit或手动按时间边界切分。

from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=4) for fold, (train_idx, val_idx) in enumerate(tscv.split(df)): train_fold = df.iloc[train_idx] val_fold = df.iloc[val_idx] print(f"fold {fold}: train {train_fold['date'].min()} -> {train_fold['date'].max()}, " f"val {val_fold['date'].min()} -> {val_fold['date'].max()}")

TimeSeriesSplit的特点是每一折的训练集永远是验证集之前的数据,不允许未来数据混入。但在做交叉验证时要注意:它的默认gap=0意味着训练集和验证集之间没有间隔,如果特征里包含 N 日移动平均线,训练集最后几天的特征窗口实际上延伸到了验证集范围,这样一个样本就同时出现在训练集和验证集的“特征上下文”里。解决方法是设置gap,至少大于特征最大回看长度。

4.2 滚动前推回测:模拟实盘中最真实的“模型漂移”

静态切分只能证明模型在某个时间节点有效,但实盘是滚动着向前走的。你应该用滚动前推(walk-forward)方式回测,即每训练一次,预测未来一段,然后加入新数据再训练一次。这种方式能暴露模型在不同市场环境下的稳定性,代价是训练次数成倍增加。

def walk_forward_predict(df, feature_cols, train_days=1000, test_days=50): preds, dates = [], [] for start in range(0, len(df) - train_days - test_days, test_days): tr = df.iloc[start: start + train_days] te = df.iloc[start + train_days: start + train_days + test_days] scaler = StandardScaler().fit(tr[feature_cols]) tr_x = scaler.transform(tr[feature_cols]) te_x = scaler.transform(te[feature_cols]) lgb_model = lgb.LGBMClassifier(n_estimators=200, learning_rate=0.05, random_state=42) lgb_model.fit(tr_x, tr["label"]) preds.extend(lgb_model.predict_proba(te_x)[:, 1]) dates.extend(te["date"].tolist()) return pd.DataFrame({"date": dates, "prob": preds})

这段代码的核心是test_days步长,它决定了模型更新的频率。实盘中,test_days设 20(一个月)比较合理,因为市场环境变化以月为单位;设太小会导致频繁重训练,工程成本高,收益却很有限。滚动回测最大的陷阱是:模型在某个时段表现特别好,而在另一个时段大幅亏损,平均下来净值曲线“看起来还行”,但实际最大回撤已经大到不可接受。

4.3 评估指标:准确率之外必须看最大回撤和年化波动

股票预测中,单纯看准确率会被误导。假设模型预测准确率 55%,但如果它只在上涨行情中预测准确,在下跌行情中连续预测错误,实际执行的策略依然可能大幅亏损。建议至少计算四个指标:方向准确率、年化收益率、最大回撤、夏普比率。

方向准确率可以直接用sklearn.metrics.accuracy_score计算,但年化收益率和最大回撤需要结合仓位假设来算。下面给出一个简化版交易模拟,假设每天按模型预测信号持有或不持有指数:

def backtest_returns(df, signal_col, daily_ret_col, fee=0.0003): df = df.copy() df["strategy_ret"] = df[signal_col].shift(1) * (df[daily_ret_col] - fee) df["strategy_nav"] = (1 + df["strategy_ret"]).cumprod() df["benchmark_nav"] = (1 + df[daily_ret_col]).cumprod() total_ret = df["strategy_nav"].iloc[-1] - 1 rolling_max = df["strategy_nav"].cummax() max_drawdown = ((df["strategy_nav"] - rolling_max) / rolling_max).min() annual_sharpe = df["strategy_ret"].mean() / (df["strategy_ret"].std() + 1e-8) * np.sqrt(252) return total_ret, max_drawdown, annual_sharpe

这里有个关键细节:signal_col.shift(1)表示信号只从第二天开始生效,目的是模拟“当天收盘后出信号、次日开盘执行”的实盘节奏。如果不做这个 shift,等于你用当天的数据预测当天的涨跌并且当天就交易,这在实盘里是做不到的,因为预测结果出来时行情已经收盘。这个 shift 是回测框架里最容易忽略却影响巨大的一个步骤。

5. 避坑清单:这些年我在股票预测上踩过的六个实打实的坑

5.1 标签泄漏的隐蔽形式:未来序列被标准化吞掉

现象:模型在验证集上方向准确率高达 70%,一上实盘就反向亏损。

原因:标准化时用了全部数据的均值和方差,或者特征工程里的滚动均值用了未来窗口。这类泄漏不会在回测中直接报错,而是表现为“训练集的分布被未来的统计量污染”。

解决:严格在滚动窗口内计算所有特征,标准化只用训练集fit。每次构建新训练集时,检查标准化器是否只基于该训练集拟合,而不是全局拟合后复用。

5.2 数据漂移:同一种特征规则在不同年份完全不同

现象:2019 年训练的模型在 2020 年回测表现优秀,但在 2021 年突然失效。

原因:市场的波动结构、成交量特征和因子有效性都在变化。深度学习模型容量越大,越容易把某一时间段内的特定规律记住,导致跨时间的泛化能力反而更差。

解决:回测分段展示净值,分别统计牛市段和熊市段的准确率。如果模型只在单边行情有效,那就应该在策略中加入“环境开关”,比如用大盘均线判定市场状态,只在特定状态下启用模型。

5.3 类别不平衡的“伪高准确率”

现象:模型准确率 88%,看起来碾压基准,但预测结果永远是“不变”。

原因:如果将标签的阈值设得过高,正样本极少,模型学会全部预测为 0 就能拿到高准确率。这是分类任务最常见的陷阱。

解决:观察混淆矩阵,计算召回率和精确率;或者改用predict_proba的输出阈值调参。在类别不平衡明显的情况下,给少数类加权重是常见的修正手段。

5.4 随机性失控:同样的代码,两次跑结果不同

现象:同一个 Jupyter Notebook,两次运行结果完全不一致,有时候模型差异大到影响结论。

原因:PyTorch、TensorFlow 的随机种子没有固定,GPU 并行计算的浮点运算是非确定性的。

解决:设置random.seed(42)、np.random.seed(42)、tf.random.set_seed(42),并关闭 TensorFlow 的 GPU 自动调优。对于 PyTorch,还需要设置torch.manual_seed和torch.backends.cudnn.deterministic=True。如果你的训练时间预算紧张,建议只固定全局种子,不追求逐比特复现。

5.5 训练数据泄露到“未来”:时间特征被误解

现象:加入了“年份”或“月份”作为特征后,模型在验证集上表现异常好。

原因:年份特征实际上让模型记住了某个特定年份的行情规律,而不是真正的预测信号。模型学到的是“2017 年涨、2018 年跌”这种伪规律。

解决:删除所有从日期中直接派生的特征,比如年、月、星期。如果一定要加入周期性信息,使用正弦编码表示“是一年中的第几天”,但要注意模型对这种特征极其敏感,容易把周期性当成绝对规律。

5.6 数据量不足时强行上深度学习

现象:只用了一只股票 3 年的日线数据,约 700 个样本,直接训练 LSTM,验证集损失一路飙升。

原因:深度学习在小样本上过拟合极快,LSTM 尤其如此。股票逐日数据本身就不是大样本问题。

解决:优先扩大数据范围,例如同时使用多只股票的横截面数据,或者从日线升到分钟线。如果只能获得少量日线数据,建议直接用 LightGBM 而不是深度学习。先问自己“数据量够不够”,再决定模型类别。

6. 概率阈值与滑窗集成:把模型输出变成可执行仓位

训练完模型只是第一步,真正让策略稳定的是交易决策层。我一般会额外做两步:一是把模型输出的概率转成仓位,而不是简单的 0/1;二是用滑窗集成多个模型预测,减少单一模型在某一段行情上的随机波动。

from scipy.stats import mode def ensemble_probs(models, X): # 多个模型分别预测概率,然后取均值或众数 prob_list = [m.predict_proba(X)[:, 1] for m in models] return np.mean(prob_list, axis=0) def signal_to_position(prob, low=0.45, high=0.55): # 概率低于 low 做空或空仓,高于 high 做多 return np.where(prob > high, 1.0, np.where(prob < low, -1.0, 0.0))

滑窗集成的思路很直白:每次回测滚动开始时,不只训练一个模型,而是用最近 3 个不同时间窗口分别训练,再对最新预测取平均。这个做法对树模型和 LSTM 都有效,代价是训练耗时增加约一倍,但胜率稳定性提升明显。low和high是仓位阈值,设定为 0.45/0.55 意味着模型只有在信心较强时才下单,避免频繁交易被手续费吃掉收益。

我自己的习惯是把概率输出落成一个 Excel 格式的每日信号表,字段包含日期、收盘价、模型概率、建议仓位、次日涨跌。每月初重新看一眼过去 20 个信号的实际命中率,如果连续 10 个信号中少于 4 个正确,就把仓位阈值调得更保守,而不是重新调模型参数。这种做法听起来不“智能”,但实盘里比反复调 LSTM 超参数靠谱得多——模型漂移是常态,敬畏不确定性才是常态。希望这些基于真实工程经历的思路,能帮你少走几段弯路。

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

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

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

立即咨询