多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

股票预测毕设全解析:从特征工程到LSTM回测避坑指南

股票预测毕设全解析:从特征工程到LSTM回测避坑指南 简介这是一份面向计算机专业毕业设计及课程作业的股票智能预测系统完整源码包以人工智能技术为核心结合金融场景实现数据采集、清洗、建模、预测与可视化全链路。全包共2000个文件压缩后约23.82MB其中包含809个HTML页面与426个JavaScript脚本构成前端展示层后端则涵盖Java、Python、PHP等多种语言实现的接口与业务逻辑并配套SQL数据库、YAML配置及若干训练好的模型文件方便直接运行或二次开发。项目内含大量可复用的算法模块与接口示例如基于LSTM、GRU的时间序列预测模型以及线性回归、随机森林等经典算法既可用于快速搭建完整系统也能深入拆解数据处理逻辑与模型调参细节。同时源码对数据采集、标准化、特征工程、训练评估等模块进行了清晰划分并带有可视化图表与前端交互代码帮助读者理解从原始行情到最终预测结果的完整工程流程。已有187人学习下载适合正在筹备相关课题的高校学生、需要参考完整项目架构的开发者以及想了解AI在量化投资中落地的初学者。1. 股票智能预测系统毕设里的“全栈数据项目”到底怎么落地如果你拿到的是一个名为“毕设课程作业_股票智能预测系统.zip”的项目包里面大概率跑的是这样一条流水线把历史行情数据下载到本地加工成技术面特征喂给分类或回归模型预测后面几个交易日的涨跌再拿到回测里看有没有超额收益。作为毕业设计这套系统的真实难点不在模型有多先进而在工程链路能不能闭合——数据、特征、训练、回测、可视化成一条线评委和老师随时能复现。适合拿来当课题的是学过 Python 和机器学习基础、又想快速攒出一个完整项目的同学。这个方向坑很典型不是网络请求报错就是回测曲线漂亮得让人心虚。2. 数据链路与特征工程先让“喂给模型的东西”保持干净2.1 数据源选型Akshare、Tushare 还是本地 CSV做毕设最怕数据源卡脖子。我最常用的做法是优先用 Akshare 的免费接口拉日线数据代码短、不用注册适合课程作业Tushare 需要 token但历史数据完整性和字段规范更好适合时间跨度较大的实验。Akshare 的日线接口返回的是 DataFrame第一版原型直接用to_csv存本地后面再做成 SQLite 或 Parquet。这里有一个朴素的标准哪个数据源能在十分钟内让你跑出一张head()表就先用它。数据源是否需要注册历史深度主要风险适合场景Akshare否部分接口支持多年接口偶尔调整字段课程作业、快速原型Tushare需要 token较完整积分限制频率时间跨度大、需要规范字段本地 CSV无取决于自采范围覆盖不全离线实验、演示回放import akshare as ak import pandas as pd # 拉取贵州茅台的日线行情 df ak.stock_zh_a_hist( symbol600519, perioddaily, start_date20190101, end_date20231231, adjustqfq ) # 统一列名akshare 返回中文列名改成英文方便后面特征工程 df df.rename(columns{ 日期: date, 开盘: open, 收盘: close, 最高: high, 最低: low, 成交量: volume, }) df[date] pd.to_datetime(df[date]) df df[[date, open, high, low, close, volume]].sort_values(date) df.to_csv(data/600519_daily.csv, indexFalse) print(df.head())这段代码逻辑很简单先用 Akshare 取贵州茅台 2019 到 2023 的前复权日线再把中文字段改成统一英文名保证后面所有模型代码只用 open、close 这类标准字段。adjustqfq一定要保留前复权能消除除权除息造成的价格跳空否则模型会把分红当成暴跌。sort_values(date)是容易被忽略的一步很多接口返回顺序不定按时间升序是时序建模的基本前提。如果在校园网环境里接口超时正确做法是退回 Tushare 或者直接用本地行情 CSV。课程作业阶段不必追求在线实时数据把某只股票五年日线存成 CSV整个训练流程完全可以离线跑通。不推荐去爬网页数据反爬和解析成本足够拖垮一个毕设周。数据量上单只股票五年日线不到 1500 行做 LSTM 训练其实偏少所以很多毕设会选 3 到 5 只股票做横向对比或者把窗口缩短到 15 天。2.2 特征工程从 OHLCV 到技术指标与标签模型吃进去的不是原始行情而是特征。常见做法是计算过去 N 日的涨跌幅、均线、RSI、MACD、成交量变化率。特征不是越多越好而是每个特征都有一个明确含义这样答辩时被问“为什么选这个特征”能讲得清。我一般会在特征工程里留一份手工计算的中间结果方便和第三方库交叉验证。第三方库计算结果是一个黑匣子手工算一遍反而能帮你回答评委的细节问题。import numpy as np def make_features(df, window10): tmp df.copy() # 每日收益 tmp[ret] tmp[close].pct_change() # N 日均线用于捕捉价格趋势 tmp[ma] tmp[close].rolling(window).mean() # 相对强弱指标 RSI简化为 N 日内涨跌幅均值之比 delta tmp[close].diff() up delta.clip(lower0).rolling(window).mean() down (-delta.clip(upper0)).rolling(window).mean() tmp[rsi] 100 * up / (up down) # 当前股价距离 N 日最高点的回撤幅度 tmp[drawdown] tmp[close] / tmp[close].rolling(window).max() - 1 # 成交量变化率衡量资金活跃程度 tmp[vol_ratio] tmp[volume] / tmp[volume].rolling(window).mean() # MACD 差离值12 日 EMA 与 26 日 EMA 之差 ema12 tmp[close].ewm(span12, adjustFalse).mean() ema26 tmp[close].ewm(span26, adjustFalse).mean() tmp[macd] ema12 - ema26 # 标签未来 5 个交易日收益是否为正作为二分类目标 tmp[future_ret] tmp[close].shift(-5) / tmp[close] - 1 tmp[label] (tmp[future_ret] 0).astype(int) # 去掉计算产生的空值 return tmp.dropna().reset_index(dropTrue) df pd.read_csv(data/600519_daily.csv, parse_dates[date]) data make_features(df, window10) print(data[[close, ret, ma, rsi, drawdown, macd, label]].tail())这一段里有两个关键选择一是用shift(-5)构造未来 5 日收益的标签而不是预测明天。原因是 5 日窗口能过滤掉大量日内噪声模型学起来比预测单日涨跌更容易收敛答辩时也更好解释。二是pct_change和rolling天然产生前几个空值dropna()以后样本量会少一个窗口长度这是正常的。rsi公式是简化版没有用 EWMA 平滑但对模型来说已经够用答辩时如实说明即可。另外要特别强调特征计算里像rolling(window).mean()这种操作第 t 行用的只是 t 时刻以前的数据逻辑上没有问题。但如果你在后面又对特征做了一次全局shift(-1)那就是让第 t 天看到了 t1 天属于典型的前视偏差。我习惯在make_features函数末尾把所有特征列单独检查一遍确保没有任何一列是依赖未来数据的。所有特征必须只依赖过去数据这是避免前视偏差的第一道关卡。特征数量控制在 8 到 15 个之间超过 20 个特征在数据量只有一千多行时很容易过拟合而且评委问起来也很难逐个解释。2.3 数据切分与归一化时序拆分是硬要求结构化数据做机器学习很多人第一反应是train_test_split随机切分——这在股票预测里是严重错误。随机打乱等于把未来信息混进训练集回测自然漂亮实盘必翻车。时序任务必须按时间顺序切训练集占总数的 70%验证集 15%测试集 15%。验证集用来调参测试集只在最后评分用一次用多了会让测试集变成隐式训练集。from sklearn.preprocessing import StandardScaler train_ratio, val_ratio 0.7, 0.15 n len(data) train_end int(n * train_ratio) val_end int(n * (train_ratio val_ratio)) feature_cols [ret, ma, rsi, drawdown, vol_ratio, macd] X_train, y_train data.iloc[:train_end][feature_cols], data.iloc[:train_end][label] X_val, X_test data.iloc[train_end:val_end][feature_cols], data.iloc[val_end:][feature_cols] y_val, y_test data.iloc[train_end:val_end][label], data.iloc[val_end:][label] scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) # 只在训练集上 fit X_val_scaled scaler.transform(X_val) X_test_scaled scaler.transform(X_test)scaler.fit_transform和transform分离是数据泄露问题上最容易踩的雷。标准化用的均值和方差只能由训练集计算验证集、测试集都必须复用同一组参数。如果对全量数据先做标准化再切分测试集的信息已经渗入训练过程评测结果会虚高。这组切分后的数据可以立刻喂给逻辑回归或随机森林做基线后续 LSTM 需要重新生成滑动窗口序列。切分完之后还可以顺手检查标签分布是否均衡直接y_train.mean()。如果均值是 0.35说明训练集中下跌样本占 65%模型什么都不做一直猜跌也能有 65% 准确率——后面要针对这个做处理。数据行数较少时建议把训练、验证、测试的比例从 7:1.5:1.5 改成 8:1:1确保测试集至少有 120 个交易日否则回测曲线只有几个点没有说服力。3. 模型选型与训练LSTM 不是唯一答案但最容易讲清楚3.1 基线模型先跑逻辑回归确认数据里确实有信号不要一上来就搭 LSTM。先用逻辑回归或随机森林在 2.3 节的特征表上跑一遍得到准确率、F1 这些指标。如果基线准确率接近 50%往往不是模型问题而是特征工程或标签构造的问题只有基线明显超过随机猜测再上 LSTM 才有对比意义。这一步还能让答辩时的评委看到你有建模思维而不是一把梭。from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, f1_score model LogisticRegression(max_iter500) model.fit(X_train_scaled, y_train) for name, X, y in [(train, X_train_scaled, y_train), (val, X_val_scaled, y_val), (test, X_test_scaled, y_test)]: pred model.predict(X) print(f{name}: acc{accuracy_score(y, pred):.3f}, f1{f1_score(y, pred):.3f}) # 打印特征系数答辩时可以用来解释模型行为 coef pd.Series(model.coef_[0], indexfeature_cols).sort_values(keyabs, ascendingFalse) print(coef)逻辑回归的优势在于可解释——每个特征对应的系数可以直接打印出来答辩时可以指着某个系数说“当前价格离 10 日最高点越远模型越倾向于预测反弹。”如果基线分数和随机猜测相差不到 1 个百分点通常说明特征里没有足够的信息先回头检查特征和标签再考虑换模型。基线模型还有一个容易被忽略的作用它帮你估算数据本身的天花板。假如逻辑回归在测试集上准确率只有 51%你后面的 LSTM 就算调得再好也很难超过 55%因为你用的是同一批特征。这时应该考虑的不是把 LSTM 层数加到 5 层而是重新构造标签——比如把二分类改成三分类涨、平、跌或者把预测周期从 5 日改成 10 日。3.2 LSTM 输入构造与 PyTorch 实现窗口长度与特征维度决定了一半效果LSTM 和表格模型的区别在于输入形状表格模型一行样本是一根 K 线而 LSTM 一个样本是连续 N 根 K 线组成的时间窗口。窗口长度一般取 20 到 60我常用 20因为 20 个交易日大约是一个月刚好覆盖一轮短期趋势。输入维度是特征工程里的特征数。PyTorch 的Dataset作用就是把二维特征表切成三维窗口样本。import torch from torch.utils.data import Dataset, DataLoader class StockDataset(Dataset): def __init__(self, features, labels, window20): self.features torch.tensor(features, dtypetorch.float32) self.labels torch.tensor(labels, dtypetorch.float32) self.window window def __len__(self): return len(self.features) - self.window def __getitem__(self, idx): x self.features[idx: idx self.window] # (window, n_features) y self.labels[idx self.window] # 用窗口之后的一天做标签 return x, y这里的核心逻辑在__getitem__取连续window行特征作为输入取窗口结束后的下一天的label作为输出。这个错位关系一旦搞错模型就会拿着未来数据作弊。配合上一节的StandardScaler注意标准化必须在构造窗口之前完成因为窗口中的每个特征都要使用同一套均值方差如果先切窗口再标准化不同窗口之间的尺度无法对齐。接下来是 LSTM 模型主体import torch.nn as nn class LSTMPrediction(nn.Module): def __init__(self, input_size, hidden_size64, num_layers2, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout, ) self.fc nn.Sequential( nn.Linear(hidden_size, 16), nn.ReLU(), nn.Linear(16, 1), ) def forward(self, x): out, _ self.lstm(x) # out: (batch, window, hidden_size) last out[:, -1, :] # 取最后一个时间步的输出 return self.fc(last).squeeze()模型参数说明参数常见取值影响input_size特征数本文是 6每个时间步看到的维度hidden_size64128越大拟合能力越强也越容易过拟合num_layers122 层是毕设兼顾效果和稳定性的选择dropout0.20.5层间随机丢弃防止过拟合window2060太短抓不到中期趋势太长样本量骤减batch_firstTrue表示输入形状是 (batch, window, features)初始化时与数据形状对齐。forward 里取out[:, -1, :]是 LSTM 预测的标准写法——用窗口最后一个时间步的隐状态输出而不是把全部时间步都送入全连接层。输出层只有一个神经元配合BCEWithLogitsLoss做二分类。3.3 训练循环与超参数学习率、早停、随机种子训练代码里最容易出乱子的不是模型结构而是三个细节二分类损失函数要用BCEWithLogitsLoss而不能直接用CrossEntropyLoss每次迭代固定 shuffle 顺序验证集 loss 连续多个 epoch 不降就早停防止过拟合。def train_model(model, train_loader, val_loader, lr1e-3, epochs30, patience5): criterion nn.BCEWithLogitsLoss() optimizer torch.optim.Adam(model.parameters(), lrlr) best_val float(inf) wait 0 for epoch in range(epochs): model.train() for x_batch, y_batch in train_loader: optimizer.zero_grad() logits model(x_batch) loss criterion(logits, y_batch) loss.backward() optimizer.step() model.eval() val_loss 0.0 with torch.no_grad(): for x_batch, y_batch in val_loader: logits model(x_batch) val_loss criterion(logits, y_batch).item() val_loss / len(val_loader) print(fepoch{epoch1}, val_loss{val_loss:.4f}) if val_loss best_val: best_val val_loss wait 0 torch.save(model.state_dict(), best_lstm.pt) else: wait 1 if wait patience: print(early stop) breakpatience5表示验证集 loss 连续 5 个 epoch 没有下降就终止训练torch.save只保存最优权重而不是最后一次迭代的权重这两点能让模型稳定很多。学习率从1e-3开始如果训练 loss 震荡明显降到1e-4。训练前记得组装 DataLoader 并固定随机种子这一步我在第 5 章会展开讲。有一个容易忽略的参数是batch_size股票数据窗口样本通常只有几百到一千个batch_size32或64比较合适如果设成 128 以上每个 batch 的梯度方向会过于平均模型收敛慢。4. 回测与可视化让模型的“成绩单”能拿去答辩4.1 手写回测引擎年化收益、最大回撤、夏普比率训练完模型不算完评委最常问的问题就是“预测准确率这么高那实盘能不能赚钱”。这时候需要一个回测引擎回答这个问题。回测的本质是按时间顺序遍历测试集模型给出每个交易日的预测方向按这个方向模拟买卖统计收益、回撤和风险调整后收益。我推荐自己写而不是用第三方框架因为几十行代码能让评委看清每一个细节而且不会有“调库黑匣子”的质疑。def backtest(close, pred_prob, threshold0.5, fee0.0003): # close: 收盘价序列, pred_prob: 模型预测的上涨概率 position 0 # 0 空仓1 持仓 equity 1.0 # 初始资金归一化 equity_curve [1.0] for i in range(1, len(close)): # 按前一交易日的预测信号决定当前交易日持仓 signal 1 if pred_prob[i-1] threshold else 0 if signal 1 and position 0: position 1 equity * (1 - fee) # 买入扣手续费 elif signal 0 and position 1: position 0 equity * (1 - fee) # 卖出扣手续费 equity * (1 (close[i] / close[i-1] - 1) * position) equity_curve.append(equity) equity_curve pd.Series(equity_curve) ret equity_curve.iloc[-1] - 1 annual (equity_curve.iloc[-1]) ** (252 / max(len(close), 1)) - 1 max_drawdown (equity_curve.cummax() - equity_curve).max() sharpe equity_curve.pct_change().mean() / equity_curve.pct_change().std() * (252 ** 0.5) return {总收益率: ret, 年化收益: annual, 最大回撤: max_drawdown, 夏普比率: sharpe}这段回测代码的逻辑是用前一交易日的预测概率作为当天的持仓信号次日开盘后才真正生效绕过前视偏差。fee0.0003近似单边万三手续费别忽略否则回测收益会整体虚高。输出的四个指标是答辩必备指标含义合格线参考总收益率测试期内资金增长倍数能跑赢同期基准指数即可年化收益把收益率折算到一年5% 以上说明模型不是纯噪声最大回撤从峰值跌到谷底的最大幅度越小越好超过 20% 要警惕夏普比率每单位波动换来的超额收益大于 1 说明风险调整后表现合理回测的时候要单独保存一份测试集的预测概率不要在整个数据集上回测。测试集必须是模型从未见过的尾部数据否则回测曲线会包含训练阶段的记忆效应。4.2 预测结果可视化从 matplotlib 到交互式界面课程作业阶段一段简单清晰的 matplotlib 代码比花哨的交互界面更实用。把预测概率和真实收益画在同一张图上能直观看出模型在哪些时段失灵。若时间充裕再用 Streamlit 包一层界面让评委输入股票代码后看到最新预测结果。我一般会先画静态图跑通整个流程后再加交互层。import matplotlib.pyplot as plt def plot_prediction(data, pred_prob, test_start_idx): fig, ax plt.subplots(figsize(14, 5)) ax.plot(data[date], data[close], labelclose, linewidth1) ax2 ax.twinx() ax2.plot(data[date][test_start_idx:], pred_prob, colororange, labelpred prob, alpha0.7) ax.set_title(Stock Prediction) fig.legend() plt.show()先画价格、再叠加预测概率这种双轴图能一眼看出预测概率是否在关键转折点发生漂移。如果预测概率长期贴在 0.5 附近说明模型对这段行情没有把握回测里频繁开平仓反而会增加手续费。答辩 PPT 里可以放三张图原始价格曲线、预测概率曲线、净值曲线评委看完这三张图基本就能理解整个系统。交互式界面方面Streamlit 是最省事的方案一个脚本就能把回测结果、概率图、最新信号全放到网页上。建议把模型权重和 scaler 保存成文件页面加载时直接读入避免每次打开都重训。界面选型上Streamlit 适合演示但如果需要复杂的前端交互还是直接导出 HTML 报告更稳省去部署环境依赖。4.3 模型输出解释概率、置信度与涨跌幅我一般会在模型输出层用 sigmoid 把 logits 转成概率再按 0.5 阈值判定涨跌。答辩时如果被问“0.6 和 0.51 有什么区别”回答是 0.6 表示模型置信度更高而不是预测涨幅更大——明确这个边界能避免被评委挑战。置信度概念也可以延伸到回测策略里只有当预测概率超过 0.55 才开仓低于 0.45 才平仓。def sigmoid(x): return 1 / (1 float(np.exp(-x))) logits model(X_batch) prob torch.sigmoid(logits).detach().numpy()阈值的选择可以通过验证集搜索完成在 0.4 到 0.6 区间每 0.05 步长试一次选择 F1 分数最高的阈值。不要直接用测试集调阈值这又会引入信息泄露。如果你用的是二分类最终汇报时除了准确率还要报 AUC因为样本不均衡时准确率会骗人。5. 毕设避坑清单股票预测系统里翻车频率最高的 5 个问题5.1 未来函数污染标签回测曲线过度乐观现象训练集和测试集指标都超过 90%回测资金曲线一路向上实盘完全不对。原因在计算特征时用了rolling(window).mean()却没有按时平移或者标准化时用了全量数据导致第 t 天的特征已经包含 t1 甚至更远的信息。解决所有特征计算后统一做一次shift(1)并严格按 2.3 节把fit_transform和transform分开。在回测中养成“信号当天收盘后生成、次日开盘执行”的习惯。我见过最隐蔽的写法是df[label] (df[future_ret] 0).astype(int)恰好future_ret是用shift(-5)构造的如果后面有人不经意地在特征列表里加上了future_ret整个模型就是在预测自己。5.2 停牌和涨跌停导致收益计算错位现象回测里某天收益突然变成 10% 或 -10%导致回撤曲线出现断崖。原因A 股有涨跌停和停牌机制某天停牌时close[i]和close[i-1]间隔了好几个交易日直接计算收益率会把停牌期间的涨跌算到一天里。解决回测前先过滤掉停牌日或把收益率改成close[i] / close[last_trade_day] - 1。最简单的做法是在数据清洗阶段删掉volume 0的行。对涨跌停日还要注意涨停时买不进、跌停时卖不出回测里不应该允许这些交易发生。5.3 归一化信息泄露现象验证集指标高于训练集怎么看都不合理。原因对全量数据先统一标准化再切分训练集测试集验证集和测试集的均值方差混入了训练集统计量。解决必须分步执行scaler.fit(X_train)和scaler.transform(X_test)。推荐把拟合好的 scaler 也用 joblib 存成文件在推理阶段和模型权重一起加载这样线上预测和线下回测使用同一套预处理参数不会出现“回测挺好部署到新数据上结果对不上”的问题。5.4 样本不均衡模型只会“看跌”现象训练集跌的样本占 80%模型干脆全都预测跌准确率还挺高。原因标签按未来 5 日收益构造震荡市里未来 5 日下跌的概率天然偏高且分类阈值 0.5 对概率型模型不公平。解决记录训练集正负样本比例若失衡超过 1.5 倍用class_weight或对少数类过采样。更实用的是把评估指标从 accuracy 换成 F1 或 AUC。逻辑回归里直接加class_weightbalancedPyTorch 里在BCEWithLogitsLoss中传入pos_weight这个参数能显著改善少数类的召回率。5.5 随机种子不固定两次结果对不上现象完全相同的代码跑两次准确率不一样答辩回放翻车。原因PyTorch 初始化、DataLoader 打乱顺序都有随机性。解决在训练脚本开头固定所有随机源。def set_seed(seed42): import random random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)固定随机种子后还要把 DataLoader 的shuffleTrue放在torch.Generator上设置同样的种子否则第一个 epoch 的数据顺序仍然随机。最后把训练完的模型权重、参数、回测结果打包成一个固定版本目录答辩时直接从目录里读取结果而不是现场重新训练这是最稳妥的演示方式。6. 提升模型说服力的三个进阶做法调参、集成与演示课程作业能跑到这一步已经比大部分同题作品完整。但想拿高分有三个方向的改进性价比最高。第一是超参数调优把 window、hidden_size、学习率、dropout 四个参数放进网格搜索里跑一遍每组参数只训练 10 个 epoch用验证集 AUC 排序。每组训练前要固定随机种子否则参数对比会被随机噪声淹没。网格搜索产出的成绩单可以直接作为答辩附录证明你理解模型对超参数敏感的特性而不只是会调库。第二用心做模型集成。LSTM、XGBoost、逻辑回归三个模型各出一个概率然后取加权平均作为最终信号。XGBoost 在表格特征上的表现通常不错和 LSTM 的结构差异也够大两个模型同时犯错的概率比较低。权重不要手动调太极端我一般固定 0.4、0.4、0.2再根据验证集微调。集成模型单独跑一套回测和单一模型对比用这个对比图当 PPT 的高潮直观证明“组合优于单模型”。第三是演示形态。答辩时与其现场训练不如把最佳权重和 scaler 序列化保存写一个几行的推理脚本让评委输入股票代码后直接看到当前信号。同时把回测曲线和最大回撤标注画在同一张图上并准备一个“如果当天买入跌了怎么办”的防守话术模型给出的是概率而非常胜预测阈值 0.55 以上的信号才执行交易实际回测中信号占比约三成这是模型对不确定性的诚实反馈。这个边界能让评委认可你而不是觉得你在吹嘘。经验上固定随机种子加多模型投票是我最后反复验证的稳妥组合最终成绩单和回放结果完全一致不会在演示现场玩心跳。为保险起见我也会把阈值从 0.5 调整到 0.55 再跑一遍防止决策边界过于敏感。这条路走到这里已经能从数据采集到模型训练、回测、答辩展示一条线全部打通。希望帮到你。本文还有配套的精品资源点击获取
返回列表