ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于时空图神经网络STGNN的沪深300成分股多维度走势预测系统

基于时空图神经网络STGNN的沪深300成分股多维度走势预测系统

简介:这份资源是面向金融量化研究者与深度学习开发者的沪深300成分股走势预测系统源码包,基于时空图神经网络STGNN构建,用于解决股票市场非平稳时间序列预测与量化交易策略开发问题。包内共109个文件,以32个py源码、31个pyc编译文件、24个npy数据数组、4个pkl模型文件为主,另含pth权重、h5模型、ipynb实验笔记、csv距离矩阵与docx说明文档,压缩包约9.38MB,结构完整便于复现。系统利用PyTorch实现STGNN,对成分股历史价格进行时空特征提取,捕捉价格变动的时序规律与市场间空间关联性,并可为量化策略提供数据驱动决策支持。目前已有56人学习下载,适合具备一定深度学习与金融时间序列基础、希望深入图神经网络实战的读者参考,可据此理解模型搭建、数据处理与策略验证的完整链路。

1. 从一张股票关系图说起:STGNN 到底在预测什么

沪深300成分股不是300个孤岛。银行股异动会顺着资金链传导到地产,新能源龙头跳水会带崩整条产业链,这种「一只票的走势被它邻居牵着走」的现象,用传统 LSTM 逐只建模根本抓不住。基于时空图神经网络 STGNN 的沪深300成分股多维度走势预测系统,核心就是把「时间维度的序列依赖」和「空间维度的股票关联」塞进同一个模型里联合训练。它解决的是单票时序模型看不见横截面联动的问题,适合已经跑通过 LSTM 预测、想进一步引入图结构做量化交易策略开发的从业者。读完你能拿到一套可复现的建图、训练、回测路径,以及参数怎么设、坑在哪。

2. 把沪深300建成图:节点、边和特征怎么定

2.1 为什么必须用图而不是把300只票拼成一个大向量

最朴素的做法是把300只股票的特征在时间步上拼成一个 300×F 的矩阵,直接喂给 LSTM 或 Transformer。这条路我踩过,翻车点很明确:拼接后的向量维度固定,一旦成分股调整、停牌、新股纳入,整个输入结构就崩了,而且模型学不到「谁和谁相关」,只能隐式地靠注意力去猜。图结构的好处是节点集合可以动态增删,边表达的是显式关系,模型在消息传递时天然知道信息该往哪流。

STGNN 的「时空」拆开看:空间维度靠图卷积(GCN、GAT 或 GraphSAGE)在节点间聚合邻居信息,时间维度靠 GRU、TCN 或时间卷积在序列上滑动。两者交替堆叠,就是 STGNN 的典型骨架。常见做法是「时间层 → 图层 → 时间层」的 sandwich 结构,先抽局部时序,再做跨股票聚合,再抽一次时序。

2.2 三种建图方式:行业、相关性、资金流

节点就是沪深300的成分股,难点全在边。我一般按下面三种方式建图,实际项目里会融合:

建图方式边定义优点缺点
行业图同申万一级行业连边稳定、可解释太粗,行业内分化大
相关性图滚动窗口收益率 Pearson 相关 > 阈值连边数据驱动、动态阈值敏感、易受极端行情污染
资金流/供应链图北向持仓、龙虎榜共现、上下游关系捕捉真实传导数据获取成本高

相关性图是最容易落地的。用过去60个交易日的日收益率算相关矩阵,取绝对值 Top-K 作为邻居,避免阈值法在震荡市里连出一堆噪声边。K 一般取 5~10,太大图会稠密,GCN 过平滑严重。

import numpy as np import pandas as pd def build_corr_graph(returns: pd.DataFrame, topk: int = 8, window: int = 60): """ returns: index=日期, columns=股票代码, values=日收益率 返回邻接矩阵 A (N x N), 对称, 无自环 """ recent = returns.tail(window) corr = recent.corr().abs() # 取绝对值, 负相关也是强关联 np.fill_diagonal(corr.values, 0) # 去掉自环 N = corr.shape[0] A = np.zeros((N, N)) for i in range(N): # 每只票只保留相关性最高的 topk 个邻居 idx = np.argsort(corr.values[i])[-topk:] A[i, idx] = corr.values[i, idx] A = np.maximum(A, A.T) # 对称化, 保证无向图 return A

逻辑说明:先算滚动窗口相关矩阵,取绝对值是因为负相关(比如多空对冲的两只票)同样是强关联信号。np.fill_diagonal置零去掉自环,自环信息由 GCN 的权重矩阵自己学。Top-K 而非阈值,是为了保证每只票的度相对均匀,避免出现孤立节点导致梯度传不进去。最后对称化,因为资金传导是双向的。

参数说明:window取 60 是经验值,太短相关矩阵噪声大,太长会掩盖近期结构变化;topk取 8 左右,沪深300 里每只票平均 8 个强关联邻居已经能覆盖主要传导链。如果做高频,window 要缩到 20 以内。

2.3 节点特征:多维度到底指哪些维度

标题里的「多维度」不是虚词,节点特征至少覆盖四类:价量(开高低收、成交量、换手率)、技术指标(MACD、RSI、布林带位置)、资金面(北向净流入、大单净额)、情绪面(新闻情感分、股吧热度)。每类做 z-score 标准化,注意是按时间截面标准化而不是按整段历史,否则会引入未来信息。

def normalize_cross_section(features: np.ndarray): """ features: (T, N, F) 时间 x 股票 x 特征 按每个时间截面对 N 只股票做标准化, 避免未来函数 """ mean = features.mean(axis=1, keepdims=True) std = features.std(axis=1, keepdims=True) + 1e-8 return (features - mean) / std

这里有个血泪经验:很多人图省事用sklearn.StandardScaler对整段数据 fit,结果回测收益虚高,实盘一上就亏。截面标准化才是对的,因为它模拟的是「当天你只知道当天所有票的相对强弱」。

3. STGNN 模型结构:时间层和图层怎么交替堆

3.1 一个能跑通的最小 STGNN 骨架

不追求 SOTA,先要能复现。下面这个结构是「两层时间卷积 + 一层图卷积 + 输出头」,输入 (T, N, F),输出每只票的涨跌概率或收益率。

import torch import torch.nn as nn import torch.nn.functional as F class STGNN(nn.Module): def __init__(self, n_feat, hidden=64, n_nodes=300, dropout=0.3): super().__init__() # 时间层: 沿时间维做一维卷积, 抽局部时序模式 self.tcn1 = nn.Conv1d(n_feat, hidden, kernel_size=3, padding=1) self.tcn2 = nn.Conv1d(hidden, hidden, kernel_size=3, padding=1) # 图层: 用可学习的邻接权重做消息传递 self.gcn_w = nn.Linear(hidden, hidden, bias=False) self.dropout = nn.Dropout(dropout) # 输出头: 每只票一个标量(预测收益或logit) self.head = nn.Linear(hidden, 1) def forward(self, x, A): # x: (B, T, N, F) -> 合并 B,N 做时间卷积 B, T, N, F_ = x.shape x = x.permute(0, 2, 3, 1).reshape(B * N, F_, T) x = F.relu(self.tcn1(x)) x = F.relu(self.tcn2(x)) x = x.mean(dim=-1).reshape(B, N, -1) # 时间维池化 -> (B,N,H) # 图卷积: A_norm 是归一化后的邻接矩阵 (N,N) h = self.gcn_w(x) h = torch.einsum('nn,bnh->bnh', A, h) # 邻居聚合 h = self.dropout(F.relu(h)) return self.head(h).squeeze(-1) # (B, N)

逻辑说明:时间卷积用Conv1d在时间轴上滑动,padding=1保证长度不变。把 B 和 N 合并是为了让同一套时间卷积权重作用在所有股票上,参数共享,避免过拟合。时间维用mean池化而不是取最后一个时间步,是因为金融序列尾部噪声大,平均更稳。图卷积这里用einsum做最朴素的邻居加权求和,A需要提前做对称归一化(D^-1/2 A D^-1/2),否则度数大的节点数值爆炸。

参数说明:hidden取 64 是沪深300 数据量下的稳妥值,超过 128 在小样本上必过拟合;dropout0.3 是金融时序的常用起点,震荡市可以加到 0.5;kernel_size=3对应 3 个交易日,想抓更长周期就堆层或换膨胀卷积。

3.2 邻接矩阵归一化:不做这一步梯度会炸

上面代码里A不能直接用原始相关矩阵。必须做对称归一化:

def normalize_adj(A: np.ndarray): A = A + np.eye(A.shape[0]) # 加自环, 保留自身信息 d = A.sum(axis=1) d_inv_sqrt = np.power(d, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0 # 孤立节点保护 D = np.diag(d_inv_sqrt) return D @ A @ D

不加自环,节点在聚合时会把自己的信息丢掉;不做 D^-1/2 归一化,高度数节点聚合后的数值量级会碾压低度数节点,训练时 loss 直接 NaN。孤立节点(停牌、新股)的度数为 0,np.power会出 inf,必须置 0 保护,这是最容易忽略的一行。

3.3 训练目标:回归收益还是分类涨跌

两种都行,但我更推荐多任务:主任务回归未来 5 日收益率,辅任务分类未来 5 日涨跌方向。回归给连续信号用于排序选股,分类给概率用于阈值过滤。损失函数用 HuberLoss(回归)+ BCEWithLogitsLoss(分类)加权:

loss = huber(pred_ret, true_ret) + 0.5 * bce(pred_logit, (true_ret > 0).float())

HuberLoss 对极端收益(涨停、跌停)比 MSE 鲁棒,金融数据里肥尾是常态。权重 0.5 是让两个任务量级接近,实际调参时看哪个任务的梯度范数大就压哪个。

4. 训练与回测:从数据切分到策略落地

4.1 时序切分:绝对不能随机 shuffle

金融时序最致命的错误是随机划分训练测试集。必须按时间切:前 70% 训练,中间 15% 验证,最后 15% 测试。而且训练集内部做样本时,标签窗口不能跨越切分点,否则未来信息泄漏。

def time_split(dates, train=0.7, val=0.15): n = len(dates) i1, i2 = int(n * train), int(n * (train + val)) return dates[:i1], dates[i1:i2], dates[i2:]

标签用未来 5 日收益,意味着训练集最后一个样本的标签用到了切分点之后的数据。稳妥做法是在切分点前留 5 个交易日的 gap,这段数据只做特征不做标签。

4.2 回测:别只看 IC,要看扣费后的净值

模型指标看 IC(信息系数)和 RankIC,但真正决定策略生死的是扣费净值。沪深300 成分股流动性好,双边成本按万分之三算,加上印花税千分之一(卖出),一次完整交易成本约 0.13%。如果模型预测的 5 日收益均值只有 0.2%,扣费后基本白干。

def backtest(pred, true_ret, topk=30, cost=0.0013): # 每天选预测收益最高的 topk 只, 等权持有 ranks = pred.argsort(axis=1)[:, -topk:] daily = np.zeros(pred.shape[0]) for t in range(pred.shape[0]): daily[t] = true_ret[t, ranks[t]].mean() # 换手率近似: 与昨日持仓的重合度 turnover = 1 - (np.isin(ranks[1:], ranks[:-1]).sum(axis=1) / topk) daily[1:] -= turnover * cost return np.cumprod(1 + daily)

逻辑说明:每天按预测收益排序取 Top-K 等权,这是最朴素的截面选股。换手率用相邻两日持仓重合度近似,np.isin逐行比较。成本只在换手部分扣,因为没换的仓位不产生交易费用。

参数说明:topk=30是沪深300 里兼顾分散和收益的常用值,太小波动大,太大趋近指数;cost=0.0013是 A 股双边成本的保守估计,做高频要往上调。

4.3 多维度特征怎么融合进节点

前面说的四类特征不是简单 concat 就完事。价量和技术指标是高频的(日频),资金面和情绪面可能是低频或不规则的。我一般把低频特征做前向填充到日频,再统一进normalize_cross_section。如果某类特征缺失严重(比如北向数据某些票没有),用行业中位数填充而不是全局均值,保留行业结构。

5. 避坑与排查:STGNN 在股票上最容易翻车的五件事

5.1 现象:验证集 IC 很高,实盘一上就亏

原因:用了未来信息。最常见的是标准化用了全样本统计量,或者标签窗口跨越了切分点。另一个隐蔽来源是相关性图用了未来窗口的收益率算边。

解决:所有统计量(均值、方差、相关系数)只在当前时间点之前的数据上计算。建图时严格用returns[:t]的滚动窗口,绝不碰returns[t:]。写完代码后做一次「打乱未来数据」测试:把测试集之后的收益随机打乱,如果模型指标不变,说明没泄漏。

5.2 现象:训练 loss 正常下降,但预测值全部趋同

原因:图卷积过平滑。堆了太多图层,或者邻接矩阵太稠密,所有节点的表示被平均成同一个向量。

解决:图层不超过 2 层,topk控制在 10 以内。加残差连接:h = h + gcn(h),保留节点自身信息。监控节点表示的方差,如果趋近 0 就是过平滑了。

5.3 现象:某些交易日预测全是 NaN

原因:停牌股票的收益率是 NaN,传播到相关矩阵和特征里,再进模型就污染全局。

解决:数据清洗阶段把停牌日的前向填充做掉,收益率算不出来就置 0。建图时对停牌节点做 mask,聚合时跳过。normalize_adj里的孤立节点保护那行就是干这个的。

5.4 现象:回测收益曲线漂亮,但换手率高得离谱

原因:模型预测值日间波动大,导致每天持仓剧烈变化。STGNN 对输入噪声敏感,尤其是相关性图每天变,邻居一换预测就跳。

解决:对邻接矩阵做时间平滑,比如A_t = 0.7 * A_t + 0.3 * A_{t-1},让图结构缓慢演化。预测值也做移动平均,或者加换手率惩罚项进损失函数。

5.5 现象:GPU 显存爆了,batch 调到 1 还是 OOM

原因:沪深300 的图是 300×300,如果 batch 里每个样本都存一份邻接矩阵,显存是B × 300 × 300 × 4字节。B=32 就是 11MB,看着不大,但中间激活值(尤其是 einsum 的输出)会放大几十倍。

解决:邻接矩阵在 batch 内共享(同一时间截面所有样本用同一张图),用torch.einsum时确保 A 不参与梯度。如果还爆,把图卷积换成稀疏矩阵乘法torch.sparse.mm,300 节点的稀疏图能省一半以上显存。

6. 让 STGNN 真正跑赢基准的一个技巧:动态图 + 行业约束

前面所有内容能让你跑通,但跑通不等于跑赢。我试过最有效的改进是动态图加行业约束:相关性图每天变,但纯数据驱动的边在极端行情里会连出跨行业的伪关联(比如某天所有票一起跌,相关矩阵全是高相关)。加一层行业 mask,只允许同行业或已知供应链关系的股票连边,噪声边能砍掉一大半。

def masked_graph(A, industry_mask): """ industry_mask: (N, N) bool, 同行业或已知关联为 True 只保留 mask 内的边, 其余置零后重新归一化 """ A = A * industry_mask return normalize_adj(A)

industry_mask从申万行业分类构建,同行业为 True,再手动加几条已知的上下游关系(比如锂矿和电池)。这个 mask 是静态的,和动态的相关性图相乘,既保留数据驱动的动态性,又用先验知识过滤噪声。

验证这个改进是否有效,别只看整体 IC,要分行情看:震荡市里行业约束的提升最明显,单边牛市里反而可能拖累(因为普涨时跨行业联动才是主导)。我一般会做一个行情分层回测,把测试期按沪深300 指数波动率分成高波动和低波动两段,分别看策略表现。如果高波动段提升、低波动段持平,说明约束起作用了。

还有个习惯:每次改完模型,先跑一遍「只用价量特征 + 行业图」的 baseline,再逐步加特征和动态边。任何一步如果 IC 没提升超过 0.005,我就回退,因为金融数据里这点提升很可能只是噪声。这个规矩帮我省了无数次白干。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进