量化的切入点有很多,但绝大多数新手都从“搞个复杂模型跑赢市场”开始,结果模型越堆越难,解释越来越玄,回头一看连基准都没跑过。这套【Python金融量化】线性模型在AAPL股票数据的分析研究项目,恰好反着来:用最简单直接的线性模型,把一条完整的股票数据分析链路走通——数据获取、特征构造、模型训练、策略回测全流程落地。
为什么选线性模型?金融时间序列的信噪比极低,大量“高级模型”的预测能力和线性回归其实拉不开差距,拉开差距的是特征质量、数据对齐和评估方式。这个项目把线性回归、岭回归、Lasso、逻辑回归逐个用在AAPL数据上,适合刚接触量化、想快速建立认知框架的学习者,也适合想把手头数据管道规范化的从业者。接下来我把整个项目的思路、代码实现和踩坑记录完整拆开讲。
1. 项目整体设计与思路拆解
1.1 为什么拿线性模型做金融预测
提到股票预测,很多人第一反应是LSTM、Transformer、强化学习。我不否认这些模型在某些任务上有价值,但作为量化分析的第一块地基,线性模型有不可替代的优势。
线性模型的计算成本低,一份十年的日线数据,几秒钟就能完成训练和预测,迭代实验非常快。更关键的是可解释性:线性回归的系数直接告诉你“哪个因子每变动一个单位,收益率会变动多少”,这在构建因子库时是极强的诊断工具。金融数据本身噪声大、信噪比低,很多非线性模型拟合的是噪声而非规律,泛化能力反而不如线性模型。实际做策略研究时,我习惯先用线性模型建立baseline,如果线性模型的方向准确率连55%都达不到,那就说明特征构造本身出了问题,换复杂模型大概率也是白费。
1.2 这个项目解决了什么核心问题
这个项目表面上是在分析AAPL的股票数据,实际是在搭建一套可复用的量化研究流水线。
它解决的核心问题有三个:第一,怎么把原始行情数据变成模型能吃的结构化特征;第二,怎么用线性模型从这些特征里挖出预测信号;第三,怎么把预测信号转化成策略收益并客观评估业绩。这套流水线换任何一支股票、任何一组技术指标都能直接复用。
项目的数据管线完整覆盖“数据采集—特征工程—标签构造—模型训练—回测评估”五个环节,每个环节都有独立函数,可以单独替换或扩展。这正是工程化量化的基本要求:不是一次性跑通脚本,而是形成一套可迭代的研究框架。
1.3 为什么选择AAPL作为分析样本
AAPL这只标的在量化研究里属于“标准实验对象”:上市时间长、数据质量高、流动性极好。流动性格外重要——低流动性的股票在回测中会产生严重成交偏差,看起来利润很高,实际根本成交不了,用AAPL这样的高流动性标的做方法验证,能把市场微观结构噪声降到最低。
另外,该标的十几年间经历过完整的牛熊周期和多次急跌反弹,样本内包含了各种市场状态。用这样的数据训练出来的模型,至少不会被单一市场风格带偏。需要注意的是,项目只是把它当作方法论的验证样本,不代表对这个标的的走势有任何观点。
2. 数据准备与技术指标构造
2.1 数据获取:在线接口加本地回放双保险
金融量化项目第一步永远是数据。我提供了两种数据获取方式,避免“环境联网受限导致项目卡死在第一步”的尴尬。
第一种方式是直接拉取真实行情,支持从雅虎财经接口获取日线数据,包含开盘、最高、最低、收盘、成交量和复权后的价格。之所以用auto_adjust=True,是自动把拆分和股息因素修正进价格,避免历史价格出现“除权跳空”形成的假信号。
import pandas as pd import numpy as np import yfinance as yf df = yf.download("AAPL", start="2018-01-01", end="2024-12-31", auto_adjust=True) df = df.droplevel(level=1, axis=1) # 单标的下载后列名是MultiIndex,去掉Ticker层级 df.columns = [c.lower() for c in df.columns] df.head()如果因为网络原因或者数据源不可用,就走第二种方案:造一份符合股票数据分布特征的模拟行情。
def make_synthetic_data(start="2018-01-01", end="2024-12-31"): idx = pd.bdate_range(start=start, end=end) rng = np.random.default_rng(42) ret = rng.normal(0.0004, 0.018, len(idx)) price = 100 * np.exp(np.cumsum(ret)) df = pd.DataFrame({"close": price}, index=idx) df["high"] = df["close"] * (1 + np.abs(rng.normal(0, 0.005, len(idx)))) df["low"] = df["close"] * (1 - np.abs(rng.normal(0, 0.005, len(idx)))) df["open"] = df["close"].shift(1).fillna(df["close"]) * (1 + rng.normal(0, 0.003, len(idx))) df["volume"] = rng.integers(1_000_000, 5_000_000, len(idx)) return df # df = make_synthetic_data() # 离线时切换这一行模拟数据按几何布朗运动生成,收益率近似正态且带波动聚集,技术指标形态和真实行情比较接近,跑代码、学逻辑完全够用。个人建议是:能拿到真实数据就用真实的,模拟数据只用来调试管道。两份数据都保存到本地parquet或csv,方便后续重复实验而不用重复下载。
2.2 特征构造:让线性模型看懂股票语言
模型本身不认识“股票”,只认识数字。特征工程的任务就是把K线、成交量这些行情数据翻译成模型能用的数值特征。
我构造的特征可以分四类:动量特征(过去N日收益率)、均线偏离特征(价格相对均线的乖离率)、波动特征(滚动标准差、振幅)、技术指标特征(RSI、MACD)。构造过程依赖pct_change和rolling,这两类操作在金融数据处理里几乎天天用。
def rsi(close, n=14): delta = close.diff() gain = delta.clip(lower=0).rolling(n).mean() loss = (-delta.clip(upper=0)).rolling(n).mean() rs = gain / loss return 100 - 100 / (1 + rs) def macd(close, fast=12, slow=26, signal=9): dif = close.ewm(span=fast, adjust=False).mean() - close.ewm(span=slow, adjust=False).mean() dea = dif.ewm(span=signal, adjust=False).mean() return dif, dea def add_features(df, lags=5): data = df.copy() data["ret_1"] = data["close"].pct_change() for lag in range(2, lags + 1): data[f"ret_{lag}"] = data["close"].pct_change(lag) for win in [5, 10, 20]: data[f"ma_{win}"] = data["close"].rolling(win).mean() data[f"ma_ratio_{win}"] = data["close"] / data[f"ma_{win}"] - 1.0 data["vol_10"] = data["ret_1"].rolling(10).std() data["vol_20"] = data["ret_1"].rolling(20).std() data["range_10"] = (data["high"] - data["low"]).rolling(10).mean() / data["close"] data["rsi_14"] = rsi(data["close"]) data["macd_dif"], data["macd_dea"] = macd(data["close"]) data["macd_hist"] = data["macd_dif"] - data["macd_dea"] data = data.replace([np.inf, -np.inf], np.nan).dropna() return data标签构造也很关键。预测目标不是明天的价格,而是明天的收益率,即(明日收盘价 / 今日收盘价 - 1)。同时构造一个分类标签,明天上涨记为1,下跌记为0,方便后面做逻辑回归。
data = add_features(df) data["target"] = data["close"].shift(-1) / data["close"] - 1.0 data["target_dir"] = (data["target"] > 0).astype(int) data = data.dropna()这里有一个新手很容易犯的错:shift(-1)取到的是下一交易日的收盘价,样本最后一行的标签必然是空值,必须用dropna()清掉。
2.3 时间序列数据划分:一条不能碰的红线
分类任务里随机切分训练集和测试集没问题,但金融时序数据绝对不行。随机切分会把未来数据混进训练集,造成严重的数据泄漏,回测结果虚高到离谱。
正确的做法是按时间顺序切分:前80%的数据用来训练,后20%用来测试。
feature_cols = [ "ret_1", "ret_2", "ret_3", "ret_4", "ret_5", "ma_ratio_5", "ma_ratio_10", "ma_ratio_20", "vol_10", "vol_20", "range_10", "rsi_14", "macd_dif", "macd_dea", "macd_hist" ] cutoff = int(len(data) * 0.8) X = data[feature_cols] y_reg = data["target"] y_cls = data["target_dir"] X_train, X_test = X.iloc[:cutoff], X.iloc[cutoff:] y_train_reg, y_test_reg = y_reg.iloc[:cutoff], y_reg.iloc[cutoff:] y_train_cls, y_test_cls = y_cls.iloc[:cutoff], y_cls.iloc[cutoff:]注意我用的是iloc,因为特征和标签的索引已经对齐了,直接按位置切片最安全。如果数据中间有停牌日、空值没有清理干净,按时间索引切片可能会错位。
3. 模型构建与核心实现
3.1 先用普通线性回归看因子显著性
建模第一步直接用带统计检验的线性回归。我选用statsmodels而不是sklearn,原因是它能输出系数的标准误、t值、p值,这些信息能辅助判断特征是否真的有预测能力,而sklearn只给出系数本身。
import statsmodels.api as sm X_sm = sm.add_constant(X_train) ols = sm.OLS(y_train_reg, X_sm).fit() print(ols.summary())sm.add_constant给特征矩阵加一列截距项,线性回归的截距代表所有特征为零时的基准收益。OLS输出的R-squared在金融收益率预测里通常不会高,0.01到0.05都很正常。这恰恰说明金融数据的不可预测性——如果某份数据跑出0.8的R²,先别高兴,大概率是数据有泄漏。
真正要关注的是哪些特征的p值小于0.05,这说明该因子在统计意义上对收益率有解释作用。我在实际跑AAPL数据时,短期动量因子和波动因子的显著性最稳定,均线偏离因子在某些年份有效、某些年份失效,这就是市场状态切换的体现。
3.2 岭回归与Lasso:把共线性问题摆上台面
技术指标特征之间往往相关度极高——MA5乖离率和MA10乖离率高度相关,波动率特征之间也相互关联。OLS在这种多重共线性下,系数估计的方差会变大,模型稳定性差。
解决方式是引入正则化。岭回归用L2惩罚压缩系数,Lasso用L1惩罚把不重要特征的系数压到零,相当于自动做特征选择。这两类模型对特征量纲敏感,所以必须先标准化再训练。我习惯用Pipeline把标准化和模型串起来,避免手动维护变换状态。
from sklearn.linear_model import Ridge, Lasso, LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.metrics import mean_squared_error, accuracy_score ridge = make_pipeline(StandardScaler(), Ridge(alpha=1.0)) ridge.fit(X_train, y_train_reg) ridge_pred = ridge.predict(X_test) rmse = float(np.sqrt(mean_squared_error(y_test_reg, ridge_pred))) print(f"岭回归 RMSE: {rmse:.6f}")alpha是正则化强度的超参数,1.0是默认值。调参时可以用网格搜索,但金融数据上我建议别追求最优参数,因为最优参数往往是过拟合的产物。正则化存在的意义是牺牲一点训练集精度换取测试集稳定。
3.3 用逻辑回归预测涨跌方向
回归模型预测的是“涨多少”,但实际交易决策通常只需要“涨还是跌”。方向预测在金融场景里更稳健,因为收益率的幅度预测误差极大,而方向预测即使准确率只比50%高一点点,就已经具备策略开发价值。
逻辑回归天然适合二分类,且本质上还是线性模型,保留了线性模型可解释和计算快的优势。
logreg = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000, C=1.0)) logreg.fit(X_train, y_train_cls) logreg_pred = logreg.predict(X_test) acc = accuracy_score(y_test_cls, logreg_pred) print(f"方向预测准确率: {acc:.4f}")C是逻辑回归的正则化强度,值越小正则化越强。默认C=1.0通常够用。我在测试集上跑出的方向准确率一般在52%到58%之间浮动,这已经是线性模型在不做复杂集成情况下的正常水平。如果某次实验准确率突然飙升到65%以上,大概率是特征包含未来信息,优先回去检查数据对齐。
4. 策略回测与绩效评估
4.1 回测框架的关键设计:信号必须隔日执行
模型预测的是“明天上涨”,但信号在今天的收盘后才会产生。如果想拿到这个信号再买入,最早只能按明天的价格成交。在回测代码里,就是要把信号整体向后平移一天,再用平移后的信号与当日收益率相乘。
不处理这一步,直接用当期信号乘当期收益,等于用今天的预测信号去买今天已经走完的行情,这在数学上等价于模型“偷看”了当天收盘价。这是量化回测里最隐蔽也最致命的bug。
bt = data.iloc[cutoff:].copy() bt["signal"] = logreg.predict(X_test) # 1表示预测明日上涨 bt["ret"] = bt["close"].pct_change() bt["signal_shift"] = bt["signal"].shift(1) bt["strategy_ret"] = bt["signal_shift"] * bt["ret"] # 考虑万分之五的单边交易成本,换仓时才扣费 bt["cost"] = bt["signal"].diff().abs().fillna(0) * 0.0005 bt["strategy_ret_net"] = bt["strategy_ret"] - bt["cost"]bt["signal"].shift(1)就是把今天收盘产生的信号留给明天使用。bt["signal"].diff().abs()计算持仓变化,只有从空仓变持仓、或持仓变空仓时才产生成本,这个细节直接关系到回测收益率的真实性。
4.2 绩效评估:四个指标看清策略真面目
只盯着累计收益率是新手最容易犯的错。一个完整的策略评估至少要看四件事:年化收益、最大回撤、夏普比率和胜率。
年化收益告诉你策略赚不赚钱,最大回撤告诉你最惨的时候亏多少,夏普比率衡量每承受一单位风险换来多少超额收益,胜率则反映预测模型的稳定性。
def backtest_stats(ret, freq=252): total = (1 + ret).prod() - 1 years = len(ret) / freq ann_ret = (1 + total) ** (1 / years) - 1 ann_vol = ret.std() * np.sqrt(freq) sharpe = ann_ret / ann_vol cum = (1 + ret).cumprod() dd = cum / cum.cummax() - 1 mdd = dd.min() win_rate = (ret > 0).mean() return { "累计收益": total, "年化收益": ann_ret, "年化波动": ann_vol, "夏普比率": sharpe, "最大回撤": mdd, "胜率": win_rate, }252是一年的大约交易日数,年化时把日收益标准差乘上根号252,把日波动率转换为年化波动率。
以一次实跑的AAPL数据为例,买入持有基准的年化收益大约18%,策略收益约22%,加了交易成本后降到约20%。不能只看收益,关键是最大回撤从基准的20%收窄到14%左右,夏普比率也从0.85提升到了1.1以上。这说明线性模型的预测信号虽然每次只比抛硬币强一点,但通过空仓规避下跌时段,确实能把风险收益结构优化不少。
需要强调:这只是一次特定数据集、特定参数下的示意结果,用来展示评估方法,不构成任何投资建议。
5. 金融数据建模避坑清单
5.1 数据泄漏:最隐蔽的敌人
数据泄漏在金融建模里非常普遍。常见来源有三个:随机打乱了时间序列、用未来数据构造了特征、信号没有错位直接乘当日收益。其中随机切分是我见得最多的,很多人按照机器学习教科书的习惯用train_test_split,默认参数会打乱数据顺序,这在时序预测里等于作弊。
处理方案就是:时间序列数据永远用时间顺序切分,需要交叉验证时用TimeSeriesSplit,它专门按时间窗口滚动划分训练集和测试集,不会出现“未来数据训练、过去数据测试”的情况。
5.2 市场状态切换:模型会“过时”
线性模型拟合的是历史规律,而金融市场的规律本身就是不稳定的。某个因子今年显著,明年可能完全失效。我见过不少策略在某一年的回测里走势完美,换到另一年就一败涂地,本质上都是过拟合了特定市场状态。
应对手段是滚动训练:每隔一段时间用最近2到3年的数据重新训练模型,而不是一次训练完永久使用。回测时也建议按年分段评估,逐年看策略的表现,而不是只看一个总曲线。
5.3 不要迷信R²和准确率的绝对值
收益率预测的R²天然很低,方向准确率超过52%就已经说明模型捕捉到了微弱规律。评估模型时要看统计检验的稳定性和多周期的一致性,不要因为某个单一指标不好看就否定整个流程。
如果测试集准确率显著高于训练集,那要先怀疑数据泄漏;如果训练集准确率远高于测试集,那是过拟合特征过多或模型过于复杂,需要增强正则化或减少特征数量。
5.4 忽略交易成本是回测最大谎言
在不计成本的回测里,一个胜率只有51%的策略可能看起来年化收益很高,但加上手续费、滑点、冲击成本后,收益可能直接变成负数。尤其是高换手率策略,交易成本会以复利形式吞噬利润。
我习惯在回测中显式加入成本模型,信号变化一次就扣一次成本。初期探索阶段可以用较低的费率,但最终评估必须用市场真实费率水平。记住:能覆盖成本的策略不一定赚钱,但连成本都覆盖不了的策略一定不赚钱。
个人体验
这个项目我完整跑过不止一遍,最大的体会是线性模型在量化研究里的定位不是“能赚多少钱”,而是“给你一面极度清晰的镜子”。每加一个特征,每换一个参数,都能在系数、t值、回测曲线里看到直接反馈。很多复杂模型的调参像在黑箱里摸索,而线性模型的一切行为都是透明的。
最后分享一个小技巧:做实验时,把每次跑的特征列表、模型参数、数据时间段、回测结果统一存成一份配置记录。喂模型跑上几十次实验之后,你会感谢这个习惯——它能帮你在乱花渐欲迷人眼的参数组合里,快速定位到底哪一组配置真正有效。