机器学习股票价格预测全流程:从特征工程到回测验证

📅 发布时间:2026/9/26 21:56:58
机器学习股票价格预测全流程:从特征工程到回测验证
简介这是一份面向股票价格预测与量化交易入门者的机器学习算法资源包能够帮助初学者系统梳理从数据准备到模型评估的完整流程。资源整合了LSTM、Prophet、AutoARIMA、朴素贝叶斯、SVM、随机森林等多种模型的预测实现并配有基础回测系统便于读者横向对比不同算法在股价时序数据上的表现也可借此理解LSTM门控机制、Prophet趋势分解等核心概念。压缩包共386个文件主体为357个csv历史行情数据文件另有18个Python脚本用于模型训练与回测以及html结果展示页、png图表、js辅助资源等整体仅1.14MB轻量易用目录划分清晰。目前已有306人学习下载。通过该资源读者可获得一套完整可运行的股价预测实验框架既能借助csv数据快速验证各模型效果也能通过Python脚本学习特征处理、模型调用与回测评估的关键细节尤其适合作为课程设计或量化研究的基础模板。1. 为什么这类项目注定赚不到钱但值得你照着跑一遍拿到「基于机器学习的股票价格预测算法」这类项目第一反应别是「我要发财了」——任何做过实盘的工程师都会告诉你测试集上再漂亮的模型放进真实市场里也经常翻车。这套项目的价值是把机器学习算法建模和回测验证这条链路完整串起来数据清洗、特征工程、多模型训练、回测检验一气呵成。适合刚入门机器学习的开发者以及想验证自己想法、不指望靠预测暴富的研究者。看完你能知道每个模块怎么落地、参数怎么设以及为什么你的回测结果不能直接拿去实盘。2. 数据与特征工程所有预测算法的地基也是最容易泄漏的一环2.1 数据获取与复权处理先用能免费拿到的日线数据这个项目名里没有写数据从哪来但任何预测算法都要先喂数据。常见做法是用免费接口拿日线行情我一般用 akshare它不需要 token适合做入门验证。先用沪深300指数日线做演示代码长这样import akshare as ak import pandas as pd # 获取沪深300日线数据默认前复权 df ak.stock_zh_a_hist( symbol000300, perioddaily, start_date20150101, end_date20231231, adjustqfq ) # 接口返回的是中文列名先重命名并抽出需要的列 df df.rename(columns{ 日期: date, 开盘: open, 收盘: close, 最高: high, 最低: low, 成交量: volume }) df df[[date, open, close, high, low, volume]] df[date] pd.to_datetime(df[date]) df df.sort_values(date).reset_index(dropTrue) print(df.head())这段代码有三个关键点。第一adjustqfq表示前复权如果不做复权处理遇到分红送股会在价格序列上留下跳空模型会把这种「假信号」当成学习目标。第二接口返回的列名是中文必须重命名否则后面所有特征计算都要跟着改。第三sort_values保证时间升序很多数据源返回的顺序并不稳定回测里顺序错了收益曲线就是乱的。免费接口的局限也说明一下它适合日线级别的策略验证不保证盘中实时更新做不了分钟级和高频回测。数据拿到后先看一眼有没有停牌导致的 NaN 或全天空行。常见做法是直接用df.dropna()处理如果行情源本身干净这一步能省掉。2.2 技术指标特征把裸价格变成模型能消化的输入只有 OHLCV 五列模型基本学不出什么东西。工程上最常用的做法是叠技术指标让特征带上趋势、动量、波动率的信息。这里用 pandas 手写几个最常见指标不依赖 ta-lib 这类需要编译的库# 简单移动均线短期与长期趋势 df[sma_5] df[close].rolling(5).mean() df[sma_20] df[close].rolling(20).mean() # RSI(14)相对强弱指标衡量近期涨跌动量 delta df[close].diff() gain delta.clip(lower0).rolling(14).mean() loss (-delta.clip(upper0)).rolling(14).mean() rs gain / loss df[rsi_14] 100 - 100 / (1 rs) # MACD快慢指数均线的差离及信号线 ema_12 df[close].ewm(span12, adjustFalse).mean() ema_26 df[close].ewm(span26, adjustFalse).mean() df[macd_dif] ema_12 - ema_26 df[macd_dea] df[macd_dif].ewm(span9, adjustFalse).mean() df[macd_hist] 2 * (df[macd_dif] - df[macd_dea]) df df.dropna().reset_index(dropTrue)滚动窗口的选取有讲究sma_5捕捉短期趋势sma_20捕捉中期趋势两者差值其实就是动量的一种表达RSI 的 14 是 Wilder 提出的经典参数短周期交易者会改成 7但做日线预测 14 更稳MACD 用 12/26/9 三组参数是查漏补缺的默认值。注意ewm(adjustFalse)是标准 MACD 算法如果写成默认的adjustTrue算出来的 DIF 曲线会整体偏移回测里不容易察觉但换到其他数据集上表现差异很大。对这些参数我的习惯是全部按行业默认值先跑一轮不要一上来就在历史数据上调参那是过拟合的第一张多米诺骨牌。特征不是越多越好。很多入门项目会把几十个技术指标一股脑堆进去模型在训练集上表现得像神测试集上立刻现原形。金融特征之间高度共线后面用树模型时特征重要度会非常分散线性模型则直接出现系数不稳定。我一般控制在一二十个特征以内先做一轮相关性筛选把相关系数超过 0.9 的同类指标只留一个。2.3 标签与数据切分时序数据不能随机打乱特征之后是标签设计。做股票价格预测标签有两种常见定义回归任务用未来 N 日收益率分类任务用未来 N 日是否上涨。分类任务对新手更友好评估指标直观。预测周期 N 选多少决定了整个项目的「性格」N1 做的是次日涨跌噪音极大N20 偏向中长线信号更慢但更稳。我先用 N5 演示from sklearn.preprocessing import StandardScaler horizon 5 df[future_ret] df[close].shift(-horizon) / df[close] - 1 df[label] (df[future_ret] 0).astype(int) df df.iloc[:-horizon] # 去掉末尾没有未来收益的样本 feature_cols [sma_5, sma_20, rsi_14, macd_dif, macd_dea, macd_hist] X_raw df[feature_cols].values.astype(float32) y_raw df[label].values # 按时间顺序切分前 70% 训练后 30% 测试 train_end int(len(X_raw) * 0.7) # 只用训练集拟合标准化参数这是防止数据泄漏的第一道闸 scaler StandardScaler() scaler.fit(X_raw[:train_end]) X_train scaler.transform(X_raw[:train_end]) X_test scaler.transform(X_raw[train_end:]) y_train y_raw[:train_end] y_test y_raw[train_end:]标签构造里shift(-horizon)是最容易出错的一行。它把未来第 5 天的收盘价相对今天的涨幅取出来如果忘记df.iloc[:-horizon]这一步最后几行样本的future_ret会是 NaNdropna 会把训练尾部的数据无声删除导致训练集和测试集的边界悄悄偏移。切分方式上train_test_split默认的shuffleTrue在这里绝不能直接用——随机打乱等于让模型在训练时看到了未来金融时序数据必须按时间顺序切分。标准化也必须先 fit 训练集再 transform 测试集这是整套机器学习应用流程里最容易被新手忽略的泄漏点后面避坑章节还会展开。到这里数据、特征、标签、切分四件事都齐了。这套流程是所有算法的公共前置无论你后面用线性回归还是 LSTM喂进去的必须是这种「按时间排好、无泄漏」的矩阵。3. 从线性回归到 LSTM四种机器学习算法在同一份数据上的对比落地3.1 线性回归与逻辑回归先建立一个不丢人的基线任何预测项目第一步都该跑一个最朴素模型当基线否则后面所有「效果好」都没有参照物。对二分类标签最朴素的基线是逻辑回归它在金融领域实际就是线性概率模型。代码非常简单from sklearn.linear_model import LogisticRegression clf LogisticRegression(max_iter1000, C1.0) clf.fit(X_train, y_train) # 预测上涨概率 pred_prob clf.predict_proba(X_test)[:, 1] pred_label (pred_prob 0.5).astype(int) # 方向准确率预测方向与真实涨跌是否一致 acc (pred_label y_test).mean() print(f方向准确率: {acc:.3f})max_iter1000是给求解器一个宽松的收敛上限数据标准化后通常几百次迭代就收敛C1.0是正则化强度的倒数C 越小正则越强。注意这里评估用的是「方向准确率」而不是分类准确率——在涨跌基本五五开的数据里你猜全部上涨也能有 50% 准确率所以方向准确率要明显高于 0.5 才算有意义。线性模型的强项是稳定、可解释系数能直接看出来哪个特征对预测贡献大弱项是它假设特征与标签是对数线性关系而市场里这种关系非常弱。跑出来的结果一般就在 0.5 附近小幅波动这是正常的别慌。3.2 树模型XGBoost 与 LightGBM 在表格特征上的优势数据是表格型的、特征是技术指标这种场景下梯度提升树通常是机器学习模型里表现最好的那一档。XGBoost 的代码结构如下from xgboost import XGBClassifier clf XGBClassifier( n_estimators500, learning_rate0.05, max_depth4, subsample0.8, colsample_bytree0.8, eval_metriclogloss, early_stopping_rounds50, random_state42 ) clf.fit( X_train, y_train, eval_set[(X_test, y_test)], verboseFalse ) # 训练结束后early_stopping 会自动使用最优迭代次数 pred_prob clf.predict_proba(X_test)[:, 1]四个参数直接决定模型会不会过拟合。max_depth4是树深金融数据里的信号非常弱树太深就是在硬记噪声4 到 6 之间比较安全subsample0.8表示每棵树随机抽 80% 的行colsample_bytree0.8表示每棵树随机抽 80% 的特征列这两招是让每棵树「长得不一样」本质和随机森林的思路一致。early_stopping_rounds50是关键后悔药模型在验证集上连续 50 轮没有变好就提前停不用手动数着迭代次数挑模型。用eval_metriclogloss而不是准确率因为早期轮次准确率可能震荡logloss 对概率预测的惩罚更平滑。LightGBM 是另一个常见选择代码几乎一样把XGBClassifier换成LGBMClassifier即可。两者在日线级数据上的精度差异通常不大XGBoost 对小数据集更稳健LightGBM 在数据量大时更快。我的建议是样本量少于几万行优先 XGBoost数据量大、特征多优先 LightGBM。3.3 时序模型 LSTM什么时候值得上深度模型看到时间序列很多人第一反应是上 LSTM。深度模型的优势是能自动学习特征之间的时序依赖但它在这个场景里有两个绕不过去的坑数据量太小、特征太短。几千行日线数据对 LSTM 来说就是「吃不饱」它动辄需要十万级样本才能发挥能力。如果一定要试代码框架是这样import numpy as np from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout window 20 def make_windows(X, y, win): Xs, ys [], [] for i in range(win, len(X)): Xs.append(X[i - win:i]) ys.append(y[i]) return np.array(Xs), np.array(ys) X_train_w, y_train_w make_windows(X_train, y_train, window) X_test_w, y_test_w make_windows(X_test, y_test, window) model Sequential([ LSTM(64, input_shape(window, X_train_w.shape[2]), return_sequencesTrue), Dropout(0.2), LSTM(32), Dense(1, activationsigmoid) ]) model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) model.fit(X_train_w, y_train_w, epochs50, batch_size32, validation_split0.1, verbose0)窗口是 20 个交易日等于用过去约一个月的特征预测未来 5 天的涨跌特征和标签的时间尺度不同这是允许的。第一层 LSTM 设return_sequencesTrue是为了把完整的时序信息传给第二层如果你只有单层 LSTM这里要改成 False。Dropout(0.2)是深度模型防过拟合的唯一盾牌金融数据噪声大这个比例建议只增不减。训练时用validation_split0.1从训练集尾部切出一部分做验证注意它按顺序切不会打乱。实际跑下来你会发现LSTM 在方向准确率上通常打不过调好超参的 XGBoost训练时间却长一个数量级。这不是 LSTM 没用而是它更适合海量高频数据。如果你只有日线级别几千条样本我建议把它当对照组跑一次然后安心用树模型。3.4 模型对比不要只看准确率看这四个维度把几个算法都跑完后一个表格把所有模型摆在一起比。这里不能只看方向准确率我一般做四列方向准确率、IC 均值、年化收益、最大回撤。IC信息系数是预测概率与未来实际收益之间的秩相关用 Spearman 相关系数计算大于 0.03 就认为模型有一点预测力import scipy.stats as st ic st.spearmanr(pred_prob, df[future_ret].iloc[train_end:].values).correlation print(fIC: {ic:.4f})对比时你会发现一个规律逻辑回归的 IC 往往接近 0XGBoost 的 IC 可能到 0.03 到 0.05LSTM 的表现则不稳定换一次随机种子可能就从 0.04 掉到 0。IC 只是预测力的一种度量真正的裁判是回测系统因为收益曲线还要经过交易成本和仓位管理的修正。模型对比这块我的习惯是每次只改一个变量比如固定同一份特征只换算法对比才有意义很多人同时换特征又换算法最后说不清到底是谁起了作用。4. 回测系统搭建用向量化循环把策略交给历史检验4.1 从预测概率到持仓信号先过一道阈值模型输出的是「未来 5 日上涨的概率」不能直接拿 0.5 一刀切。原因在于预测概率集中在 0.45 到 0.55 之间阈值设 0.5 会导致频繁开平仓手续费吃掉大量收益。常见做法是设置一个不对称的阈值比如 0.55 才开仓0.45 才平仓中间区域空仓等待pred_prob clf.predict_proba(X_test)[:, 1] threshold_open 0.55 threshold_close 0.45 signal np.zeros_like(pred_prob) signal[pred_prob threshold_open] 1 signal[pred_prob threshold_close] 0 # 中间区域维持前一日仓位用前向填充处理 for i in range(1, len(signal)): if signal[i] 0 and pred_prob[i] threshold_close: signal[i] signal[i - 1]阈值不是玄学它直接控制交易频率。阈值越高交易越少单笔质量要求越高阈值越低交易越频繁成本越重。我一般先用 0.5 跑通全流程然后看回测里的换手率再决定抬不抬阈值。很多入门项目跳过了信号到仓位的这一步直接拿 0.5 做判断这是回测和实盘差距大的一个重要原因。4.2 向量化回测单循环计算净值、回撤与夏普回测系统的作用是把「策略信号」翻译成「收益曲线」。基本回测系统不需要事件驱动的复杂框架一个按时间顺序迭代的循环就够了。核心在于信号与收益的对齐预测在第 t 天收盘后生成最早只能在第 t1 天执行这是回测绝对不能逾越的红线。import numpy as np # 测试集的日收益率 close df[close].iloc[train_end:].values daily_ret np.diff(close) / close[:-1] daily_ret np.append(daily_ret, 0) # 末尾补 0对齐长度 # 循环回测 equity 1.0 # 初始资金 peak 1.0 # 历史最高净值 max_drawdown 0.0 # 最大回撤 equity_curve [] for t in range(1, len(signal)): # t 日持仓由 t-1 日收盘后的信号决定避免前视偏差 pos_today signal[t - 1] equity * (1 pos_today * daily_ret[t]) peak max(peak, equity) max_drawdown max(max_drawdown, (peak - equity) / peak) equity_curve.append(equity) total_return equity / 1.0 - 1 years len(signal) / 250 # 一年约 250 个交易日 annual_return (equity ** (1 / years) - 1) if years 0 else 0 print(f累计收益: {total_return:.2%}, 年化收益: {annual_return:.2%}, 最大回撤: {max_drawdown:.2%})这个循环里最容易理解错的是signal[t - 1]这一行。它表示你在第 t-1 天收盘后根据信号决定「明天持有还是空仓」第 t 天的涨跌才计入你的仓位收益。如果直接写signal[t] * daily_ret[t]等于你在当天开盘前就知道当天涨跌这就是前视偏差回测收益会虚高得离谱。年化收益按 250 个交易日折算最大回撤衡量的是从净值最高点跌到下一个低点的最大幅度这个数字比年化收益更值得盯着因为实盘中 50% 的回撤需要 100% 的涨幅才能回本。4.3 交易成本与滑点不放成本的回测是自欺欺人上面这段代码的收益是「毛收益」没扣任何交易成本。实际交易里每次调仓要付佣金、印花税和冲击成本三者的合计通常在 0.1% 到 0.2% 之间。日线策略一年调仓几十次成本会显著侵蚀收益。加上成本后的回测才勉强接近真实# 双边成本估算佣金 0.015% 印花税 0.1%卖出时收这里简化双边 cost_rate 0.001 0.0015 # 信号改变一次代表一次调仓 turnover np.abs(np.diff(signal)).sum() total_cost cost_rate * turnover net_annual_return annual_return - cost_rate * (turnover / years) print(f调仓次数: {turnover:.0f}, 总成本: {total_cost:.2%}, 净年化: {net_annual_return:.2%})这里的印花税和佣金比例是按国内股票交易的常见水平估算的具体数值随券商和政策浮动回测时把它当参数留出来不要写死。滑点更难量化日线级别流动性好的标的可以忽略但如果你在回测里用了开盘价成交而实盘开盘瞬间价格往往往不利方向偏这就是滑点。我的做法是在 cost_rate 里额外加 0.05% 到 0.1% 的滑点缓冲宁可把回测做得难看一点。回测系统绝不能只输出一个「累计收益」就收工。我每次回测都至少看四个数年化收益、最大回撤、夏普比率、调仓次数。夏普比率是年化收益超出无风险利率的部分除以收益波动率代码里可以用annual_return / np.std(daily_ret * signal_temp) * np.sqrt(250)近似计算它衡量的是「每承担一单位波动能换多少收益」。这四个数凑在一起才能判断一个策略是不是「收益高风险低」的良性策略而不是单看一条净值曲线自我陶醉。5. 五个常见问题让预测和回测结果毁于一旦的坑5.1 训练集准确率 98%回测却一路亏损数据泄漏这是整个项目里最常见的翻车现场。现象是你在训练集上跑出「完美」的准确率测试集一测就像换了个市场回测曲线更是一路向南。原因大概率出在数据切分或特征构造这两个环节。第一你在整个数据集上做了标准化再切分测试集的均值和方差已经被模型「看见」了第二你用了shuffleTrue把时序打乱模型在训练时偷看了未来的数据。解决方法是把顺序彻底拧过来先按时间切分再对训练集 fit 标准化器最后 transform 测试集。切分时固定shuffleFalse或者干脆手动按索引切片。这是机器学习应用流程里最基本的一条纪律但做过几个项目之后你会发现它也是被违反得最频繁的一条。5.2 回测每天赚钱实盘一买就亏前视偏差现象很有迷惑性回测净值曲线漂亮得像教科书实盘第一周就开始亏钱。原因几乎都出在「同一根 K 线既被用来计算信号又被用来计算收益」。典型表现是用当天的收盘价计算出当天信号却按当天的收盘价或开盘价成交。收盘价在收盘那一刻才知道信号最早只能用于第二天的交易。另一个隐蔽版本是使用 MACD 之类需要未来数据的指标如果指标窗口跨越了你预测的标签区间它就提前看到了答案。解决方法就是 4.2 节里那个对齐逻辑第 t 天的持仓必须由第 t-1 天收盘后生成的信号决定。逐行检查你回测循环里signal[t]和daily_ret[t]是否用了同一个 t这是排查前视偏差最直接的办法。我的原则是任何一个特征如果在 t 时刻收盘后拿不到一律按拿不到处理宁可错过一天行情也不偷看未来。5.3 换一组参数就崩过拟合与超参抖动现象是同一套数据你把max_depth从 4 改成 6测试集准确率就从 55% 掉到 50%换一个时间段数据又完全反过来。原因有两个层面。一是树模型对超参数天然敏感而金融数据里的有效信号极其微薄模型很容易去拟合噪声二是你在调参时反复「试错」每一次试错都参考了测试集结果这等于用测试集做了训练测试集的评估信息已经被污染。解决方法是把评估分成两层训练集内做时间序列交叉验证用于选参最后一小段「从没碰过」的数据作为最终验证。训练时要开early_stopping_rounds让模型自己决定迭代轮数。不要执着于某个参数的精确最优值固定一组合理的默认值比反复微调更可靠。血泪经验是在金融数据上调参跑得越精细过拟合越严重。5.4 2015 年好用2018 年失灵市场非平稳性现象是同一个模型在不同的市场阶段表现差异极大牛市里收益翻倍熊市里回撤 40%。原因是市场本身不是一个稳定的统计过程波动率、相关性、投资者行为都在变模型学到的规律在下一个阶段可能完全失效。这不是 bug而是这类预测任务的结构性困难。任何时序预测模型都逃不开这个问题区别只在于谁更早发现。解决方法是做滚动重训每隔一段时间用最近数据重新训练而不是拿一个模型无限期跑下去。回测时要把「模型每 3 个月重训一次」这个动作写进循环里才能得到一个接近未来的真实评估。详细做法在第 6 章展开。另外一个实用技巧是加波动率过滤市场波动率过高时强制空仓这类规则常被证明比模型本身更赚钱。5.5 同一份代码两次跑出不同结果随机种子没固定现象是代码没改换一台机器或者重跑一次收益数字就变了有时差好几个百分点。原因在于机器学习算法内部有随机性XGBoost 的列采样、LSTM 的权重初始化、训练时的 dropout每一步都可能引入随机扰动。如果你不在代码里固定种子实验结果根本不具备可复现性后面所有对比结论都会失真。解决方法是在每个可能引入随机性的地方显式设置种子import os import random import numpy as np os.environ[PYTHONHASHSEED] 0 random.seed(42) np.random.seed(42)同时给每个模型的random_state参数赋值比如 XGBoost 的random_state42深度学习模型在keras.utils.set_random_seed(42)。固定种子不是为了追求某个具体数值而是让你能确定「结果差异是数据或参数引起的而不是随机波动」。我习惯把种子写在脚本开头所有模型统一用一个值这样对比实验才站得住。6. 进阶验证滚动重训与基准对比判断模型是否真的有效前面把流程跑通了模型也回测了但你还不知道「这套东西到底有没有用」。最后一个验证手段是用滚动重训模拟真实部署环境。真实环境里你不可能永远用 2015 年训练好的模型预测 2023 年的行情。正确做法是每经过一段固定时间就重新训练一次。代码框架如下retrain_window 750 # 每次用 750 个交易日约 3 年训练 trade_window 250 # 每次验证 250 个交易日约 1 年 ic_list [] equity_list [] for start in range(0, len(X_raw) - retrain_window, trade_window): X_train_roll X_raw[start:start retrain_window] y_train_roll y_raw[start:start retrain_window] X_test_roll X_raw[start retrain_window:start retrain_window trade_window] y_test_roll y_raw[start retrain_window:start retrain_window trade_window] # 每次滚动都重新标准化避免旧统计量干扰 scaler_roll StandardScaler().fit(X_train_roll) clf_roll XGBClassifier(n_estimators200, max_depth3, random_state42) clf_roll.fit(scaler_roll.transform(X_train_roll), y_train_roll) pred_prob_roll clf_roll.predict_proba( scaler_roll.transform(X_test_roll))[:, 1] ic_roll st.spearmanr( pred_prob_roll, df[future_ret].iloc[start retrain_window: start retrain_window trade_window].values ).correlation ic_list.append(ic_roll) print(f滚动 IC 均值: {np.mean(ic_list):.4f})滚动重训的价值在于它看的是「模型在时间推进中的平均表现」而不是某一段特定行情下的偶然发挥。IC 均值如果能稳定在 0.03 以上说明模型确实有一部分预测力如果每次滚动 IC 都在 0 附近摆动说明前面测试集上的好表现只是运气。最后一步也是最容易被跳过的一步拿策略和「买入持有」对比。买入持有就是第一天全仓买入一直不卖它是这个市场里最朴素的基准。你的机器学习策略如果跑不赢买入持有那无论模型多复杂、回测多漂亮都没有实际价值。对比时同时看年化收益和最大回撤一个收益高但回撤大一倍不止的策略实盘很难拿得住。套路走完说点个人习惯。我最早做这类项目时在数据切分上偷懒直接train_test_split(shuffleTrue)回测曲线好看到极点实盘一测就露馅排查了整整一周才找到泄漏源头。现在我的习惯是在写第一行特征代码前先问自己一句「这个特征在 t 时刻真的拿得到吗」然后把所有拿不准的统统当作拿不到处理。这套「先泄漏排查、再滚动验证、最后对比基准」的流程能拦下九成不靠谱的策略想法。希望帮到你。本文还有配套的精品资源点击获取