1. “躺平”与挖 alpha 的关系:懒人逻辑反而更容易出超额收益
先说个反直觉的结论:我做量化研究这几年,真正让自己收益曲线变好的转折点,不是多盯了几个小时盘,也不是熬夜调参,而是下定决心把日常工作流里那些重复劳动全部砍掉,逼自己“懒下来”。
这里的 alpha,在量化语境里指的是超额收益,也就是说不靠大盘上涨、不靠风格轮动,纯粹靠策略本身赚到的钱。而 Python 圈子里还常有另一层误解——很多人问“python 中 alpha 是什么意思”,其实在数据分析之外,这个词在图像处理里还代表透明度通道,在软件工程里代表内测版本。但在咱们做策略研究的人眼里,alpha 只有一个意思:能持续产生正期望的因子信号。
顺带说一句,做量化研究如果不把“研究流程本身”当成一个系统来优化,那时间黑洞是非常惊人的。我最早自己做因子研究时,流程大概是这样的:打开行情库导数据,写一段临时脚本算指标,跑完存个 CSV,再写一段回测脚本验证,看一眼结果,不满意,改参数,重跑。听起来还行?实际上每个环节都要 10 到 20 分钟,叠加起来一天能完整跑通三四个 idea 就算不错了。更可怕的是,隔两周想复现一个当时看起来还行的结果,翻文件夹找脚本能找半天,找到之后发现当时的参数写死在代码里,根本想不起来是哪个版本。
这个系列前两篇聊的是通用工作流和时间管理,这篇落到具体场景:一个以挖因子为生的研究日常,到底应该怎么设计流程,才能像“躺平”一样省力,又保持持续产出。
先说结论:挖 alpha 这件事,核心瓶颈根本不是灵感,而是“验证一个想法”的速度。你有一个 idea,从有了想法到初步检验结果出来,这个循环越短,单位时间内试错次数越多,遇到的“意外有效信号”也越多。alpha 的数学本质就是信息优势,而信息优势往往藏在那些别人没空去验证的角落。别人一天验证 3 个想法,你一天验证 15 个,哪怕命中率一样,长期积累下来的有效因子数量就是五倍差距。
所以我理解“躺平”不是真的躺着,而是把精力从“重复操作”里释放出来,留给真正需要判断力的环节。想明白这件事之后,我开始系统性地重构整条研究工作流,下面这篇就把核心思路和踩过的坑都摊开来讲。
2. 数据管道:让数据自己“跑到”你面前,而不是你去追数据
如果说因子研究是一座工厂,那数据就是原材料。过去我有一半以上的时间花在“搞数据”上:下载、清洗、对齐、补缺失,这些活儿又琐碎又不能出错。直到我把数据管道重构成全自动的,才真正感觉到什么叫“躺着等饭”。
2.1 增量更新:只处理变化的部分,别天天全量重来
最早我做数据更新非常笨:每天晚上把全市场日线数据从头拉一遍。数据量小的时候还能忍受,但标的多了之后,一次全量更新能跑差不多 20 分钟,更不用说中途断网、交易所调整代码这类幺蛾子。
后来我改成增量更新,核心思路是:只拉每个标的最后一根交易日之后的数据。具体实现并不复杂,用一个本地表记录每个标的最近更新时间,然后按时间差请求增量数据。配合定时触发(我用的系统自带计划任务,也可以用调度框架),每天收盘后自动执行,跑完发一个结果通知给我。
这里有个容易被忽视的细节:增量更新不能只关注“新数据”,还要处理“错误修正”。比如某些行情源偶尔回传修正过的历史价格,如果只管增量,这种修正就永远漏掉了。我的处理方式是在增量更新的同时,对最近 5 个交易日的数据做一次全量校验,发现对数收益率异常跳变的标的,自动触发重拉。这个机制帮我挡住过好几次隐藏的数据事故。
2.2 数据清洗的统一标准:复权、对齐与交易日历
弄过 A 股数据的同学都懂,前复权和后复权是个大坑,分红除权日处理不好,因子值会出现虚假的跳变。我现在的原则是:日常因子计算统一用后复权价格,这样历史收益率的计算是连续的。
另一个比复权更隐蔽的坑是“交易日历对齐”。不同资产(股票、期货、ETF)的交易日不完全重合,如果不做交易日历对齐,因子数据里就会出现空洞。我维护了一张全局交易日历表,所有序列都以它为基准做重采样,缺失的填 NaN 而不是填 0。填 0 是最容易引入前视偏差的做法之一,NaN 至少能直观地告诉你这里缺数据。
数据管道的最后一步是完整性校验,我写了一个固定的检查脚本,每天跑完自动检查三样东西:序列长度是否达到预期、最新日期是否为最近交易日、关键字段是否存在极端缺失率。这三项过了,数据才进入因子计算的输入池。
2.3 一个可复用的数据管道骨架
下面是我实际在用的管道骨架,去掉了业务细节,保留核心结构,方便你照着搭:
# daily_pipeline.py # 伪代码,具体行情源和数据库连接请按自己环境替换 import pandas as pd from datetime import datetime, timedelta TRADING_CAL = load_trading_calendar() # 全局交易日历 def incremental_update_all_symbols(symbols): """增量更新,带最近N日全量校验""" for sym in symbols: last_date = get_local_last_date(sym) target_dates = TRADING_CAL[TRADING_CAL > last_date] if len(target_dates) == 0: continue new_data = fetch_kline(sym, start=last_date, end=datetime.now().strftime("%Y%m%d")) upsert_local(sym, new_data) # 最近5日校验 check_recent_5days(sym) def check_recent_5days(sym): recent = load_local(sym, tail=5) rets = recent["close"].pct_change() if (abs(rets) > 0.21).any(): # 涨停/跌停也可能触发,需要进一步判断 refetch_full_recent(sym, days=30) def main(): symbols = get_symbol_list() incremental_update_all_symbols(symbols) run_data_quality_report()这段代码看着简单,但每个函数背后都有一段血泪史。比如fetch_kline要注意限频控制,否则请求太密集会被封;upsert_local要有幂等性设计,重复执行不会插重复数据;check_recent_5days里的阈值不能拍脑袋,要结合涨跌停规则调整,否则天天误报。
数据管道跑顺之后,我最大的感受是:研究工作的“启动成本”变成了零。以前下午想做点什么,得先花半小时把数据准备妥当,现在打开电脑数据已经整整齐齐躺在库里,测试新想法就像在查字典一样方便。
3. 因子库的工程化改造:从散落脚本到统一框架
数据问题解决之后,下一个拖慢节奏的是因子代码的管理方式。早期我把每个因子写成一个独立脚本,几百个脚本散在文件夹里,命名从factor_v1_final到factor_v2_真的最终版,懂的都懂。
这样的直接后果就是:组合回测时想同时用到多个因子,得手动把脚本拼在一起,变量名冲突、时间区间不一致、参数覆盖,各种莫名其妙的问题。与其说是研究,不如说是在给历史代码填坑。
3.1 统一的因子接口与注册机制
我重新设计了一套简单的因子框架,核心只有三件事:统一的数据输入格式、统一的因子输出格式、自动注册机制。
所有因子都继承同一个基类,输入是一致的(结构化行情数据),输出是一列与交易日对齐的序列。用装饰器做注册:
# factor_base.py import pandas as pd from functools import wraps FACTOR_REGISTRY = {} def register_factor(name): def decorator(cls): FACTOR_REGISTRY[name] = cls return cls return decorator class BaseFactor: def __init__(self, params=None): self.params = params or {} def compute(self, market_data: pd.DataFrame) -> pd.Series: """输入统一日频行情,输出因子值序列,索引为交易日""" raise NotImplementedError @register_factor("momentum_20d") class Momentum20d(BaseFactor): def compute(self, market_data): close = market_data["close"] return close.shift(1) / close.shift(21) - 1 # 用shift避免用到当日信息设计这个统一接口的额外好处是:所有因子天然支持同一套缓存、回测、相关性和检验工具。新因子写完之后,直接扔进统一框架,跑一次全套检验流程只要一行命令。
3.2 参数管理与实验可复现
做因子研究最容易出问题的是参数管理。我以前常常遇到这种情况:某个因子跑出不错的结果,但过几天想复现,怎么都对不上,最后发现当时参数是在命令行里临时改的,没存进代码。
现在我把参数管理纳入了框架设计。每个因子的参数都用一个只读字典保存,连同因子名、数据版本、计算时间一起打包存入结果表。跑出任何一组结果,都能追溯到完整的参数环境和数据版本。
说句实话,可复现性就是研究效率的一部分。不可复现的实验结果,等于这个实验从来没做过,所有时间都是浪费。
3.3 因子缓存:同样的活儿别干两遍
因子计算是无状态的,同样的输入和参数输出完全一致的结果。这个特性非常适合缓存。我用了一套简单的策略:以“因子名 + 参数哈希 + 数据截至日期”为 key,计算结果落盘缓存。下次再有人请求同一个因子,直接从缓存加载,计算时间从秒级降到毫秒级。
缓存的价值不只是省时间,更重要的是它让你敢于试错。以前想到一个变体因子,但重算全部历史数据要等很久,思路一打断就不想做了;现在因为缓存的存在,计算新变体往往只需要几十秒钟,试错成本低到可以忽略。
有了这套框架之后,我新添一个因子的完整流程是:写 compute 方法、加装饰器注册、跑一行命令生成完整检验报告。全过程通常不超过 15 分钟。这和以前“每次都要从头调试脚本”的体验,完全不在一个量级。
4. 回测提速与结果校验:低垂果实先摘干净
框架搭好之后,又出现一个新问题:计算本身变快了,回测反而成了瓶颈。特别是多因子组合需要滚动遍历历史的场景,纯 Python 跑一遍要几分钟甚至更久。优化路上我做了很多试验,最有用的三个手段如下。
4.1 先分析再优化:别凭感觉改代码
拿到一段慢代码,我第一反应是打开性能分析工具,看看时间到底耗在哪个函数上。有一次我觉得是因子计算慢,分析之后发现 80% 的时间花在 dataframe 的索引对齐上,换成 numpy 数组之后直接提速二十倍。还有一次是数据库查询没用索引,全表扫描拖慢整体,加了个索引又快了十几倍。
如果只是凭直觉去优化“看起来慢的部分”,很可能白费功夫。性能分析工具输出的热点列表,才是真正该动手的地方。
4.2 多进程并行:让每个核都干活
回测任务天然适合并行——同一个时间段内不同股票池的回测互不依赖。我用进程池处理这部分,简单粗暴但有效。要注意的是,并行任务之间不能共享可变状态,否则很容易踩到 Python 的全局解释器锁问题。
实际跑下来,在一台 8 核机器上,回测速度大概能提升 5 到 7 倍。至于为什么不是 8 倍,因为有数据加载和进程间通信的开销。这个损失可以接受,毕竟投入回报比非常高。
4.3 防前视偏差的校验清单
提速之后,更重要的其实是验证结果的可靠性。我给自己列了一个校验清单,每次回测结果跑出来必须过这几关:
| 校验项 | 常见问题 | 检查方法 |
|---|---|---|
| 未来函数 | T 日因子用到了 T 日收盘后才知的数据 | 因子要求至少 shift(1) |
| 幸存者偏差 | 只用了当前还在市标的,退市标的缺失 | 用全量历史标的池 |
| 涨跌停无法交易 | 因子信号出现在一字板行情,实际买不进 | 检查信号日是否大面积封板 |
| 参数过拟合 | 在样本内反复调参直到好看 | 划分样本外区间强制验证 |
| 交易成本缺失 | 把毛利当净利,过度乐观 | 加入双边手续费和滑点模型 |
这里面最反直觉的是“涨跌停无法交易”。很多人回测时完全忽略这个因子,导致某些策略在回测里看着年化很高,实际根本交易不了。我之前做过一个基于极端收益反转的因子,回测表现亮眼,结果发现信号大多出现在连续涨停后,开板瞬间根本没法按回测价格成交。加了涨跌停过滤之后,超额收益直接砍掉一大半。
回测指标的解读也要小心。IC(信息系数)和 IR(信息比率)是常用指标,但只看平均值不够,还得看稳定性,比如 IC 的月度胜率和滚动 IC 的震荡幅度。一个 IC 高但波动剧烈的因子,实盘中的体验往往很差,净值曲线大起大落,很容易在低点被洗出去。
5. “伪 alpha”识别:看起来有效为什么上了实盘就翻车
先明确一件事:回测里出现的高收益,并不等于真 alpha。做因子挖掘这条路上,我见过太多看起来精美的“伪 alpha”,它们就像海市蜃楼,近看诱人,伸手一碰就碎。
5.1 数据穿越是最难察觉的敌人
有一种“伪 alpha”来自数据穿越。比如做行业轮动策略时,用了某个行业指数“当日最终值”来计算轮动信号,但收盘前你是拿不到最终值的,这就是未来信息。在日频数据上,这种穿越往往只差一个 shift,但就是这一个 shift 的差距,回测和实盘就是天壤之别。
我处理数据穿越的办法很笨但很有用:人为制造一个“断点日期”。在断点之前的数据上计算因子,用断点之后的数据做验证,如果因子的表现突然崩掉,那大概率有问题。这相当于对每一个因子做一次时间维度上的压力测试。
5.2 相关性与拥挤度:因子的“隐性市值”
因子的相关性是另一个大坑。很多新手看到两个不同名字的因子都有效,就以为发现了两块独立的 alpha,实际上它们可能高度相关,贡献的其实是同一份收益。我定期对因子库做相关性聚类,同簇里保留解释力最强的那个,其余降权或剔除。
拥挤度这个概念也用得上。如果一个因子在历史回测里表现非常好,但最近一两年开始失效,很可能不是策略的问题,而是这个因子已经被人发现并且资金涌入,alpha 被套利掉了。超额收益的生命周期是客观存在的,没有哪个因子能永远有效,所以我的因子库会定期做“衰减体检”,对 IC 持续走弱的因子自动降级观察。
5.3 样本内外差异的理性阈值
我给自己定了一个相对保守的判定标准:样本内 IC 均值至少要达到 0.03 以上才考虑入库;样本外的衰减不能超过 50%。不能用这个标准去卡所有策略,毕竟不同频率、不同类型的因子表现基准不同,但有了底线标准,至少能拦住很多自欺欺人的“伪 alpha”。
另外提醒一句:单独看 IC 均值之外,一定要看因子的多空分组收益单调性。一个因子的 IC 可能不错,但它主要靠多头端赚钱,空头端一塌糊涂,这种因子实盘价值有限,因为做空端要么成本高,要么根本融不到券。单调性检验能帮你判断因子是不是“阴阳脸”。
6. 实盘前最后一道闸门:样本外模拟与容量估算
通过了历史回测和历史压力测试,不代表可以直接上实盘。我还有一个强制执行的步骤:开一个模拟账户,按实盘规则跑至少二十个交易日,然后把模拟结果和回测结果对比。
6.1 模拟盘的意义不是看收益,而是看流程
模拟盘跑出来的收益率肯定和回测有差异,这个不重要。真正要看的是:每日信号是否稳定、交易执行是否顺畅、有没有遇到数据延迟或涨跌停买不进的情况。这些在回测里不暴露、不真正跑模拟盘根本发现不了的问题,才是模拟盘最大的价值。
我记得有一次跑模拟盘,策略在历史回测中表现稳定,但实盘模拟中频繁出现“信号时间晚于下单时间”的问题。查了一圈才发现,数据源在收盘后补充修正过行情,导致盘中算出的因子值和盘后完全不一样。这种问题不模拟根本发现不了。
6.2 容量估算:你的策略能吃多少钱
容量估算很多人忽略,但对资金体量稍微大一点的账户来说非常重要。一个因子的容量取决于它交易的标的流动性和持仓周期。测算方法并不复杂:用历史成交额的中位数,乘以可参与比例,再除以策略的日均换手率,就能大概估算出策略能承载的资金上限。
之前做了一个小市值因子,回测出超额非常高,但真正去算容量时发现,全市场能容纳的资金非常有限,超过一定规模之后冲击成本会完全吃掉超额收益。这个因子最后被我降级成观察池,只在资金很小时使用。
6.3 从模拟到实盘的灰度切换
就算模拟盘跑得不错,我也不建议直接满仓上实盘。更稳妥的做法是:先拿十分之一的目标资金跑两周,观察实际成交滑点和信号延迟是否符合预期。确认没问题,再逐步提高资金比例。
这本质上是一种风险控制,但很多量化新手最容易忽略。他们往往回测一漂亮就恨不得立刻满仓干进去,结果遇到实盘和回测的差异时回撤一大截。灰度切换的额外好处是,在真正投入大资金之前,你有机会发现流程里的隐藏问题,而且代价足够小。
我自己的体会是,“挖 alpha”这件事,七分靠流程,三分靠灵感。流程顺了,灵感才能落地;流程混乱,再好的 idea 也会被耗死在“写脚本、对数据、除 bug”上。躺平的意义,就是把有限的精力留给那三分真正需要创造力的部分。
最后分享一个收藏已久的小技巧:每次新增或修改因子之后,我会在 git 提交信息里写清楚“为什么做这个改动”,而不是只写“更新因子”。三个月后再回头看,这些 commit 信息就是你最宝贵的研究日志,能帮你复盘哪些思路靠谱、哪些路是白走的。挖 alpha 是一场持久战,日志记好了,你的经验就不会白费。