简介:基于机器学习的股票价格预测算法包,面向金融数据与量化入门开发者,整合LSTM、Prophet、AutoARIMA、朴素贝叶斯、SVM、随机森林等算法,并内置基础回测系统,便于对比预测效果。压缩包共386个文件、约1.14MB,含357个CSV历史数据、18个Python脚本、HTML报告、PNG图表等,数据与代码分离,可直接运行修改。其中LSTM等神经网络实现配有门控机制与记忆单元的原理说明,帮助理解序列预测要点;整体从数据读取、模型训练到回测评估均有对应脚本,结构清晰。包内文件组织简洁,适合快速上手多算法股票预测与策略验证。目前已有306人学习下载,是一份轻量实用的算法参考资源。
1. 股票价格预测到底能不能落地:先列出边界再谈算法
一个把「基于机器学习的股票价格预测」当作核心话题的项目,最吸引人的地方是它一口气给出了两个东西:一个能跑通模型训练与评估的最小代码骨架,以及一个能把预测结果转化成收益曲线的回测系统。这两个东西凑在一起,就构成了一套「从历史数据到策略绩效」的完整链路,几乎是刚接触量化方向的人最想复现的起点。
但我要先把丑话说在前面:股票价格预测的难点从来不在算法库调用,而在数据切分是否严谨、特征里有没有穿进未来信息、回测时是不是偷偷用了次日数据。标题里提到的各种机器学习算法,无论线性回归还是 LSTM,在股价这种强噪声、非平稳、信噪比极低的数据上,都很难做出一份让实盘满意的成绩。所以这篇文章的价值在于:帮你把特征工程、模型选型、回测引擎按正确顺序落地,并绕开那些让回测曲线「过度漂亮」的常见陷阱。适合已经会一点 Python,想拿真实行情数据动手验证机器学习流程的从业者。
2. 特征工程与数据切分:为什么直接用收盘价训练必输
2.1 一句话说清股票预测里的「非平稳性」坑
股票价格序列本身是非平稳的。价格有趋势、有均值回归、有波动率聚集,直接把原始收盘价喂给线性回归或者随机森林,模型很容易学习到一条「昨天涨今天也涨」的近似路径,在训练集上拿到极高的 R²,但换到验证集立刻失效。这个现象在金融时间序列里很常见:价格水平本身携带的信息远没有价格变化率有效。
更本质的原因是,普通监督学习假设训练样本和测试样本服从同一个分布,而股票行情每过一段时间,波动特征、市场风格都会变化。所以做股票价格预测时,第一原则是:把「价格水平」转换成「收益率」或「对数收益率」,让目标变量变得平稳,模型学到的是相对变化而不是绝对价位。这也是我在接触这类项目时,最先检查的一条数据处理习惯。
另一个常被忽略的点是特征与目标之间必须严格隔开。如果特征里使用了当天收盘价,而目标也是当天收盘价或当天涨跌,模型会直接把特征复制到输出,得到惊人的分数,实盘却一分钱赚不到。特征工程的设计目标,是用过去 N 天的信息去预测未来某一天的走向,中间不能有任何重叠。
2.2 用 pandas 写一个可直接落地的特征脚本:滞后值、收益率与技术指标
下面这份脚本是我在这个方向上常用的最小特征构造方案,你可以直接把它当成项目骨架的一部分。它把原始 K 线 DataFrame 转成一个含滞后特征、滚动统计量、收益率标签的训练表。
import pandas as pd import numpy as np def build_features(df: pd.DataFrame, lags: int = 5, window: int = 10) -> pd.DataFrame: """ df: 必须包含 'open','high','low','close','volume' 列,按日期升序 lags: 滞后几天的收益率特征 window: 滚动窗口长度 返回: 新的特征表,自动把原始价格列去掉,只保留衍生特征和目标 """ data = df.copy() # 1. 收益率序列,比价格水平稳定得多 data['ret'] = data['close'].pct_change() # 2. 滞后收益率序列,构成模型能看到的历史窗口 for i in range(1, lags + 1): data[f'ret_{i}'] = data['ret'].shift(i) # 3. 滚动统计量:均值、标准差、成交额 data['ret_ma'] = data['ret'].rolling(window).mean() data['ret_std'] = data['ret'].rolling(window).std() data['volume_ma'] = data['volume'].rolling(window).mean() # 4. 目标变量:未来一天收益率,注意 shift(-1) 才是未来 data['target'] = data['close'].shift(-1) / data['close'] - 1 # 5. 去掉包含 NaN 的前若干行和最后一行,避免把空值喂给模型 data = data.dropna() # 6. 只保留特征列和目标,原始价格序列不再参与训练 feature_cols = [c for c in data.columns if c.startswith('ret') or c.startswith('volume_ma')] feature_cols = [c for c in feature_cols if c != 'target'] return data[feature_cols + ['target']]这段代码里最关键的是shift(i)和shift(-1)的搭配。ret_i用正位移,只拿过去的信息;target用负位移,表示未来一天的收益。模型看到的是第 N 天之前共 5 天的收益率和滚动窗口统计量,去预测第 N 到第 N+1 天的收益,特征与标签在时间轴上严格错开。
特征里没有直接放收盘价和开盘价,是因为价格水平不稳定,而收益率和波动率具备更好的跨时间可比性。volume_ma用于捕捉量能变化,对大多数股票来说,放量下跌和缩量下跌的后续走势差异明显。实际使用时,lags可以从 5 调到 20,window常见取 5、10、20。数据量小的时候窗口太大只会让有效样本变短,不必一味求大。
2.3 训练/验证/测试切分:改掉随机打散,改成时间窗
如果沿用 sklearn 默认的train_test_split(random_state=42),数据会被随机打散,训练集里混入未来样本,验证集里出现过去样本。这种切分方式在普通表格数据里没问题,但在股票价格预测里是致命的:序列相关性会让模型在验证集上「偷看」到同一段行情趋势,评估结果虚高。正确做法是严格按日期顺序切分。
def temporal_split(df: pd.DataFrame, train_ratio=0.7, valid_ratio=0.15): n = len(df) train_end = int(n * train_ratio) valid_end = int(n * (train_ratio + valid_ratio)) train = df.iloc[:train_end] valid = df.iloc[train_end:valid_end] test = df.iloc[valid_end:] return train, valid, test # 用法示例 train_df, valid_df, test_df = temporal_split(feature_df) X_train, y_train = train_df.drop(columns=['target']), train_df['target'] X_valid, y_valid = valid_df.drop(columns=['target']), valid_df['target'] X_test, y_test = test_df.drop(columns=['target']), test_df['target']建议比例按 7:1.5:1.5 或 8:1:1 都行,关键是中间不能有随机过程。如果你手里有多只股票的数据,还应该把切分做成「按时间点统一切」,也就是同一日期在所有股票上划分一致的训练/验证边界,否则同一时间段的行情会同时出现在两个集合里,造成股票间交叉泄漏。
如果数据量够大,更稳的方案是滚动前推验证:训练集从窗口起点滚到 T,验证集是 T+1 到 T+2,然后训练集延到 T+2,验证集再往后推。这其实就是前面提到的「滚动再训练」的雏形,我们会在第 6 章展开。
3. 机器学习模型怎么选:从线性回归到 LSTM 的最小实现
3.1 数据量决定模型梯队:先跑哪三个基线模型
股票价格预测项目里最容易犯的错,是一上来就上 LSTM 之类的大模型。真实行情的日线数据量非常有限,一只股票十年也就两千多个交易日,经过滞后和滚动窗口处理后,有效样本进一步缩水。这种数据规模下,深度学习模型很难发挥优势,反而容易过拟合。
我的常见做法是先按数据量排三个梯队:第一梯队是线性回归、岭回归,用来确认特征方向是否大致正确;第二梯队是随机森林或梯度提升树,适合处理特征间的非线性关系和异常值;第三梯队才是 LSTM、Transformer 这类序列模型,前提是你有足够的分钟线或者多年日线数据,且愿意花时间调参。把线性回归跑通后,再用树模型对比,如果树模型没有明显优势,说明数据中的线性信号已经占主导,强行加深模型多半是徒劳。
3.2 用 scikit-learn 完成多模型对比的代码骨架
下面这段代码把三种模型串在一个脚本里,输出训练集和验证集的预测方向准确率。方向准确率比 R² 或 RMSE 更接近股票预测的实际目标,因为交易决策最终关心的是涨还是跌。
from sklearn.linear_model import Ridge from sklearn.ensemble import RandomForestRegressor from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_squared_error import numpy as np models = { 'ridge': Ridge(alpha=1.0), 'random_forest': RandomForestRegressor( n_estimators=200, max_depth=5, min_samples_leaf=20, random_state=42 ), 'gradient_boosting': GradientBoostingRegressor( n_estimators=100, max_depth=3, learning_rate=0.05, random_state=42 ), } for name, model in models.items(): model.fit(X_train, y_train) y_pred_train = model.predict(X_train) y_pred_valid = model.predict(X_valid) # 把回归预测值转成方向:预测收益大于0记为涨 train_dir_acc = (np.sign(y_pred_train) == np.sign(y_train)).mean() valid_dir_acc = (np.sign(y_pred_valid) == np.sign(y_valid)).mean() print(f'{name}: train_acc={train_dir_acc:.4f}, valid_acc={valid_dir_acc:.4f}')Ridge 加alpha=1.0是为了压一下特征共线性,防止滞后收益率之间互相放大。随机森林里我严格限制了max_depth=5和min_samples_leaf=20,目的是让每棵树不要学得太细,因为金融数据噪声极大,树一旦深了就记住历史噪音。梯度提升树同理,learning_rate=0.05搭配 100 棵树,不容易在验证集上快速过拟合。
训练时看train_acc和valid_acc的差:如果训练集准确率 90%,验证集只有 51%,那就是严重过拟合。反过来说,如果训练集只有 53%,验证集也有 52%,说明模型确实学到了弱但稳定的规律,这反而是股票预测里更常见、更可信的状态。
3.3 LSTM 里最少要调的三个参数:序列长度、隐藏层、丢弃率
如果数据量足够,LSTM 是一个值得尝试的序列模型版本。它需要先把数据改成三维张量(样本数, 时间步, 特征数),和前面那套特征表的结构不同,要注意区分。这里我给出一个用 Keras 实现的最小版本,并说明最值得调的三个参数。
import numpy as np from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam def make_lstm_dataset(feature_df, time_steps=20): """ 把特征表转成监督学习的三维窗口数据 time_steps 相当于让模型看多少根K线再预测 """ data = feature_df.drop(columns=['target']).values target = feature_df['target'].values X, y = [], [] for i in range(len(data) - time_steps): X.append(data[i:i+time_steps]) y.append(target[i+time_steps]) return np.array(X), np.array(y) X_lstm, y_lstm = make_lstm_dataset(feature_df, time_steps=20) # 按时间顺序切分,注意这里不能用前面重排过的训练集 split1 = int(len(X_lstm) * 0.7) split2 = int(len(X_lstm) * 0.85) X_train_l, X_valid_l = X_lstm[:split1], X_lstm[split1:split2] y_train_l, y_valid_l = y_lstm[:split1], y_lstm[split1:split2] model = Sequential([ LSTM(units=32, return_sequences=True, input_shape=(20, X_lstm.shape[2])), Dropout(0.2), LSTM(units=16, return_sequences=False), Dropout(0.2), Dense(8, activation='relu'), Dense(1) ]) model.compile(optimizer=Adam(learning_rate=0.001), loss='mse') model.fit( X_train_l, y_train_l, validation_data=(X_valid_l, y_valid_l), epochs=30, batch_size=32, verbose=0 )第一个必调参数是time_steps,也就是模型一次看多少天。日线数据下我一般从 10 到 30 之间试,太短学不到趋势,太长会把久远且不相关的行情也纳入,反而增加噪声。第二个是 LSTM 的units,它不是越大越好,金融序列信号弱,32 或 64 起步足够。第三个是Dropout,建议在每层 LSTM 后都加,取值 0.2 到 0.4,能明显缓解过拟合。
还有一个容易被忽略的点:训练 LSTM 时要把特征做归一化,但归一化的均值和方法只能用训练集的统计量,验证集和测试集要复用同样的参数。这个问题在下一章会专门讲,因为它非常隐蔽,又直接影响所有模型的结果。
4. 基础回测系统如何搭建:从预测信号到收益曲线
4.1 回测引擎的最小接口:先想清楚「谁在喂数据」
标题里提到这个项目自带回测系统。回测系统本质上做一件事:把预测结果按时间顺序翻译成持仓和收益。它接收的是模型在每一天给出的信号,输出的是资金曲线、交易记录和绩效指标。
写回测引擎之前,先定两个接口约定:模型输出的是「当天收盘后的信号」,交易在「次日收盘价」执行,这是最稳妥也是最常用的对齐方式。这样避免了当天收盘时看到信号、当天就用同一根收盘价成交的荒谬情况。另一个约定是持仓只做多不做空,最小变动单位是一整手。对于没有真实交易经验的开发人员,先实现多头仓位,再考虑融券做空,逻辑会清晰很多。
# 回测引擎的最小输入输出约定 # signals_df: index 为日期,包含 'signal' 列,取值为 1 或 0 # price_df: index 为日期,包含 'close' 列,表示回测用的价格 # 输出:每日持仓、资金净值、交易明细有了这种约定,任何模型的预测结果都能直接灌进回测系统,不需要为不同模型改代码。
4.2 交易模拟:以次日收盘价成交并计入手续费滑点
下面是一段可运行的最小回测引擎代码。它逐日遍历,遇到信号变化就换仓,手续费和滑点由参数控制。
import numpy as np import pandas as pd def run_backtest(signals: pd.Series, prices: pd.Series, commission=0.0005, slippage=0.0002): """ signals: 每日信号,1 表示持有,0 表示空仓 prices: 每日收盘价 commission: 单边手续费率,默认万5 slippage: 单边滑点比例,默认万2 """ dates = prices.index cash = 1_000_000 # 初始资金 position = 0 # 当前持有市值 records = [] for i, date in enumerate(dates): price = prices.iloc[i] # 获取前一天的信号,因为当天的收盘信号要次日执行 if i == 0: target_position = 0 else: target_position = signals.iloc[i - 1] # 仓位调整 if target_position == 1 and position == 0: # 买入:按次日收盘价成交,扣手续费和滑点 buy_price = price * (1 + slippage) position = cash * (1 - commission) / buy_price cash = 0 elif target_position == 0 and position > 0: # 卖出 sell_price = price * (1 - slippage) cash = position * sell_price * (1 - commission) position = 0 # 每日资产净值 = 现金 + 持仓市值 if position > 0: equity = position * price else: equity = cash records.append({'date': date, 'equity': equity, 'position': target_position}) result = pd.DataFrame(records).set_index('date') result['return'] = result['equity'].pct_change().fillna(0) return result这里的核心是「信号用昨天收盘的预测,成交用今天收盘价」,从机制上杜绝了未来函数。手续费commission按双边各收一次,滑点按成交价乘以比例,虽然简单,但已经能把回测和真实交易之间的大部分差距体现出来。如果去掉这两项参数,回测会显得过于乐观,这一点我们下一章重点讨论。
如果你用的是分钟级数据,滑点设定还要考虑盘口深度;如果是日线数据,万二滑点基本够用。A 股场景下还需要额外考虑涨跌停限制和 T+1 制度,实盘里买入当天不能卖出,回测引擎最好也加上「当日买入当日不可卖」的约束,否则资金周转效率会被高估。
4.3 三个必看的绩效指标:年化、夏普、最大回撤
回测做完不能只看最终的资产总额,至少要看年化收益率、夏普比率和最大回撤三个指标。它们分别回答:赚钱快不快、赚钱稳不稳、赔钱狠不狠。
def performance_stats(equity_curve: pd.DataFrame, trading_days=252, risk_free_rate=0.02): # equity_curve 需包含 'equity' 和 'return' 两列 returns = equity_curve['return'] total_ret = equity_curve['equity'].iloc[-1] / equity_curve['equity'].iloc[0] - 1 years = len(equity_curve) / trading_days annual_ret = (1 + total_ret) ** (1 / years) - 1 # 夏普比率:超额收益除以波动率 excess_ret = returns.mean() * trading_days - risk_free_rate vol = returns.std() * np.sqrt(trading_days) sharpe = excess_ret / vol if vol > 0 else 0 # 最大回撤:从历史峰值下跌的最大幅度 peak = equity_curve['equity'].cummax() drawdown = equity_curve['equity'] / peak - 1 max_drawdown = drawdown.min() return { 'total_return': total_ret, 'annual_return': annual_ret, 'sharpe': sharpe, 'max_drawdown': max_drawdown }sharpe年化参数取 252,对应一年大约 252 个交易日。A 股做空受限,这个夏普算法偏乐观,因为只有多头仓位。max_drawdown是风险铁指标,一个年化 50% 但最大回撤 70% 的策略,实际体验非常恶劣,净值稍微波动就会把投资者吓出局。回测系统把这三个指标输出后,才能判断该模型值不值得继续研究。
5. 股票价格预测中的常见坑与排查:回测好看不一定实盘好用
5.1 数据泄漏:把未来信息写进特征,回测曲线「过度漂亮」
现象:训练集准确率很高,验证集准确率更高,回测曲线几乎沿着最大涨幅一路上行,年化收益高到不真实。
原因:特征构造里混入了未来数据。最常见的是用当天的收盘价同时构造特征和目标,例如ret = close.pct_change()之后,直接用ret做特征,又用第二天的ret做目标,中间某步shift方向搞反了。其次是rolling窗口默认包含当前行,如果当前行已经包含「当天涨幅」,而目标也是当天或次日的涨跌,就相当于让模型偷看了答案。
解决:每次做完特征表,打印出来人工核对一行。对第 T 行的特征,确认它只用到第 T 天及之前的数据;对第 T 行的目标,确认它是第 T+1 天或之后的收益。我习惯在特征表里留一个date列,抽查随机几行的特征和时间戳是否严格对齐,这个习惯救了我很多次。
5.2 归一化视野穿越:全样本 MinMax 是回测里最常见的错
现象:做 LSTM 或需要归一化的模型时,对整段数据用了一次MinMaxScaler.fit_transform,训练集和验证集分数看起来都正常,但一换成滚动预测就崩盘。
原因:fit_transform在全部数据上计算最小值和最大值,等于让训练过程提前知道了未来的价格区间。在回测中,这相当于每一轮都看了全局,模型在验证集上的得分被严重抬高。这是股票价格预测里最典型的时序泄漏,比特征重叠更隐蔽,因为单看训练代码很难发现。
解决:先切分数据,再在训练集上fit,验证集和测试集上只transform。如果是滚动训练,每一轮都重新fit一次,不能复用上一轮的缩放器。代码上建议把缩放器保存下来:
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler() X_train_scaled = scaler.fit_transform(X_train) X_valid_scaled = scaler.transform(X_valid) # 只复用参数,不重新计算 X_test_scaled = scaler.transform(X_test)5.3 未来函数:用当日收盘信号却在当日开盘成交
现象:回测里交易频繁,每次买卖都精准踩在高低点上,胜率很高,但把同样的信号移到实盘就面目全非。
原因:信号生成和交易执行用了同一根 K 线。例如模型用当天收盘价预测当天涨跌,交易代码却假设当天能以开盘价成交。实际上当天开盘时收盘价还不存在,这是从未来返回过去交易。
解决:把模型预测和交易执行之间至少隔一个周期,也就是第 4 章代码里的做法:第 T 天收盘后生成信号,第 T+1 天成交。排查方法是在交易明细里把signal_date和trade_date都打印出来,逐一检查每笔交易是否满足trade_date >= signal_date + 1。
5.4 手续费与滑点设成 0:收益曲线直接「高烧」
现象:回测年化收益 80%,最大回撤只有 10%,但实际上实盘跑下来不仅不赚,频繁交易还亏了不少手续费。
原因:回测参数里手续费和滑点都填了 0,或者只按单边万三设置,没考虑冲击成本和涨跌停封单。高频交易策略对固定交易成本极其敏感,每天换仓一次的策略,一年 200 多次交易,手续费和滑点叠起来会吃掉大量收益。
解决:至少按双边万五手续费加万二滑点起步,交易频率越高,成本参数越要保守。更好的办法是把手续费参数设计成回测引擎的必填参数,不填就报错,强制每一个策略都在带成本的环境下验证。用第 4 章的run_backtest跑一遍,对比commission=0和commission=0.001的结果,你会发现两者差距大得惊人。
6. 进阶验证:用滚动再训练检验预测稳定性,再谈是否实盘
回测跑完只是第一步,真正决定这个方案值不值得投入的,是模型在滚动窗口下的稳定表现。我见过太多项目在固定切分下效果尚可,一改成滚动训练就原形毕露,原因是市场风格会变,旧规律不断失效。
滚动再训练的思路是:每 N 天用过去 M 天的数据重新训练模型,然后在接下来 K 天测试,窗口不断平移。代码实现并不复杂,关键在于每次训练只使用当前窗口之前的数据,绝不扩展到窗口之外。
def rolling_retrain(feature_df, model_factory, train_days=500, test_days=50, step=50): """滚动训练验证:返回每一段测试集的预测方向准确率""" accs = [] start = 0 while start + train_days + test_days <= len(feature_df): train_part = feature_df.iloc[start:start + train_days] test_part = feature_df.iloc[start + train_days:start + train_days + test_days] X_tr = train_part.drop(columns=['target']) y_tr = train_part['target'] X_te = test_part.drop(columns=['target']) y_te = test_part['target'] model = model_factory() model.fit(X_tr, y_tr) pred = model.predict(X_te) acc = (np.sign(pred) == np.sign(y_te)).mean() accs.append(acc) start += step return accsmodel_factory是一个返回模型的函数,例如lambda: Ridge(alpha=1.0),这样每段窗口的模型都是干净重新训练的。观察 accs 列表里的趋势:如果大部分分段在 0.5 附近浮动,说明模型没有稳定规律;如果存在几段明显较高然后迅速下滑,说明策略只在一段时间内有效;如果整体稳定在 0.52 以上,并且没有异常高的段,反而是更可信的结果。
这种验证方式比跑一次固定测试集更能暴露模型的真实水平。做股票预测我不敢看单次回测的绩效,只敢看滚动验证后的平均准确率和最差段回撤。只要平均准确率过不了 0.52,我大概率会放弃当前特征组合,回去重新挖特征,而不是继续调模型参数。
最后补充一个我坚持了很长时间的习惯:每次实验都要跑一个「买入持有」的基线策略做对比。如果模型预测出的策略赚得比买入持有少,那这个模型再复杂也没有实盘意义。把这一点写进你的回测系统,让它自动输出基准曲线的对比图,你就能少走很多弯路。希望这篇笔记能帮你把项目跑稳,更希望你能在动手预测之前,先冷静地把回测的坑填完。
本文还有配套的精品资源,点击获取