简介:这是一套基于机器学习的股票预测与分析完整项目,面向计算机专业毕业设计、课程设计及需要实战练习的学习者。项目包含可运行的Python源码、算法模型权重及详细文档说明,覆盖数据预处理、特征工程、模型训练与结果可视化等环节,并内置多家公司股票历史行情CSV数据,便于直接复现股票趋势预测流程。压缩包共2000个文件,以png图表、csv数据、npz模型数据、pth权重及py脚本为主,另有xml工程配置与说明文档,整体约693MB,结构完整,经过严格调试可直接运行。该项目在CSDN已有614人学习,代码注释与文档配套清晰,适合作为高分毕业设计参考或进一步扩展到量化交易分析方向。
1. 股票预测到底在预测什么:先别急着写代码
很多人拿到"基于机器学习的股票预测"这个毕设题,第一反应就是找一个LSTM模型,把过去60天的收盘价扔进去,预测明天的收盘价。回测画出来曲线完美贴合,导师看了点头,答辩时却被人问一句"你这预测出来价格,敢拿真钱去交易吗?"就哑火了。这个标题真正的核心不是"用Python写一个预测函数",而是"如何构建一个符合金融逻辑、可回测、可解释的机器学习预测流程"。它解决的是:把历史行情、技术指标、交易量等信息转化为能训练的特征,把未来一段时间的涨跌或收益率转化为标签,训练出能在未见过的数据上稳定有效的模型,并用科学的方法评估它值不值得信。
适合谁?一是做本科/硕士毕设的学生,需要完整的源码和文档支撑;二是想入门量化的开发者,先弄清这条流水线的每个环节怎么串起来。这里强调一个反直觉结论:预测价格数值几乎注定失败,因为价格序列非平稳、噪声大;真正能做的是预测方向上概率——明天涨还是跌、未来5日收益率是否超过阈值。想清楚这个,整个毕设的立论就站住了。
2. 数据是预测的地基:用akshare拉日线数据与清洗拼接
2.1 为什么选akshare而不是Excel手工数据
常见做法是找历史行情数据源,免费的国内接口里akshare最省事,不用注册 token,直接 pip install akshare,就能拉A股日线、分钟线、指数成分股列表。比tushare方便在不需要积分,比yfinance的优势是A股复权、停牌状态处理得更符合国内习惯。有的同学喜欢从Wind或Choice导出Excel,但那只能做静态快照,没法做"滚动扩展窗口"的真实训练流程,而且不同格式的表拼接起来特别容易出错。所以这里建议直接用akshare,把数据采集写成可复现的脚本,毕设文档里也好写"数据来源与获取方式"。
2.2 拉取沪深300成分股日线数据的代码
先用akshare拿到沪深300成分股代码列表,再遍历拉日线前复权数据。前复权必须做,否则除权除息会让价格出现非自然的跳空,模型会把这种假信号当真。下面是最小可用代码:
import akshare as ak import pandas as pd import time # 1. 获取沪深300成分股,返回的DataFrame里有 symbol 和 name stock_300 = ak.index_stock_cons_csindex(symbol="000300") # 有时候返回的列名为 code,先用合法性检查兜底 if "code" not in stock_300.columns: stock_300 = stock_300.rename(columns={"代码": "code", "名称": "name"}) codes = stock_300["code"].astype(str).str.zfill(6).tolist() # 2. 拉取每只股票的前复权日线,存成dict data_dict = {} for code in codes[:50]: # 毕设跑通流程可以先限50只,全量300只容易触发限流 try: df = ak.stock_zh_a_hist( symbol=code, period="daily", start_date="20150101", end_date="20231231", adjust="qfq" # qfq 前复权,hfq 后复权,这里必须用 qfq ) if df is None or df.empty: continue # akshare返回的列名是中文,统一成英文便于后续特征工程 df = df.rename(columns={ "日期": "date", "开盘": "open", "收盘": "close", "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount" }) df["date"] = pd.to_datetime(df["date"]) df = df[["date", "open", "high", "low", "close", "volume", "amount"]].copy() df["code"] = code data_dict[code] = df except Exception as e: # akShare偶发网络错误,跳过这只,不要中断整个循环 print(f"{code} 拉取失败: {e}") time.sleep(0.12) # 控制请求频率,避免被源站限流这段代码里最重要的参数是adjust="qfq",a股除权除息后如果不复权,股价会产生向下的跳空缺口,机器学习会把这种缺口当成可预测的规律,实际是财务事件,不是市场行为。start_date和end_date决定了时间跨度,跨度太短模型学不到不同行情阶段(牛市、熊市、震荡市),建议最少覆盖5年以上,最好带一轮完整牛熊。codes[:50]是做原型验证的通用技巧,先跑通流程再上全量。
2.3 清洗与拼接:停牌、异常值、缺失值处理
拉下来的数据不能直接用,必须做三层清洗。第一层是缺失值,停牌日会有空行或NaN,最简单的做法是直接删除,但要注意有的股票停牌一个月,删除后日期不连续,后面做滚动窗口时窗口内天数变少,特征会失真。常见处理是:如果连续缺失不超过5天,用最近一个交易日的数据向前填充;连续缺超过5天的,直接剔除这段区间。第二层是异常值,比如成交量因为数据源错误出现0或负值,开盘价高于涨停价之类的,这类数据直接删掉对应行。第三层是拼接,把50只股票的数据纵向堆起来,但必须在堆之前给每只股票生成内部的"交易日序号",因为不同股票停牌日期不同,用时间索引对齐反而会错位。
def clean_stock_df(df, max_gap=5): df = df.sort_values("date").reset_index(drop=True) # 删除成交量或价格<=0的行 df = df[(df["volume"] > 0) & (df["close"] > 0) & (df["high"] >= df["low"])] # 日期连续性检查:生成期望的交易日序列后求差集 full_dates = pd.date_range(df["date"].min(), df["date"].max(), freq="B") missing_dates = full_dates.difference(df["date"]) if len(missing_dates) <= max_gap: df = df.set_index("date").reindex(full_dates).ffill().reset_index() else: # 缺失过多,保留实际交易日期 df = df.dropna(subset=["close"]) # 生成股票内部连续序号,供后续时间窗口使用 df["day_idx"] = range(len(df)) return df all_df = pd.concat( [clean_stock_df(d) for d in data_dict.values()], ignore_index=True )这里freq="B"是工作日频率,A股周一至周五交易,节假日会多算缺失,但一般只有几天,ffill能处理。day_idx是全流程的关键——后面构造"过去60天特征"时,不能用日历日来框,必须用这只股票自己的交易日序号。
3. 特征与标签构造:把K线转成监督学习样本
3.1 标签设计的三种做法:涨跌分类、收益率回归、未来N日极值
标签是整个预测任务的定义,做错了一切白搭。最常用的三种:第一种是二分类标签,未来第T天的收盘价是否高于今天的收盘价,高于为1,否则为0,这对应"持股/空仓"决策;第二种是回归标签,未来T日收益率(close[t+T]/close[t] - 1),模型输出一个连续值;第三种是"未来N日最高价是否超过今日收盘价+阈值"或者"最低价是否跌破阈值",这更像目标止盈止损。毕设里建议用第一种,分类任务评估简单、答辩好解释,而且神经网络和树模型都能输出概率,很方便做阈值调整。
需要注意的是,标签必须完全由"未来"数据构成,不能引入任何当时未知的信息。下面这段代码展示了正确的标签构造方法,其中shift(-T)保证了标签与特征在时间上对齐。
def make_labels(df, horizon=5, mode="classify"): df = df.sort_values("day_idx") # 未来horizon日收盘价 future_close = df["close"].shift(-horizon) if mode == "classify": # 用未来收盘价和当前收盘价比大小,作为涨跌标签 df["label"] = (future_close > df["close"]).astype(int) elif mode == "regression": df["label"] = future_close / df["close"] - 1.0 # 最后horizon行没有未来数据,标签为NaN,训练时必须丢弃 df = df.dropna(subset=["label"]).reset_index(drop=True) return df为什么shift(-horizon)是时序里最容易出错的点:如果不做 shift,直接用当天的close和当天的feature,那标签里就包含了当天的收盘信息,特征如果也用了当天收盘,就变成"用答案预测答案"。另一个常见错误是dropna丢掉了没有未来标签的尾部数据,这会导致回测时最后horizon天无法出预测,所以策略要在标签有效的区间内逐日滚动。
3.2 技术指标特征:MA、RSI、布林带,用ta库还是手写
特征工程里,技术指标是必选项。常见做法是用ta库或talib生成RSI、MACD、布林带、ATR等指标,但talib在Windows上安装麻烦,ta库更友好。不过毕设里更推荐手写指标,不是造轮子,而是手写代码能保证你清楚每个指标的边界——比如RSI计算时是简单移动平均还是指数移动平均,不同实现结果差很多。下面给出核心特征的特征表:
| 特征族 | 具体特征 | 计算窗口 | 说明 |
|---|---|---|---|
| 动量类 | 收益率 | 1/5/10/20天 | 用close.pct_change()即可 |
| 均线类 | MA5/MA10/MA20 | 5/10/20天 | 与当日close的比值,归一化 |
| 波动类 | 标准差 | 5/10/20天 | close收益率的标准差 |
| 量价类 | 成交量均值/换手率 | 5/10天 | volume/流通股本(可选) |
| 超买超卖 | RSI | 14天 | 用Wilder平滑实现 |
| 通道类 | 布林带位置 | 20天 | (close - lower)/(upper - lower) |
用ta库一行能生成几十个指标,但容易生成未来函数——因为很多库的指标默认用了全量数据做中心化或归一化,你看起来没什么问题,实际上数据被"从未来借了信息"。比如有人用ta.MACD,之后又对整个DataFrame做Z-score标准化,标准化用的是全部数据的均值和方差,这就是典型的未来函数。所以准则只有一个:所有特征计算必须只用当前时刻及之前的数据,写在循环里或者用rolling(),不要在构造完整个数据集之后再做全局标准化。
import numpy as np import pandas as pd def add_features(df): df = df.sort_values("day_idx").copy() close = df["close"] # 收益率:保留多窗口,树模型能自己选择用哪个 for win in [1, 5, 10, 20]: df[f"ret_{win}"] = close.pct_change(win) # 均线偏离:当前价/过去N日均价 - 1 for win in [5, 10, 20]: ma = close.rolling(win).mean() df[f"ma_bias_{win}"] = close / ma - 1.0 # 波动率:用收益率标准差 for win in [5, 10, 20]: df[f"vol_{win}"] = df["ret_1"].rolling(win).std() # RSI-14 手动实现 delta = close.diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.ewm(alpha=1/14, adjust=False).mean() avg_loss = loss.ewm(alpha=1/14, adjust=False).mean() rs = avg_gain / avg_loss df["rsi_14"] = 100 - 100 / (1 + rs) # 布林带位置:20日均线上下2倍标准差 mid = close.rolling(20).mean() std20 = close.rolling(20).std() upper = mid + 2 * std20 lower = mid - 2 * std20 df["bb_pos"] = (close - lower) / (upper - lower).replace(0, np.nan) return df这段代码里的ewm(alpha=1/14, adjust=False)是RSI公式中注明的Wilder平滑,用adjust=False是为了让指数权重从最早时刻开始一致,如果使用默认的adjust=True,序列前段权重不同,指标值会偏移,虽然不算错误,但会跟主流平台如TradingView的计算结果对不上,答辩时容易被动。
3.3 构造样本的时序切分:防止未来函数浸入
特征和标签都构造好之后,下一步是把数据切成样本矩阵。这里最容易踩坑的是把所有股票的数据混在一起做随机切分。绝对不能随机切分!因为同一天的样本之间高度相关,比如2021年3月,全市场都在涨,随机切分会把上涨样本同时分进训练集和测试集,模型记忆了市场状态,测试集准确率虚高,一到实盘完全失灵。正确做法是"按时间切分",比如前80%时间做训练,后20%做测试。
all_features = [col for col in df_panel.columns if col not in ["date", "code", "close", "label"]] X = df_panel[all_features].values y = df_panel["label"].values dates = df_panel["date"].values # 按时间切分:假设按日期排序后取80%临界点 cut_idx = int(len(dates) * 0.8) X_train, X_test = X[:cut_idx], X[cut_idx:] y_train, y_test = y[:cut_idx], y[cut_idx:]这里有个细节,df_panel是已经按日期排序的,如果之前concat的时候按股票堆叠,要重新sort_values("date")。另外,如果你用了股票的day_idx特征,那么这个特征本身也是从0递增的,模型学到的可能不是市场规律,而是"第1024天以后都是上涨",这属于序号泄漏。所以特征集中绝不要放day_idx、code这类与预测目标没有因果关系的ID类变量。处理办法是把它们设为索引而不是特征列。
4. 模型选择与参数调优:从逻辑回归到XGBoost
4.1 基准模型逻辑回归:参数少,能跑通全流程
很多同学一上来就上LSTM,结果训练半天不收敛,连数据预处理对不对都不知道。我的建议是第一个模型用逻辑回归,它解释性强、训练快、不容易过拟合,适合作为毕设的baseline。逻辑回归的本质是学习一组线性权重,对特征做sigmoid映射,在金融这种低信噪比场景下,线性模型往往不比非线性模型差太多,而且权重绝对值可以解释成"特征影响方向"。
from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, precision_score, f1_score model_lr = LogisticRegression(max_iter=1000, C=1.0, random_state=42) model_lr.fit(X_train, y_train) y_pred = model_lr.predict(X_test) print("Accuracy:", accuracy_score(y_test, y_pred)) print("Precision:", precision_score(y_test, y_pred))这里C=1.0是正则化强度的倒数,C越小正则化越强,防止过拟合。为什么不设C=0.1?因为如果特征没做标准化,逻辑回归收敛可能有问题。先跑一个StandardScaler对特征做Z-score标准化,注意必须用训练集的均值和标准差去变换测试集,不能在全量数据上拟合Scaler。这是另一个高频翻车点。
4.2 随机森林与XGBoost:处理特征重要性和过拟合控制
随机森林是比逻辑回归更上一步的选择,能捕捉非线性关系,还能输出特征重要性。XGBoost在结构化数据上通常比随机森林好一点,但参数多、调参复杂,毕设里不一定需要追求极致精度,把随机森林调好足够拿高分。最关键的两个参数是max_depth和n_estimators,股票数据噪声大,max_depth别超过6,树太深必然过拟合。n_estimators用200左右就行,太多会让训练变慢且收益递减。
from sklearn.ensemble import RandomForestClassifier model_rf = RandomForestClassifier( n_estimators=200, max_depth=6, min_samples_split=20, min_samples_leaf=10, random_state=42, n_jobs=-1 ) model_rf.fit(X_train, y_train) importances = pd.Series(model_rf.feature_importances_, index=all_features) print(importances.sort_values(ascending=False).head(10))min_samples_split=20和min_samples_leaf=10是控制粒度的关键,金融数据里噪声样本很多,如果叶子节点样本太少,模型会把个别极端行情当成规律。训练结束后看特征重要性前十,通常vol_5、bb_pos、ret_20会排前面,如果出现某个不合理的特征(比如label自己),说明前面构造样本时出了问题。
训练集和验证集不能随机打乱,这对树模型同样成立。验证集的选取要按时间顺序,用"后面20%时间的数据",而且验证集与训练集之间要有间隔——比如训练集截至2022-06,验证集从2022-07开始,这样可以避免窗口重叠带来的信息泄漏。
4.3 模型输出去做策略:从预测概率到信号
模型输出0/1是分类结果,但真正做回测时要的是概率。predict_proba得到的第二列是"上涨概率",你可以设定阈值0.5、0.55、0.6,只有概率超过阈值才买入。这样做的原因是,机器学习模型在概率接近0.5时预测价值很低,强行按0/1信号交易会频繁换手,光手续费就吃掉收益。
y_prob = model_rf.predict_proba(X_test)[:, 1] # 只选概率>0.55的持有,其余空仓 position = (y_prob >= 0.55).astype(int) # 模拟收益:第二天真实收益 daily_ret = df_panel["close"].pct_change().shift(-1).iloc[cut_idx:].values strategy_ret = position * daily_ret cum_ret = np.cumprod(1 + strategy_ret)这里shift(-1)是为了对齐——你在第t天收盘后根据概率决定是否持有第t+1天,不能直接用当天的收益率,否则等于拿当天的涨跌去预测当天的信号,未来函数又回来了。
5. 回测与评估避坑:为什么回测很漂亮实盘就翻车
5.1 坑一:前视偏差——用未来数据做特征
现象:训练集准确率99%,测试集准确率99%,回测曲线一路向北,觉得马上财务自由了。 原因:几乎可以肯定数据泄漏。最常见的泄漏有两处——特征里混入了未来信息,比如时间窗口用了shift(-1)把明天的数据带到今天;或者全局标准化用了测试集的均值/方差。 解决:严格按时间顺序构造特征,只允许用shift(正数)或rolling();标准化代码写成"在训练集上fit,在测试集上transform",如下所示:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 绝对不能重新fit另外,用一个自动检查函数打印特征的最大值和最小值,如果测试集中出现训练集从未见过的极端值,就要警惕是泄漏还是真实的分布漂移。对于前视偏差,最直接的排查方法是把特征列和未来close放在同一张表里,人工检查几行。
5.2 坑二:涨跌不均衡导致准确率虚高
现象:测试集准确率60%,但实际策略年化收益是负的。再看一列,测试集中有70%的样本标签是0(下跌),模型学成"永远预测下跌"就能拿70%准确率。 原因:样本不平衡。尤其在熊市或震荡市,下跌天数占比高,模型走捷径。 解决:不要只盯准确率,要看precision、recall、F1,更要看策略收益和最大回撤。训练时可以给少数类加权重,逻辑回归里加class_weight='balanced',随机森林里同样支持。但在金融里,预测上涨本身也是低概率事件,所以更实际的做法是用过采样只对上涨样本做SMOTE,但要谨慎,SMOTE会生成插值的合成样本,打破时间顺序性,毕设里宁可用class_weight,不要合成样本。
5.3 坑三:没有考虑交易成本与滑点
现象:回测年化收益30%,实盘一跑只有8%,扣掉手续费和滑点甚至亏钱。 原因:回测里按收盘价无缝成交,忽略了佣金、印花税、卖出时费用、买卖价差。A股印花税卖出才收,佣金双边万2到万3,最低5元,如果每日调仓,摩擦成本非常高。 解决:回测时必须模拟交易成本,简化的做法是每次买入扣掉0.1%成本、卖出扣掉0.15%成本(含印花税+佣金+滑点)。更准确一点,用"次日开盘价成交"而不是"当日收盘价成交",因为信号在收盘后才能产生,当天成交是幻想。
fee_buy = 0.001 fee_sell = 0.0015 daily_ret_after_cost = np.where(position_shifted == 1, daily_ret - fee_buy, 0) # 卖出日要先计算持仓收益再扣卖出费 sell_days = (position_shifted == 0) & (position_shifted.shift(1) == 1)如果毕设里做的是日频信号,建议调仓频率控在一周一次以下,否则成本会吞噬大量收益。这个结论可以放进论文的"交易成本敏感性分析"。
5.4 坑四:参数过拟合到历史区间
现象:在2015-2020年调出的max_depth=8和min_samples_leaf=2在2021年测试集上效果暴跌,但换个区间调参又变好,说明参数只是记住了特定行情的形状。 原因:参数寻优时反复在测试集上试,测试集的信息通过人的肉眼泄漏进了模型选择,这不是模型主动过拟合,而是人工调参过拟合。 解决:用三层时间切割——训练集(2015-2019)、验证集(2020-2021)、测试集(2022-2023)。只在验证集上调参,测试集只跑一次。如果测试集结果不理想,也不要再调了,直接如实写进论文"该模型在样本外失效,说明规律不稳定",这反而比强行凑一个好结果更显研究素养。终极做法是滚动时间窗:用前5年训练,预测下一年,每一年滚动一次,把多年结果汇总,这才是量化里常用的Walk-Forward分析。
5.5 坑五:幸存者偏差
现象:用今天沪深300的成分股去拉历史数据回测,回测表现好得出奇,但实盘你根本不知道未来谁会被剔除。 原因:沪深300成分股每年调整,表现差的股票被换出,表现好的留下来。用当前成分股倒推历史,相当于自动避开了后来退市和下跌的股票。 解决:最简单的方法是用指数本身的历史点位做回测基准,或者用过去的成分股列表(akshare能取到不同期的成分股,但要慢得多了)。毕设里至少要做一个敏感性说明:把样本换成"某时间点截面前500股市值股票",观察结果是否稳健。另外,买过的股票退市、停牌、涨跌停无法成交,回测都要考虑,至少把涨停板买入、跌停板卖出的情况设成"当天无法成交,顺延到下一天"。
6. 让毕设拿高分的三个进阶技巧
如果前面的流程都跑通了,这个毕设已经能拿一个不错的分数。但如果想冲优秀,下面三个进阶技巧值得加进去。第一个是Walk-Forward滚动验证。不要只在固定训练集和测试集上跑一次,改成每一年滚动一次:用2015-2019训练,预测2020;再用2016-2020训练,预测2021;以此类推。这样能生成多年连续预测,策略评估更有说服力。代码思路很简单,外面加一个年份循环,里面复用之前的训练和预测逻辑,关键点是每次都要重新fit标准化器和模型,不要偷懒沿用上一次的参数。
第二个是加入风险指标。论文里除了贴准确率,一定要算夏普比率(年化收益/年化波动)、最大回撤、卡玛比率、胜率和盈亏比。这些指标是金融领域通用的评价语言,导师和答辩评委一眼就能看出你是不是懂行。用百分比收益序列算:
def sharpe_ratio(returns, rf=0.02, periods=252): excess = returns - rf / periods return np.sqrt(periods) * excess.mean() / (returns.std() + 1e-8) def max_drawdown(equity_curve): running_max = np.maximum.accumulate(equity_curve) return (equity_curve / running_max - 1).min()第三个技巧是写文档的时候,把一个"失败实验"单独做一节。比如你试过预测原始收盘价、试过直接把所有特征不做标准化丢进LSTM,结果很差,然后把失败原因分析清楚——非平稳性、噪声过大、窗口信息不足——这恰恰是本课题最难的地方。毕设文档的价值不在"我做出来了完美模型",而在"我理解了问题边界,并能用实验证据说明为什么某些做法行不通"。这也是我把"高分"定义为"流程完整、评估科学、结论可信"而不是"准确率90%"的原因。
最后,说句个人的血泪经验:做这类题最容易犯的错就是希望在论文里写"准确率90%",最后为了这个数字去调标签、改阈值、换区间,出来的模型拿去实盘一买就套。真实规律是,日频涨跌预测准确率能稳定超过55%就已经是优秀的模型了,剩下的收益要靠仓位管理和风险控制。把重点放在"预测概率的排序能力"上——高概率样本的收益是否显著高于低概率样本的收益——比追求绝对准确率更有学术价值,也是答辩时最能讲出深度的地方。希望帮到你。
本文还有配套的精品资源,点击获取