简介这份资源面向希望用人工智能方法研究股票市场的学习者与开发者聚焦机器学习在股价分析与预测中的完整实践路径。内容围绕数据预处理、特征工程、模型选择、训练验证、评估调优及持续监控等环节展开涉及线性回归、决策树、随机森林、支持向量机以及LSTM、GRU等神经网络模型并讨论技术指标构建与市场预测的局限性适合具备一定Python与机器学习基础、想将算法落地到金融时序场景的读者。压缩包共2个文件以1个py脚本和1个txt说明为主脚本承载核心分析与预测逻辑文本文件用于记录依赖或运行说明整体约45KB结构精简便于快速上手。目前已有442人学习下载可作为股票预测项目的入门参考帮助读者理解从历史行情数据到模型评估的完整流程并掌握特征构造、交叉验证与误差指标等关键思路。1. 从一份股票价格预测压缩包说起机器学习做金融时序到底靠不靠谱打开任何一个技术社区搜「机器学习 股票价格 预测」你大概率会看到两种极端一种是把 LSTM 吹成印钞机另一种是甩一句「金融时序不可预测」然后扬长而去。这份名为「基于机器学习算法的股票市场股票价格的分析及预测」的压缩包本质上就是这条赛道里最典型的一类工程产物——用历史行情数据走一遍「数据获取 → 特征工程 → 模型训练 → 价格预测 → 回测评估」的完整链路。它解决的不是「明天涨停买哪只」这种问题而是让你亲手把一套时序预测流水线跑通理解模型在金融数据上到底能学到什么、学不到什么。适合谁看有 Python 基础、懂一点 pandas 和 sklearn、想从「调包跑通」进阶到「知道为什么这么调」的从业者。如果你指望靠它直接实盘赚钱那这篇文章可能会让你失望但如果你想搞清楚 LSTM 预测股价为什么经常「滞后一天」、特征里加什么才有增量信息、回测怎么做才不骗自己那接下来的内容值得你花时间。金融时序预测是机器学习里最容易被玄学化的方向之一我踩过的坑比跑通的模型多得多这篇就把血泪经验摊开讲。2. 数据管道与特征工程把原始行情变成模型能吃的张量2.1 数据获取与清洗别小看复权和缺失值股票原始行情数据看起来干净实际上坑很多。最常见的问题是除权除息导致的跳空——如果直接用不复权价格某天突然从 50 块变成 25 块模型会以为发生了暴跌实际上只是拆股。所以第一步必须做前复权处理。另一个坑是停牌日的缺失值A 股停牌可能长达数月直接 dropna 会丢掉大量样本用前值填充又会让模型学到「价格不动」的假模式。我一般用 akshare 或 tushare 拉数据下面是一个最小可用的数据清洗脚本import akshare as ak import pandas as pd import numpy as np # 拉取贵州茅台日线数据adjustqfq 表示前复权 df ak.stock_zh_a_hist(symbol600519, perioddaily, start_date20180101, end_date20241231, adjustqfq) df df.rename(columns{ 日期: date, 开盘: open, 收盘: close, 最高: high, 最低: low, 成交量: volume }) df[date] pd.to_datetime(df[date]) df df.sort_values(date).reset_index(dropTrue) # 停牌日用前值填充同时打标记告诉模型这是填充值 df[is_filled] df[close].isna().astype(int) df[[open, close, high, low, volume]] \ df[[open, close, high, low, volume]].ffill() # 去掉仍然缺失的头部行 df df.dropna().reset_index(dropTrue) print(f清洗后样本数: {len(df)}, 时间范围: {df[date].min()} ~ {df[date].max()})这段代码的关键点有三个adjustqfq保证价格连续性is_filled标记列让模型知道哪些是填充数据避免把停牌期的平坦曲线当成真实信号ffill()而不是dropna()是为了保留时间序列的连续性。参数上perioddaily适合做日频预测如果你想做日内预测需要换成分钟级接口但数据量和噪声会急剧上升。2.2 特征构造从 OHLCV 到有信息量的因子只用开盘、收盘、最高、最低、成交量这五个原始字段模型能学到的非常有限。真正拉开差距的是特征工程。我通常会把特征分成四组特征组具体字段计算方式作用价格动量收益率、对数收益率close.pct_change()消除量纲让模型关注变化波动率滚动标准差、ATRret.rolling(20).std()刻画风险状态均线偏离MA5/MA20/MA60 偏离度close / close.rolling(n).mean() - 1捕捉趋势位置量价关系量比、换手率变化volume / volume.rolling(5).mean()反映资金异动# 构造特征 df[ret] df[close].pct_change() df[log_ret] np.log(df[close] / df[close].shift(1)) df[vol_20] df[ret].rolling(20).std() df[ma5_dev] df[close] / df[close].rolling(5).mean() - 1 df[ma20_dev] df[close] / df[close].rolling(20).mean() - 1 df[vol_ratio] df[volume] / df[volume].rolling(5).mean() # 预测目标未来第 5 天的收益率而不是绝对价格 df[target] df[close].shift(-5) / df[close] - 1 # 去掉因滚动窗口和 shift 产生的 NaN df df.dropna().reset_index(dropTrue) feature_cols [ret, log_ret, vol_20, ma5_dev, ma20_dev, vol_ratio] print(df[feature_cols [target]].describe())这里有一个非常重要的设计决策预测目标用收益率而不是绝对价格。原因很直接——股票价格的绝对值是非平稳的今天 1700 块的茅台和两年前 1000 块的茅台模型没法用同一个尺度去拟合。而收益率近似平稳模型学起来稳定得多。另一个决策是预测未来第 5 天而不是第 1 天因为日频数据噪声极大预测周期太短信噪比太低模型很容易退化成「预测昨天」的懒惰解。2.3 时序切分为什么不能随机划分训练集这是新手翻车最多的地方。用train_test_split随机划分时序数据等于让模型在训练时看到了未来的信息回测结果会好得离谱实盘一塌糊涂。正确做法是按时间顺序切分并且训练集和验证集之间留一段 gap防止特征窗口跨越边界造成信息泄漏。def time_series_split(df, train_ratio0.7, val_ratio0.15, gap5): n len(df) train_end int(n * train_ratio) val_end int(n * (train_ratio val_ratio)) # gap 用于隔开训练集和验证集防止特征窗口重叠 train df.iloc[:train_end - gap] val df.iloc[train_end:val_end - gap] test df.iloc[val_end:] return train, val, test train, val, test time_series_split(df) print(f训练集: {len(train)}, 验证集: {len(val)}, 测试集: {len(test)})gap5是因为我们的预测目标是未来第 5 天如果不留 gap训练集最后几行的标签实际上落在了验证集的时间范围内这就是典型的前视偏差。这个参数一般设成预测周期的长度宁可多留一点也不要少留。3. 模型选型与训练LSTM、XGBoost 还是 Transformer3.1 基线模型先跑通再优化很多人一上来就搭 LSTM结果调了两周参数还不如线性回归。我的习惯是先用轻量模型建立基线确认特征管道没问题再上复杂模型。XGBoost 和 Ridge 回归是两个很好的起点。from sklearn.linear_model import Ridge from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_squared_error, mean_absolute_error import xgboost as xgb import numpy as np scaler StandardScaler() X_train scaler.fit_transform(train[feature_cols]) X_val scaler.transform(val[feature_cols]) X_test scaler.transform(test[feature_cols]) y_train, y_val, y_test train[target].values, val[target].values, test[target].values # Ridge 基线 ridge Ridge(alpha1.0) ridge.fit(X_train, y_train) ridge_pred ridge.predict(X_test) # XGBoost 基线 xgb_model xgb.XGBRegressor( n_estimators300, max_depth4, learning_rate0.05, subsample0.8, colsample_bytree0.8, early_stopping_rounds30 ) xgb_model.fit(X_train, y_train, eval_set[(X_val, y_val)], verboseFalse) xgb_pred xgb_model.predict(X_test) for name, pred in [(Ridge, ridge_pred), (XGBoost, xgb_pred)]: rmse np.sqrt(mean_squared_error(y_test, pred)) mae mean_absolute_error(y_test, pred) # 方向准确率预测涨跌方向对不对 direction_acc np.mean(np.sign(pred) np.sign(y_test)) print(f{name}: RMSE{rmse:.5f}, MAE{mae:.5f}, 方向准确率{direction_acc:.3f})注意这里的评估指标除了 RMSE 和 MAE方向准确率才是金融预测里最该看的指标。RMSE 小不代表能赚钱因为模型可能只是学会了输出接近零的值收益率均值附近方向全错。方向准确率能到 55% 以上配合合理的仓位管理才有实用价值。XGBoost 的参数里max_depth4是刻意压低的金融数据信噪比低树太深必然过拟合early_stopping_rounds30配合验证集防止训练过头。3.2 LSTM 的输入构造与滞后问题LSTM 适合时序建模但直接喂原始价格序列效果往往很差。核心问题是滞后性LSTM 学到的本质是「用最近的价格预测下一个价格」而价格的自相关性极强模型会倾向于输出接近上一个时刻的值看起来预测曲线很平滑实际上只是平移了一天。解决办法是喂特征序列而不是价格序列并且用滑动窗口构造样本import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader class StockDataset(Dataset): def __init__(self, features, targets, window20): self.features features self.targets targets self.window window def __len__(self): return len(self.features) - self.window def __getitem__(self, idx): # 取 window 天的特征作为一个样本 x self.features[idx:idx self.window] y self.targets[idx self.window] return torch.tensor(x, dtypetorch.float32), torch.tensor(y, dtypetorch.float32) class LSTMPredictor(nn.Module): def __init__(self, input_dim, hidden_dim64, num_layers2, dropout0.3): super().__init__() self.lstm nn.LSTM(input_dim, hidden_dim, num_layers, batch_firstTrue, dropoutdropout) self.fc nn.Sequential( nn.Linear(hidden_dim, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 1) ) def forward(self, x): out, _ self.lstm(x) # 只取最后一个时间步的输出 return self.fc(out[:, -1, :]).squeeze(-1) # 构造数据集 window 20 train_ds StockDataset(X_train, y_train, window) val_ds StockDataset(X_val, y_val, window) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size64, shuffleFalse) model LSTMPredictor(input_dimlen(feature_cols)) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) criterion nn.MSELoss()window20对应约一个月的交易日这个窗口要覆盖你特征的计算周期比如 MA20 需要 20 天否则窗口内的特征值不完整。dropout0.3在 LSTM 和全连接层都加了金融数据必须重正则化。weight_decay1e-4是 L2 正则进一步压制过拟合。训练循环里我一般会加梯度裁剪和早停best_val_loss float(inf) patience, wait 10, 0 for epoch in range(100): model.train() for xb, yb in train_loader: optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() model.eval() val_losses [] with torch.no_grad(): for xb, yb in val_loader: val_losses.append(criterion(model(xb), yb).item()) val_loss np.mean(val_losses) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_lstm.pt) wait 0 else: wait 1 if wait patience: print(fEarly stop at epoch {epoch}) break print(fEpoch {epoch}: val_loss{val_loss:.6f})clip_grad_norm_的max_norm1.0是防止 LSTM 梯度爆炸的常规操作金融序列偶尔出现极端值不裁剪容易训练发散。早停的patience10意味着验证集损失连续 10 轮不降就停这个值太小会欠拟合太大浪费时间10 轮是我试下来比较稳的。3.3 Transformer 在小样本金融数据上的取舍Transformer 在 NLP 和时间序列上都很火但用在股票日频数据上要谨慎。标准 Transformer 的自注意力机制需要大量数据才能学好而单只股票 5 年日频数据也就 1200 条左右很容易过拟合。如果一定要用建议做两个改动一是用较小的模型维度d_model32 或 64二是加因果掩码确保只能看到历史信息。class CausalTransformer(nn.Module): def __init__(self, input_dim, d_model32, nhead4, num_layers2, dropout0.2): super().__init__() self.input_proj nn.Linear(input_dim, d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dropoutdropout, batch_firstTrue, dim_feedforward64 ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.head nn.Linear(d_model, 1) def forward(self, x): x self.input_proj(x) seq_len x.size(1) # 因果掩码位置 i 只能关注 i 的位置 mask torch.triu(torch.ones(seq_len, seq_len), diagonal1).bool() x self.encoder(x, maskmask) return self.head(x[:, -1, :]).squeeze(-1)d_model32和dim_feedforward64都是刻意压小的标准 Transformer 的 d_model 动辄 512放在 1000 条样本上就是灾难。因果掩码用torch.triu生成上三角矩阵确保注意力只往前看。即便如此我的经验是在单只股票日频数据上调好的 LSTM 或 GRU 通常比 Transformer 更稳Transformer 的优势要等到多股票联合建模、数据量上到几十万条才能体现。4. 回测与评估别让漂亮的损失曲线骗了你4.1 从预测值到交易信号模型输出的收益率预测值本身不是交易信号需要设定阈值转换成仓位。最简单的策略是预测收益率为正就做多为负就空仓A 股不能做空。def backtest(pred_returns, actual_returns, threshold0.0, cost0.001): # 预测值超过阈值才开仓否则空仓 positions (pred_returns threshold).astype(float) # 策略收益 仓位 * 实际收益 - 交易成本 * 仓位变化 trades np.abs(np.diff(positions, prepend0)) strategy_ret positions * actual_returns - trades * cost cumulative (1 strategy_ret).cumprod() return cumulative, strategy_ret cum, strat_ret backtest(lstm_pred, y_test, threshold0.002, cost0.001) total_return cum[-1] - 1 annual_return cum[-1] ** (252 / len(cum)) - 1 sharpe strat_ret.mean() / (strat_ret.std() 1e-8) * np.sqrt(252) max_dd (cum / cum.cummax() - 1).min() print(f总收益: {total_return:.2%}, 年化: {annual_return:.2%}, f夏普: {sharpe:.2f}, 最大回撤: {max_dd:.2%})threshold0.002意味着预测收益率超过 0.2% 才开仓这个阈值不能设太低否则频繁交易被手续费吃掉。cost0.001是单边千一的交易成本估计包含佣金和滑点实际做高频的话这个值还要往上调。夏普比率和最大回撤比总收益更能反映策略质量夏普低于 1 的策略基本没有实盘价值。4.2 滚动回测一次切分不够单次 train/test 切分的结果偶然性太大可能刚好测试集是牛市什么模型都赚钱。更严谨的做法是滚动窗口回测walk-forward每次用过去 N 天训练、未来 M 天测试滚动前进。def walk_forward(df, feature_cols, train_days500, test_days60, step60): results [] start 0 while start train_days test_days len(df): train df.iloc[start:start train_days] test df.iloc[start train_days:start train_days test_days] # 这里用 XGBoost 做示例换成 LSTM 同理 model xgb.XGBRegressor(n_estimators200, max_depth4, learning_rate0.05, subsample0.8) model.fit(train[feature_cols], train[target]) pred model.predict(test[feature_cols]) results.append(pd.DataFrame({ date: test[date].values, pred: pred, actual: test[target].values })) start step return pd.concat(results, ignore_indexTrue) wf_results walk_forward(df, feature_cols) cum, _ backtest(wf_results[pred].values, wf_results[actual].values) print(f滚动回测总收益: {cum[-1] - 1:.2%})train_days500约两年数据test_days60约三个月step60表示每次滚动三个月。这三个参数决定了回测的稳健性和计算量训练窗口太短模型学不够太长又可能包含过时模式。滚动回测的结果如果和单次切分差很多说明策略不稳定别急着上实盘。5. 避坑与排查金融时序预测里最容易翻车的五件事5.1 前视偏差回测收益高得离谱现象回测年化收益 80% 以上夏普超过 3感觉发现了圣杯。原因特征计算里用了未来信息。最常见的是标准化时用了全量数据的均值和方差或者滚动窗口的centerTrue或者标签构造时shift方向搞反。解决所有涉及统计量的操作标准化、归一化、滚动统计必须只用训练集拟合再 transform 验证集和测试集。标签的shift(-n)要反复确认方向写完后手动打印几行核对。5.2 标签泄漏模型在训练集上完美测试集崩盘现象训练损失降到接近零验证损失居高不下。原因特征里混入了标签的「未来信息」。比如用当日收盘价构造的特征去预测当日收盘后的收益而收盘价本身在收盘那一刻才确定实盘中你根本拿不到。解决严格区分「决策时刻」和「数据可用时刻」。如果你在 T 日收盘后做决策那 T 日收盘价可以用如果要在 T 日盘中决策只能用 T-1 日及之前的数据。把这条规则写进特征工程的注释里。5.3 过拟合换一段数据就失效现象在 2018-2022 年数据上表现很好2023 年一塌糊涂。原因模型记住了训练期的特定市场模式比如某次牛市的特征组合市场风格切换后失效。解决降低模型复杂度树深度、LSTM 层数、d_model加重正则化dropout、weight_decay用滚动回测而不是单次切分确保策略在不同市场阶段都跑过。5.4 交易成本被忽略纸面富贵现象策略信号频繁翻转回测收益还行实盘一算手续费全亏回去。原因回测里没算交易成本或者成本估得太低。解决A 股单边成本至少按千分之一算佣金万三 印花税千一 滑点高频策略还要加。在回测函数里把cost参数显式写出来每次调策略都看一眼换手率。5.5 数据质量停牌、涨跌停、除权没处理干净现象模型在某些日期预测误差突然巨大。原因停牌日填充值、涨跌停日的极端收益率、除权日的价格跳空没处理好。解决涨跌停日可以打标记或者直接剔除停牌日用前值填充并加is_filled标记除权必须用前复权数据。这些清洗步骤看起来琐碎但不做的话模型学到的全是噪声。6. 进阶技巧用多股票联合建模提升泛化单只股票的数据量始终有限一个自然的想法是把多只股票的数据拼在一起训练让模型学到跨股票的通用模式。但直接拼会有一个问题不同股票的价格量纲和波动率差异很大模型会被高波动股票主导。我的做法是先做截面标准化——在每个时间截面上对所有股票的同一特征做 z-score。def cross_sectional_normalize(df, feature_cols, date_coldate): # 按日期分组对每个特征做截面 z-score for col in feature_cols: df[f{col}_cs] df.groupby(date_col)[col].transform( lambda x: (x - x.mean()) / (x.std() 1e-8) ) return df # 假设 df_multi 是多只股票拼接后的长表包含 code 列 cs_features [f{c}_cs for c in feature_cols] df_multi cross_sectional_normalize(df_multi, feature_cols)截面标准化之后特征值反映的是「这只股票在当前时刻相对于其他股票的位置」而不是绝对水平。这样模型学到的模式更容易跨股票迁移。另一个技巧是加入股票代码的 embedding让模型能区分不同股票的个体特征类似推荐系统里对用户和物品做 embedding 的思路。验证多股票模型是否真的比单股票好不能只看整体损失要按股票分组看方向准确率def evaluate_by_stock(df_result, pred_colpred, actual_colactual, code_colcode): rows [] for code, group in df_result.groupby(code_col): acc np.mean(np.sign(group[pred_col]) np.sign(group[actual_col])) rows.append({code: code, direction_acc: acc, n_samples: len(group)}) result pd.DataFrame(rows).sort_values(direction_acc, ascendingFalse) print(result.to_string(indexFalse)) return result如果大部分股票的方向准确率都在 50% 附近徘徊说明模型没有学到跨股票的有效模式可能只是记住了训练集的噪声。这时候要回头检查特征是否有截面区分度或者考虑加入行业、市值等分组信息。最后说一个我自己的习惯每次跑完模型不管指标多好看我都会把预测值和实际值的时间序列画出来肉眼过一遍。如果预测曲线明显滞后于实际曲线或者在某些极端行情下完全失灵那指标再好也不能信。金融时序预测这件事模型只是工具对市场的理解和对自己回测框架的怀疑才是真正决定成败的东西。希望帮到你。本文还有配套的精品资源点击获取