资讯动态

用Python线性回归做股票预测:AAPL量化全流程复盘

发布时间:2026/10/22 3:44:45 来源:尧图企业网站定制
最近被一个做金融数据分析的朋友问到一个有点扎心的问题“我把AAPL过去十年的历史数据丢进线性回归训练集上R²能拉到0.9以上为什么一放到实盘预测就翻车”这个问题基本概括了线性模型在量化分析里的所有尴尬它在数据拟合上很能打但在价格预测上却常常露怯。这篇博文我想用一次完整的实操复盘来讲清楚这件事——项目不复杂拿Python金融量化常用的工具链对AAPL股票数据做一次从数据清洗、特征构建、线性回归建模到滚动样本外验证的全流程分析。文章会尽量按真实做项目的顺序推进每一步都给出Python代码和能落地的参数选择顺便把那些教科书里不会写、只在实盘调试中才会遇见的坑一并挑明。适用于刚开始接触量化分析、想用线性模型练手的开发者也适合想搞清楚“为什么模型在行情数据上这么不稳定”的老朋友。读完你会明白线性模型在金融分析里真正的价值不在于做预测的“水晶球”而在于当一个足够透明、可解释、能反复检验的基准模型。1. 项目定位线性模型能给股票分析带来什么1.1 从噪声行情里找规律的真实场景做金融量化的人第一课往往不是学策略而是学“认输”——你得先承认股价数据里的大部分波动是噪声真正能被模型捕捉的结构性规律很少。AAPL这类高流动性标的尤其典型日线数据看起来趋势清晰但拆到分钟、小时级别随机性大得可怕。线性模型恰恰是一个很好的“照妖镜”它能帮你区分哪些规律是真实存在的哪些只是数据的偶然巧合。这个项目里我选择AAPL作为分析对象不是因为它有多特殊而是看中三点一是历史K线数据完整日线数据跨度足够长方便做滚动窗口验证二是它的交易量大、参与者众多价格行为比较接近有效市场假说里的理想样本更适合用线性模型去“测试底线”三是代码在它上面跑出来的结论放到其他主流标的上也大体成立不会因为数据太奇怪而失去参考价值。至于为什么选线性回归而不是直接上神经网络核心原因是这个阶段我要的不是“预测有多准”而是“模型到底学到了什么”。线性回归的参数有明确的业务含义一个特征系数是正还是负能直接告诉我这个因子和未来收益是正相关还是负相关。这种可解释性在量化研究的早期探索里比花哨的深度学习结构值钱得多。1.2 线性模型的定位解释大于预测很多人一开始就抱着“预测准确率100%”的目标来做股价预测这个期望需要尽快放下。线性模型在行情数据上最好的定位是当“探测雷达”和“基准线”。什么叫探测雷达就是你怀疑某个指标比如过去5天的涨跌幅、市场整体情绪、成交量变化对未来的股价方向有影响线性模型能帮你快速得到一个方向性的判断和影响程度的粗略估计。什么叫基准线你后来做了一个更复杂的模型总得有个对比对象如果复杂模型连线性回归都跑不过那它大概率只是在过拟合噪声而不是学到了更好的规律。基于这个认知我给这个项目定下的目标是利用AAPL的日线数据构建一个以线性回归为核心的预测流程检验“动量因子”“波动率因子”“成交量因子”是否对AAPL未来短期收益方向存在稳定的解释力。输出物包括训练集拟合报告、滚动窗口样本外预测的胜率和盈亏比以及一段可复现的Python代码框架。这套东西做扎实了后续换数据、换因子、换模型只是改配置的事。2. 数据工程AAPL日线数据的获取与特征构建2.1 数据源选型一条命令拉取完整行情做金融量化踩过最多坑的环节其实是数据获取。行情数据错一天、缺一列、前复权和后复权搞错后面所有分析都跟着错。我用的是yfinance它对日常分析足够友好一条命令就能拿到OHLCV开盘、最高、最低、收盘、成交量日线数据。代码长这样import yfinance as yf import pandas as pd import numpy as np df yf.download(AAPL, start2015-01-01, end2023-12-31, auto_adjustTrue) df df[[Open, High, Low, Close, Volume]].copy() df.index pd.to_datetime(df.index) print(df.head())有两个细节值得展开。第一参数auto_adjustTrue非常重要它会在下载时自动应用复权因子处理股票分红和拆股带来的价格跳空。如果不做复权AAPL在历史上有过拆股记录原始价格会出现不连续的断崖回归模型会把这种拆股造成的“假下跌”当成真实规律去学习得出的系数自然毫无意义。第二日期索引一定要显式转成pd.to_datetime并做升序排列后面做滚动窗口、合并特征的时候索引数据类型不统一会引发各种诡异报错。获取数据之后的第一步永远是“看一眼全貌”print(df.info()) print(df.isna().sum()) print(df.describe())金融数据的一大特点是“看似规整实则到处是毛病”节假日导致停牌、极端行情导致数据源推送缺失、本地时区转换导致日期错位都会让DataFrame出现空值或重复索引。对于日线级别的分析我的习惯是如果某个交易日缺失直接前向填充ffill()因为从交易逻辑上说停牌日的收盘价延续上一个交易日的价格是合理的近似成交量则可以先填0再按后续逻辑处理。但要注意如果缺失值超过总长度的1%就得回到数据源重新检查拉取逻辑靠补值是补不出可靠结论的。2.2 特征工程给线性模型配好输入线性模型不像神经网络它不会自动帮你找特征组合喂进去什么它就只能看到什么。所以这个环节做得好不好直接决定模型上限。针对AAPL日线数据我构建了下面几类特征每个特征都有明确的交易含义特征名计算方式含义ret_1Close / Close.shift(1) - 1昨日单日收益率短期反转/动量信号ret_5Close / Close.shift(5) - 1近5日动量ret_10Close / Close.shift(10) - 1近10日动量vol_5ret_1.rolling(5).std()近5日波动率风险水平代理vol_20ret_1.rolling(20).std()近20日波动率中期风险代理volume_ratioVolume / Volume.rolling(20).mean()当日成交量相对20日均值的倍数量能异动high_gap(High - Close) / Close上影线比例盘中抛压信号low_gap(Close - Low) / Close下影线比例盘中承接信号构建代码如下df[ret_1] df[Close].pct_change() df[ret_5] df[Close].pct_change(5) df[ret_10] df[Close].pct_change(10) df[vol_5] df[ret_1].rolling(5).std() df[vol_20] df[ret_1].rolling(20).std() df[volume_ratio] df[Volume] / df[Volume].rolling(20).mean() df[high_gap] (df[High] - df[Close]) / df[Close] df[low_gap] (df[Close] - df[Low]) / df[Close]这些特征的计算逻辑要拆开看才有味道。ret_1和ret_5是一对“矛盾组合”它们分别捕捉短期反转和中期动量到底谁占上风在不同市场阶段答案完全不一样线性回归会用系数大小自动给出一个加权答案。vol_5和vol_20的差值反映的是短期波动相对长期波动是放大还是收缩这是个低延迟的风险监测指标。volume_ratio直接刻画量能股价上涨时如果配合放量信号可靠性远高于缩量上涨这个特征在纯价格模型里经常被忽略但它往往能提供差异化的解释信息。做完特征之后记得删掉前20个包含空值的样本因为滚动窗口计算会让最早期的数据不完整df df.dropna()这里有个容易忽略的点特征计算顺序不能乱。ret_5依赖Closevol_5依赖ret_1volume_ratio依赖Volume任何一步提前dropna都会导致后续滑动窗口计算错位。我习惯先把所有特征都算完最后统一去缺失值中途不做任何删除操作。2.3 训练集构造时间切分与防未来泄漏金融时间序列和常规机器学习最大的区别在于样本之间存在强时间关联。用train_test_split的默认随机切分方式处理时间序列是新手最容易踩的雷随机抽样会把相邻日期的数据一个放进训练集、一个放进测试集模型等于提前“看”到了测试集的信息样本外表现自然虚高。正确的做法是按时间顺序切分。我通常把前80%的数据作为训练集最后20%作为测试集train_size int(len(df) * 0.8) train df.iloc[:train_size] test df.iloc[train_size:]这个切分比例不是拍脑袋定的。对于日线数据如果总长度为2200个交易日左右大约8年数据训练集用1700多天足够覆盖多种市场环境测试集用400多天也足以支撑胜率统计的显著性。如果测试窗口太短比如只有50天那胜率统计信噪比太低模型好坏基本靠运气。防泄漏还有另一个容易忽略的口子标准化。如果先在整个数据集上计算均值和标准差再切分训练集和测试集测试集的统计信息已经在预处理阶段被模型间接使用过了。正确做法是只在训练集上fit标准化器再用同一套参数transform测试集。from sklearn.preprocessing import StandardScaler feature_cols [ret_1, ret_5, ret_10, vol_5, vol_20, volume_ratio, high_gap, low_gap] scaler StandardScaler() X_train scaler.fit_transform(train[feature_cols]) X_test scaler.transform(test[feature_cols]) y_train train[ret_1].shift(-1).dropna().values这个y_train的构造需要格外说明。我选的标签是“下一日收益率”也就是用今天及今天以前的信息去预测明天开盘或收盘的变动。.shift(-1)把收益率序列往前挪一天让第t天的特征对应第t1天的收益正是为了对齐时间窗口。如果这一步做反了模型就会学到“今天预测今天”的虚假高准确率回测结果会让你兴奋一晚上实盘亏一星期。3. 建模拟实练趋势线、多因子与正则化3.1 第一条回归线用日期预测收盘价做任何建模实验我建议都从最朴素、最“弱智”的模型开始。这里的第一条线就是用交易日序号去预测收盘价说白了就是画一条时间趋势线import statsmodels.api as sm df[time_idx] np.arange(len(df)) X_time sm.add_constant(df[time_idx]) y_close df[Close] model_time sm.OLS(y_close, X_time).fit() print(model_time.summary())statsmodels的好处是回归结果报表详细t统计量、p值、F统计量一眼看全。跑完之后你会看到一个非常“漂亮”的结果截距项显著时间项系数显著整体拟合优度很高。如果只看这些数字你会觉得“股价趋势完全可以用时间预测”但这里藏着一个经典的统计学陷阱。这个陷阱叫伪回归。AAPL收盘价本身是随机游走过程它的水平Level会随时间漂移而时间变量又是一个严格递增的序列两个有趋势的序列放在一起回归哪怕它们本质毫无关系也很容易跑出高R²和显著系数。这个结果漂亮得毫无意义因为它不符合线性回归的经典假设——残差应该是平稳的、均值为零的随机扰动但价格数据天然不满足。检验伪回归的标准方法是ADF单位根检验。对“价格”做该检验会得到一个很大的p值说明序列存在单位根对“日收益率”做同样的检验p值迅速趋近于0说明收益率是平稳的。这就是为什么后面建模一定用收益率而不是价格——收益率序列内部才是真正可以被线性模型尝试解释的规律所在价格序列里的大部分“规律”都是数据本身的惯性。3.2 多因子线性回归把市场信息塞进方程接下来是重头戏把第2节构建的8个特征一起丢进线性回归标签还是下一日收益率from sklearn.linear_model import LinearRegression model_mlr LinearRegression() model_mlr.fit(X_train, y_train) y_pred_train model_mlr.predict(X_train) coef_df pd.DataFrame({ feature: feature_cols, coef: model_mlr.coef_, abs_coef: np.abs(model_mlr.coef_) }).sort_values(abs_coef, ascendingFalse) print(coef_df)先强调一个细节这里标签我确实要先对齐再 fit所以写完整一点df[target] df[ret_1].shift(-1) df_model df.dropna() train_size int(len(df_model) * 0.8) train df_model.iloc[:train_size] test df_model.iloc[train_size:] X_train scaler.fit_transform(train[feature_cols]) X_test scaler.transform(test[feature_cols]) y_train train[target].values y_test test[target].values拿到系数表之后我第一件事不是看哪个系数大而是看系数的符号是否符合常识。比如ret_5和ret_10这类动量因子的系数通常为正说明过去一段时间涨得多的股票短期内倾向于继续涨这是动量效应的体现。vol_5和vol_20的系数通常为负说明波动率越高未来收益越不确定风险溢价为负在短期窗口内也说得通。如果某个系数符号和交易直觉完全相反不要急着怀疑模型先回头检查特征计算和标签对齐很多时候是数据处理时日期错位了一天。多因子回归的问题在于特征之间往往高度相关。ret_5和ret_10都是动量类指标天然相关vol_5和vol_20也纠缠不清high_gap又和收益率本身强相关。这种多重共线性会让系数估计变得极不稳定训练集和测试集之间系数的符号甚至可能翻转。3.3 正则化处理解决共线性的正确姿势面对多重共线性最有效的处理手段不是删特征而是正则化。岭回归Ridge和套索回归Lasso在线性模型的损失函数里加了一个惩罚项约束系数的大小。from sklearn.linear_model import RidgeCV ridge RidgeCV(alphasnp.logspace(-3, 3, 30)) ridge.fit(X_train, y_train) print(best alpha:, ridge.alpha_) print(pd.Series(ridge.coef_, indexfeature_cols).round(4))选Alpha的过程也值得细说。我用的是RidgeCV它会自动在给定的Alpha序列里做交叉验证挑出能使均方误差最小的那个惩罚强度。但注意这里的交叉验证如果直接用KFold会出问题因为时间序列的交叉验证必须保持时间顺序常用的替代方案是使用时间序列切分器比如TimeSeriesSplit。它按时间前进方向切出多组训练和验证集保证任何验证集样本的时间索引都晚于对应的训练集样本。在实际跑AAPL数据时我最常用的是TimeSeriesSplit(n_splits5)配合RidgeCV一起用得到的Alpha会比固定切分更稳定。后面滚动验证环节我也会沿用这个思路。Ridge适合应对“所有特征都有点用、但彼此相关”的场景它不会把系数压到零而是压缩系数的绝对值Lasso则倾向于把弱特征的系数直接压成0起到特征选择的效果。对收益率这种信噪比很低的数据我个人偏好先跑RidgeCV因为它保留了所有特征的贡献方便后续做因子解释性分析。如果追求更简洁的模型再跑Lasso对比一次看看哪些特征被踢出局了。4. 滚动窗口检验样本外预测才能撑住场面4.1 滚动窗口的逻辑与参数选择固定切分训练集和测试集只是第一步真正接近实战的检验方式是滚动窗口验证。逻辑不复杂设定一个长度为L的训练窗口用它预测接下来H天的收益然后把窗口整体向前滑动一步重新训练、重新预测直到覆盖全部样本。为什么必须这么做因为市场规律是时变的。8年前有效的一个因子现在可能已经完全失效牛市的动量效应在熊市里往往反转。固定切分只能证明模型在“那一个”时间切片上的样本外表现滚动窗口则能展示模型在不同市场阶段、不同波动环境下的稳定性这才是量化模型能否长期存活的关键。窗口参数L和预测步长H怎么选取决于你交易什么频率这一点后面单独说这里先给一个基础配置训练窗口L250个交易日大约一年。预测步长H5个交易日大约一周。滑行步长1个交易日也可改成H减少计算量。为什么训练窗口取一年太短比如20天模型会跟着最近一段行情剧烈抖动噪声被当成信号太长比如1000天模型又太钝无法适应市场风格切换。在AAPL这种高流动性标的上250天是一个能平衡“样本量足够”和“新鲜度足够”的经验值。4.2 滚动回归实战代码核心代码其实是一个循环每次训练都在窗口尾端做一次预测记录预测值和真实值。from sklearn.linear_model import Ridge window 250 step 5 records [] X_all scaler.fit_transform(df_model[feature_cols]) y_all df_model[target].values idx df_model.index for i in range(window, len(df_model) - step, step): X_train_roll X_all[i - window : i] y_train_roll y_all[i - window : i] X_test_roll X_all[i : i step] y_test_roll y_all[i : i step] model Ridge(alpharidge.alpha_) model.fit(X_train_roll, y_train_roll) y_pred_roll model.predict(X_test_roll) for j in range(len(y_test_roll)): records.append({ date: idx[i j], y_true: y_test_roll[j], y_pred: y_pred_roll[j], direction: 1 if y_test_roll[j] 0 else 0, pred_direction: 1 if y_pred_roll[j] 0 else 0 }) result_df pd.DataFrame(records)这段代码里有几个设计细节值得划重点。第一滑行步长我取的是step5也就是每预测完5天窗口一次性前进5天这样每次训练窗口的数据不重叠计算效率高很多也避免测试样本高度自相关导致统计指标失真。如果每1天滑一次结果会更平滑但训练耗时线性增长而且相邻测试样本几乎相同胜率统计的独立样本数并没有实质增加。第二每次滚动训练都新建一个Ridge对象相当于每个预测时刻都用最新数据重新学了一遍模型参数这样才能反映市场的时变性。第三预测的是未来5个交易日的每日收益而不是直接用第t5天相对第t天的整体收益后者会丢失中间每天的路径信息对交易参考价值更小。4.3 结果度量胜率、盈亏比与回撤怎么算拿到预测记录后不能只盯着准确率一个数字那会掩盖很多问题total len(result_df) hit result_df[direction] result_df[pred_direction] accuracy hit.mean() gains result_df.loc[hit, y_true] losses result_df.loc[~hit, y_true] avg_win gains.mean() if len(gains) else 0 avg_loss losses.mean() if len(losses) else 0 profit_loss_ratio abs(avg_win / avg_loss) if avg_loss ! 0 else 0 print(f样本数: {total}) print(f方向准确率: {accuracy:.2%}) print(f平均盈利: {avg_win:.4%}, 平均亏损: {avg_loss:.4%}, 盈亏比: {profit_loss_ratio:.2f})这些指标要组合起来解读。方向准确率50%是一个分水岭。如果准确率在52%左右单看会觉得很鸡肋但如果盈亏比超过1.2说明模型在判断对了的时候吃到的肉比判断错了时挨的打要多仍然有实际交易价值。反过来准确率60%但盈亏比只有0.6平均下来还是亏钱的因为做错时亏掉的仓位往往更大。条件期望的拆解更接近实战。市场上涨的日子里模型预测对的次数占比、市场下跌的日子里预测对的次数占比这两个比率差异往往比总体准确率更有意思。很多线性模型在牛市环境里预测上涨准确率很高但下跌行情里预测能力几乎为零这种不对称性直接影响你在回撤期的应对方案也提示你不能盲目相信单一模型后面可以考虑叠加风险控制模块。5. 线性模型在行情数据上的5个大坑5.1 伪回归一个必然踩的坑第一坑就是前面提到的伪回归。拿非平稳的价格序列和同样非平稳的时间变量做回归R²高到离谱系数显著到离谱实盘起来亏到离谱。这类问题的通用检验方法就是ADF单位根检验不通过的序列不要直接进入线性回归一律先转成收益率或差分序列。当然也存在一个常见的争论如果要预测的标的本身就是价格水平那不做差分行不行我的态度很明确如果目标是研究驱动因素比如判断动量因子是否有效那用收益率做标签更干净如果目标是直接预测N天后价格点位那误差项会带有明显的自相关结构必须用时间序列模型去专门处理不能用普通最小二乘的框架去套。5.2 多重共线性毁掉你的系数解读第二个坑是多重共线性。特征之间相关程度高回归系数的方差会被放大样本稍有变动系数就在正负之间大幅跳变。我见过最极端的案例同一个动量因子在2019年窗口里系数是正的、统计显著换到2022年窗口就变成负的、同样统计显著两个结果都能讲出故事但至少一个是在胡说。解决共线性不一定要删特征。用VIF方差膨胀因子诊断一下相关程度轻度的用Ridge严重的再考虑PCA或者直接剔除冗余变量。实操上我会优先保留经济逻辑上更直接的特征比如有了ret_5之后ret_10的增量信息有限删除它通常比加罚项更干脆。5.3 数据泄漏藏在预处理细节里第三个坑在数据预处理环节也是最隐蔽的。特征计算本身也可能造成泄漏。比如你想用“过去20天最高价突破”作为特征但这个突破信号如果你在当天收盘时已经知道那么用它预测当天之后的新开盘是合理的可如果你不小心用了未来20天的数据窗口去计算这个最高价就等于把未来的信息塞进了今天的特征里。更常见的泄漏来自归一化。Z-score标准化如果在整个数据集上先算均值和方差测试集的分布信息已经悄悄进入了模型。正确做法是每个滚动窗口内部各自fit标准化器再用同样参数去transform测试窗口之前第2节已经演示过这里再强调一次所有预处理步骤都必须以训练窗口为参照而不是以全局数据为参照。5.4 别用MSE当交易决策指标第四个坑是把回归模型的损失函数当成交易决策的唯一指标。MSE、RMSE这些指标衡量的是预测值和真实值的数值误差但在交易里真正影响盈亏的是方向正确性和盈亏比。一个模型可能RMSE很小因为它的预测值整体贴近真实价格但它对涨跌方向的判断准确率只有50%这种模型在交易上是没用的。更讽刺的是线性回归的损失函数天然会偏向“预测到主要的波动幅度”把许多大涨大跌的极端样本往均值方向收缩导致它天然低估尾部风险。这就是为什么我每次做完回归都会立刻算一次方向准确率和盈亏比哪怕回归模型的R²只有0.03只要方向胜率稳定在53%以上配合合理仓位管理它就是一个可以进入下一步回测验证的信号源。5.5 任务定义决定模型上限第五个坑比较抽象但影响最深远预测任务的定义方式决定了模型上限。把预测目标设为“未来5日总收益”和“未来5天每一天的收益方向”看起来只是标签形式的不同实际上建模难度差异巨大。“未来5日总收益”实际上是叠加了5天噪声的累积量直接回归需要模型在更长周期上保持稳定的解释力“每一天的收益方向”则噪音更高但胜率评估更接近交易视角。而如果把目标改成“未来5天最高价相对当前价的涨幅”那就是另一个更难的极值预测问题线性模型的适用性会进一步下降。做这个项目时我最终选择了“未来5日收益方向”作为核心任务因为它在理论可解性、样本数量和交易意义之间取得了平衡。这个选择不是唯一的但你必须清楚自己选了什么以及为什么选。6. 复盘这个项目到底学到了什么做完整个流程之后我拿AAPL同一段数据反复跑了不止一轮。说实话线性模型在这类行情数据上的样本外方向准确率很难稳定超过54%到55%这在很多人眼里不算一个令人兴奋的数字。但换个角度看在一个接近有效市场的标的上一个由几个粗糙因子堆出来的线性模型能保持正期望这件事本身就说明这些因子里确实含有微弱的、未被市场完全定价的信息。后面要扩展我给的推荐方向有三个。第一把单标的换成板块或全市场选股用线性回归做截面选股打分这才是线性模型在量化里更舒服的战场——不是预测价格而是排序。第二叠加非线性特征比如把波动率做成分箱变量或者加入技术指标的交互项在可解释性不变的前提下扩大模型容量。第三也是最推荐的把模型输出的预测值当成一个信号外面再接一层仓位管理和风险控制逻辑这才是一个完整的量化系统。这个项目让我对数据泄漏、窗口选择和指标定义有了更敏感的直觉。很多时候模型不赚钱不是因为数学不够复杂而是数据流里某个不经意的小错误让整个回测建立在幻觉之上。希望这篇复盘能帮你少踩几个同样的坑也欢迎你在自己的标的上跑一遍同样的流程对比一下结果会不会呈现不同的规律。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑