1. 先搞清楚逐笔数据到底比快照多了什么
第一次把一整天的逐笔成交和逐笔委托灌进内存的人,大概都有同一个反应:数据量比想象中多出一个数量级,字段比想象中杂乱,真正能用上的字段又比想象中少。一个中等活跃度的标的,一天的逐笔成交通常是几万到几十万条,逐笔委托更是成交笔数的两三倍,碰上行情剧烈波动的日子还要再翻。这些东西单纯存下来没有意义,因子计算的本质是把这些高频事件流压缩成若干低频、可解释、可检验的数值特征,压缩的过程决定了最后算出来的是金子还是噪声。
我先把三层数据的定位摆清楚,因为后面所有因子设计都建立在这个认知上。最粗的是日频行情,开高低收量额,它把一天的所有信息压成了一根柱子,你连当天价格怎么走的都不知道;中间是快照行情,通常三秒一笔,每笔给十档买卖盘口和最新价,它像每隔三秒给市场拍一张照片;最细的是逐笔数据,每一笔成交、每一笔委托挂出或撤销都单独成行,它记录的是两张照片之间发生的所有事情。这三者的关系不是简单的包含,逐笔数据里含有大量快照永远看不见的信息,而快照里有一些聚合状态又需要逐笔事件去还原。
1.1 为什么快照数据算不出真正的订单流
很多人以为快照的十档盘口足够算因子了,实际动手就会发现一个问题:三秒的采样间隔太粗。假设一笔大额主动买单在某个三秒窗口中间打进去,成交完了之后对手方又迅速补了新的挂单,那么前后两张快照的盘口可能看起来几乎一样,你从这个差值里读不出任何东西,可实际上中间发生了一次真实的流动性消耗和补充。订单流因子的核心价值恰恰藏在两次快照之间的“事件序列”里,而不是前后两个静态状态的差。快照给你的是一阶差分的近似,逐笔给你的是完整的积分过程。
更直接的问题是主动买卖方向。快照数据本身不告诉你成交是买方发起还是卖方发起,虽然可以用价格变动去猜——价格上涨的那一笔算主动买,下跌算主动卖——但这在价格没有变化的时候完全失效,而恰恰是横盘时段的成交方向最有信息量,因为它反映了在某个价位上谁在急着成交。逐笔成交数据通常带有买卖方向标志位,或者带有买方委托编号和卖方委托编号,可以精确回溯这笔成交是谁主动打出来的。这个字段的可靠性差异,直接决定了你后面算主动买入占比、净主动买入额这类因子是精确还是糊弄。
1.2 逐笔数据能撑起的因子空间
把逐笔数据能做的事情列一下,大概有这么几类:成交侧的强度类因子,包括主动买卖比例、单笔成交额分布、大单占比、成交笔数与平均单笔规模;委托侧的意图类因子,包括挂单量、撤单量、撤单率、委托价格相对中间价的偏移分布;盘口形状类因子,需要用委托和成交重建订单簿后才能算,比如深度不平衡、盘口斜率、加权价差;时序结构类因子,比如成交间隔的分布、价格跳跃频率、单位时间内的信息到达强度。这四类因子的数据依赖程度不同,后面会逐个拆开讲。
需要提前说清楚一个现实:逐笔数据的因子研究,工作量的大头不在公式,而在数据清洗和订单簿重建。公式本身可能就三五行,但为了让这三五行跑得出正确结果,你得先解决时间戳乱序、集合竞价混入、撤单记录缺失、订单编号回绕这一堆问题。我见过不少团队在这一步上栽跟头,因子回测看着漂亮,实盘一对就崩,最后发现是重建逻辑在特定场景下算错了盘口。
2. 字段梳理与清洗:脏数据处理优先级高于因子设计
2.1 逐笔成交和逐笔委托分别有哪些关键字段
逐笔成交一般包含这些信息:成交序号、成交时间(精确到毫秒甚至微秒)、成交价格、成交数量、买卖方向标志、买方委托编号、卖方委托编号。有些数据源还会给出成交类型,区分连续竞价成交、集合竞价成交、大宗交易之类的场景。这里面最容易被忽略的是成交序号,它是判断数据完整性的第一道关卡,序号应该是严格连续递增的,一旦出现跳号就说明中间丢了记录,后面基于事件序列算出来的所有东西都会偏。
逐笔委托的字段结构和成交不一样:委托编号、委托时间、委托价格、委托数量、买卖方向、委托类型(限价、市价、本方最优等)。这里有个关键点是委托编号的对应关系——同一笔委托的挂出、部分成交、全部成交、撤销,都能通过委托编号串起来形成一条完整的生命周期轨迹。我强烈建议在清洗阶段就把这个映射建好,给每一笔委托打上“最终状态”的标签,是全部成交、部分成交后撤销,还是挂出后原封不动撤掉。这个标签本身就是很好的因子素材,撤单率因子就是从它来的。
各数据源的字段命名和语义会有出入,尤其是委托类型和方向标志的取值,一定要拿几个典型的交易日做人工核对。经验做法是挑一天数据,手工找出开盘前九点十五到九点二十五这段集合竞价期间的记录,看看字段是不是符合预期,然后再找几个明显的大单成交去验证方向标志是否正确。这一步花不了多少时间,但能帮你避开后面几周的方向性错误。
2.2 订单簿重建为什么必须自己做一遍
想算盘口类因子,就必须有订单簿。如果只看快照的十档,你就只能在三秒的粒度上做切片,但很多有效信号是三秒内就衰减掉的,比如一笔大买单瞬间吃穿了三档,两秒后价格又回来了,快照根本捕捉不到这个冲刷过程。自己重建订单簿的好处是可以在任意事件点上取到全深度状态,不受采样频率限制。
重建的基本逻辑不复杂:维护两个有序数据结构,一个买方挂单簿(按价格从高到低),一个卖方挂单簿(按价格从低到高)。收到一条新委托就插入对应队列;收到一条成交就按委托编号找到对应挂单,扣减数量,扣到零就移出;收到撤销记录就把对应挂单移除。听起来顺,但坑非常多,最典型的是部分数据源的撤销事件并不单独成行,而是通过成交记录里某个特殊标志位来表达,或者是通过委托数量与成交数量的差额推算,不同实现方式处理逻辑完全不同。
还有一个细节是价格档位的维护。真实市场里挂单价格是离散的,最小变动单位决定了价格格点,但有些标的在极端情况下会出现跨越多个价位的大额扫单,如果你用字典按价格索引,插入和删除的复杂度没问题,但要保证遍历时的排序正确。用有序容器或者定期排序的列表都行,关键是不要在每笔事件上都做全量排序,那是性能杀手。我通常的做法是用堆加延迟删除的方式,或者直接维护一个价格数组配合游标,因为价格分布通常在中间价附近很密集,两端很稀疏。
2.3 脏数据的几种典型形态和处理策略
| 问题形态 | 表现形式 | 处理策略 |
|---|---|---|
| 时间戳乱序 | 同一毫秒内多条记录顺序不固定 | 以成交序号或委托编号为主排序键,时间戳为辅 |
| 序号跳号 | 成交序号出现中断 | 标记该时段数据不可信,因子值置空或插值 |
| 集合竞价混入 | 开盘/收盘阶段记录格式与连续竞价不同 | 按时间段硬切,连续竞价因子只用中间时段 |
| 撤单记录缺失 | 委托量减少但无对应成交 | 定期与快照盘口对账,用差值反推撤单量 |
| 委托编号回绕 | 编号重复出现 | 检测编号重复点,按出现顺序重新编号 |
这张表里的最后一条容易被忽视。部分数据源的委托编号字段位数有限,在数据量大的标的或者长时间连续交易的情况下会出现编号复用,如果你直接用编号做键,就会出现后一笔委托覆盖前一笔的挂单,盘口状态直接错乱。检测方法是统计编号的重复出现次数,正常情况下每个编号在“挂出—终结”之间只应出现一次,出现两次就说明回绕了。
清洗环节还有一个反直觉的经验:不要追求把数据洗干净到完美,而要追求把不可信的数据标识出来。完全丢弃异常时段会让你损失大量样本,尤其是那些波动剧烈的日子恰恰是因子最能体现区分度的日子。更好的做法是给每条因子值附带一个质量标记,回测时可以选择性剔除。我在实际项目里会在因子表里加一列数据质量分,从0到1,根据丢包率、序号连续性、盘口对账误差综合计算,后面做组合优化的时候把它当作权重用,比粗暴剔除干净得多。
3. 从原始事件流到因子值:六类核心因子的完整拆解
3.1 主动买卖与订单流不平衡因子
这是最容易上手也最容易做错的一类。最朴素的主动买入占比是:把带有买方主动标志的成交量求和,除以总成交量。听上去毫无难度,但这里有三个必须想清楚的问题。
第一个是成交方向标志的可信度。不同数据源对这个字段的定义不完全一致,有的基于成交时买方委托和卖方委托的先后顺序推断,有的基于成交价与当时盘口的相对位置推断,还有的由撮合系统直接给出。前两种都属于事后推断,在某些边界场景下会出错,比如两笔委托同时到达、价格相同的情况。验证方法很简单:随机抽几百笔成交,用当时的订单簿状态去反推方向,看一看标注一致率。一致率低于九成就要考虑自己用盘口位置重新判定方向。
第二个是大额成交对占比因子的扭曲。占比因子天然被成交量大的样本主导,如果某一天出现一笔占全天成交两成的巨单,这个因子基本就只反映那一笔的方向了。解决办法是同时计算按笔数加权的方向和按成交量加权的方向,两者差异大的时候往往意味着大单和散户方向相反,这个差异本身就是一个独立因子,比单纯的占比更有信息量。
第三个是归一化方式。成交量占比在0到1之间,看似无量纲,但实际分布高度偏斜,大部分时间集中在0.5附近,两端极少。直接用原始值做截面比较会出问题,因为不同股票的活跃程度差异巨大。比较稳妥的做法是做截面分位数变换,把每天的因子值映射到0到1的均匀分位,再减去0.5,这样既去掉了量纲,又对极值稳健。这个处理在后面所有因子身上都适用,不只是这一类。
订单流不平衡的另一个版本是基于委托单的净流入,公式是(主动买入委托量减去主动卖出委托量)除以两者之和。注意这里说的是委托而不是成交,因为委托反映了意图,成交只是意图的一部分实现。这两个版本的因子相关性通常不低,但不完全一样,委托版本对短期反转更敏感,成交版本对趋势延续更敏感。我做因子池的时候一般两个都留,让后面的组合优化去决定权重。
3.2 大单识别与资金流强度
大单的定义不能拍脑袋。有人说单笔成交额超过一百万算大单,这在某些标的上是合理的,放在另一个流动性差异很大的标的上就完全不对。合理的做法是用标的自身的历史分布做动态阈值,比如取过去六十个交易日该标的单笔成交额的八十分位作为大单线,超过这条线的成交计入大单统计。这样每个标的的大单标准都是相对的,截面比较才有意义。
大单类因子可以拆成几个维度:大单成交量占比、大单净买入占比、大单平均规模相对于自身历史的变化、大单集中度(前十大单笔成交占大单总量的比例)。最后这个维度我觉得被低估了,它衡量的是资金的一致性,如果一天的大单买入集中在两三笔上,说明是同一批资金在动作,如果分散在几十笔上,说明是多方零星介入,这两种情况后续的价格行为差别很大。
计算上要注意拆单识别。同一个资金方往往会把大单拆成很多笔小单连续打进去,如果只看单笔成交额,这些小单全部会被归为小单,资金流信号就丢了。简单的补救办法是做短时间窗口内的成交聚合,比如把同一方向上相邻两秒内的所有成交合并成一笔“合成成交”再判断大小。窗口长度怎么定是个经验活,太短拆单识别不出来,太长会把不同资金的独立行为混在一起。我试过的窗口从五百毫秒到五秒都有,实测下来两秒左右在多数活跃标的上比较平衡,但这跟标的的成交频率有关,高频标的可以短一些,低频标的要长一些。
资金流强度的另一种表达是净主动买入额除以成交额,这个比值的好处是有界,坏处是对大单特别敏感。为了兼顾稳定性,我会额外算一个去极值版本:先把每笔成交的带符号金额做缩尾处理,超过某个分位数的截断掉,再求和归一化。缩尾的分位数取多少要试,太紧会损失信息,太松起不到稳健作用,我一般在百分之九十九点五附近试几组。
3.3 撤单率与挂单意图识别
撤单数据是逐笔委托里最有价值也最容易被浪费的部分。撤单率高说明挂单的诚意低,很多单子挂出来只是为了影响别人的判断,并不打算成交。但直接算撤单率有个陷阱:撤单量和委托量的统计口径必须一致,如果你的委托量包含了市价委托,而市价委托根本不存在撤销的可能,那分母就被虚增了,撤单率被系统性低估。正确做法是只用限价委托做分母。
撤单类因子我通常算三个:撤单笔数占比、撤单量占比、撤单的加权平均存活时间。第三个是重点,它衡量的是挂单挂了多久才被撤掉。存活时间极短(毫秒级)的撤单往往意味着价格快速移动导致挂单失去了意义,存活时间中等(几秒到几十秒)的撤单更像是刻意的诱骗性挂单,存活时间很长(分钟级)的撤单则可能是资金改变了主意。把这三个区间的撤单分别统计,形成三个独立的因子,比一个笼统的总撤单率信息量大得多。
还有一个细节是撤单方向的不对称性。买方撤单和卖方撤单的意义完全不同。如果买方大量撤单而卖方挂单稳定,说明多头在退却,这个信号比总撤单率强。所以我会再构造一个撤单不对称度:(卖方撤单量减买方撤单量)除以总撤单量,这个因子的方向性比较明确,实测在短周期上有一定预测力。
需要提醒的是撤单类因子对数据质量极其敏感。如果数据源本身有丢包,撤单量会被系统性低估,因为一部分撤单记录直接丢了,而这些丢失的记录不会表现为异常,只会表现为“这笔委托一直挂着”——盘口就会越来越厚,越来越不真实。所以撤单因子的使用必须配合订单簿对账,每天收盘前用重建出来的盘口和真实快照比一遍,误差超过阈值就当天的因子标记为不可信。
3.4 盘口形状类因子:深度、斜率与价差
这类因子必须依赖重建后的订单簿,是逐笔数据最独特的产出。最基础的是加权平均价差,不是简单的最佳买卖价差,而是按各档挂单量加权的价差,能反映整体流动性成本。
深度不平衡的常见算法是:取中间价上下各若干档,把买方总量和卖方总量做差再归一化。归一化方式有两种,一种是除以两者之和,得到负一到一之间的值;另一种是除以历史平均深度,得到相对深度的变化。前者适合截面比较,后者适合时序比较,我一般两个都算。档位数的选择影响很大,取三档太浅容易被单笔大单扰动,取十档以上又会混入太多远端的噪声挂单,五档到十档是实践中最常用的区间,具体取多少要看标的的挂单分布,挂单集中在近档的标的取少一些,挂单分散的取多一些。
盘口斜率是另一个有意思的维度。把各档挂单量对价格距离做回归,得到的斜率反映了流动性的衰减速度。斜率陡说明流动性集中在近档,稍微吃一点价格就会快速移动;斜率平说明远端也有大量挂单,吸收冲击的能力强。这个因子的计算要注意剔除掉空档——有些价位根本没有挂单,价格距离和挂单量的关系就断了,直接回归会有偏。稳妥做法是做插值补齐,或者只用连续有挂单的档位。
盘口形状类因子有一共性缺陷:它们的更新频率由事件驱动,不同标的之间的可比性差。同一时刻,活跃标的的盘口可能每秒变化几十次,冷门标的可能几分钟不动。所以这类因子在做截面比较前,必须做时间窗口内的平均,比如取过去一分钟每分钟末的盘口状态做时间加权平均,而不是取某个瞬时值。瞬时值噪声太大,截面排名每天都在剧烈跳动,因子换手率高得离谱,扣掉交易成本后基本没有剩余。
3.5 成交时序结构与信息到达强度
这类因子关注的是“节奏”而不是“量”。同样是一百万成交额,在一分钟内均匀成交和一秒钟内集中成交,含义完全不同。后者说明有资金急于成交,愿意付出冲击成本,通常是更强的信号。
可算的指标包括:成交间隔的均值与离散度、单位时间成交笔数、相邻成交的价格跳跃幅度、连续同方向成交的最大长度。最后这个是订单流持续性的直接度量,计算方法是扫一遍成交序列,统计连续同方向的成交笔数,取最大的一段或者取所有段的均值。均值比最大值更稳,最大值容易被极端值带偏。
成交间隔的分布值得单独说一说。正常交易状态下,间隔分布近似指数分布,如果出现大量超短间隔(毫秒级连击)夹杂着长间隔,说明成交是阵发式的,通常对应算法单的执行。可以用间隔分布的偏度和峰度作为因子,偏度高说明有集中的爆发段,峰度高说明间隔分布有长尾。这类因子跟波动率有较强的相关性,使用前要做波动率中性化,否则你捕捉到的只是波动本身。
还有一点是时间尺度。逐笔因子的有效半衰期通常很短,几分钟到几十分钟,超短线策略能吃到,但如果你做的是持仓几天的策略,直接把这些高频因子放进去大概率是白搭。常见的处理是用日内多个时间段的因子值做加权,权重按时间衰减,或者用日内均值加日内趋势两个维度去刻画。这一点在第6节讲因子衰减时还会展开。
3.6 因子的标准化、去极值与中性化
所有逐笔因子的原始分布都很糟糕,这一点必须有心理准备。它们要么极度偏斜(成交额相关),要么是离散的计数(笔数相关),要么在0附近堆积(占比相关)。不做处理直接进模型,回归系数会被少数极端样本绑架。
标准流程是四步:去极值、标准化、缺失值处理、中性化。去极值用中位数绝对偏差法比用简单的分位数截断更稳,计算方法是先算中位数,再算各值到中位数的绝对偏差的中位数,把超出若干倍这个偏差的值拉回边界。倍数的选择在三到五之间,我一般取三,保守一点。标准化用截面z分数就够了,但要注意如果某些因子天然是有界分布(比如占比类),做完z分数可能会放大噪声,这类因子更适合直接用分位数变换。
中性化是容易被跳过但影响巨大的一步。逐笔因子和市值、波动率、换手率的相关性普遍不低,比如大单占比因子天然和市值负相关,小市值股票的大单门槛低,占比自然高。如果不做中性化,你的因子实际上是在赌小市值,风格切换的时候就惨了。中性化做法是把因子值对市值的对数、过去二十日波动率、过去二十日换手率做截面回归,取残差作为新的因子值。回归前所有自变量也要做去极值和标准化,否则结果不稳。
注意:中性化用的风格因子必须来自截面同一时点,不能混入未来信息。市值要用当日可得的流通市值或总市值,波动率要用截至前一日的数据,这一点在因子表里一定要标注清楚,不然后面的回测会出大问题。
4. 工程实现:让一整天的因子在可接受的时间内算完
4.1 数据结构选型的取舍
第一版实现我通常用纯 Python 字典加列表把逻辑跑通,因为可读性高,出问题好调试。但单只标的几十万条事件,几百只标的加起来,纯 Python 循环的耗时是按小时计的,回测迭代速度完全没法接受。所以逻辑验证完之后必须换实现。
两个主流方向:向量化和编译加速。向量化适合那些能表达成滚动窗口聚合的因子,比如主动买入占比、成交额的时间加权均值,用 pandas 的 rolling 或者 numpy 的累积和可以做到接近内存带宽的速度。编译加速适合那些天然串行、有状态的逻辑,最典型的就是订单簿重建,因为每一笔事件都要依赖前一状态的盘口。
我实际的组合是这样:订单簿重建用编译型方案写成事件循环,因子聚合用向量化。订单簿重建的产物不是一个完整的历史盘口序列(那太占内存了),而是若干个关键时点的状态快照,比如每秒钟末取一次。有了这个秒级盘口序列,后面的盘口类因子就全部变成常规的表格聚合问题了。这个思路的关键在于状态重建和因子聚合解耦,重建只做一次,不同因子复用同一份中间结果,这样新增因子的边际成本很低。
内存方面要留意。一只标的的逐笔数据结构体如果按每条两百字节算,五十万条就是一千万字节,也就是十兆,几百只标的同时加载会吃掉好几个G。如果内存吃紧,可以按标的逐个处理,处理完一只存一只的因子结果,把中间态释放掉。别为了图省事把所有标的全灌内存,跑到一半爆掉重来更浪费时间。
4.2 订单簿增量维护的关键代码结构
下面这段是重建逻辑的骨架,用编译加速的方式写,核心思想是每个价格档位维护一个总量和该档位的委托队列:
import numpy as np from numba import njit @njit(cache=True) def rebuild_book(order_price, order_qty, order_side, order_id, trade_qty, trade_ord_id, trade_side, snap_times, event_times, max_levels=10): # bid_qty/ask_qty 按价格索引的累计量,price_index 维护价格到数组下标的映射 # 这里用简化写法示意,实际实现需要用有序数组维护档位 n_events = event_times.shape[0] # 输出:每个采样时点的买卖各档累计量 out_bid = np.zeros((snap_times.shape[0], max_levels)) out_ask = np.zeros((snap_times.shape[0], max_levels)) active = {} # order_id -> (price, remaining_qty, side) snap_ptr = 0 for i in range(n_events): # 先处理所有在本次事件之前到达的采样时点 while snap_ptr < snap_times.shape[0] and snap_times[snap_ptr] < event_times[i]: # 按当前 active 状态聚合出前 max_levels 档 snap_ptr += 1 # 挂单事件 if order_side[i] != 0: active[order_id[i]] = (order_price[i], order_qty[i], order_side[i]) # 成交事件,扣减对应委托 else: if trade_ord_id[i] in active: price, remain, side = active[trade_ord_id[i]] remain -= trade_qty[i] if remain <= 0: del active[trade_ord_id[i]] else: active[trade_ord_id[i]] = (price, remain, side) return out_bid, out_ask实际代码比这个长得多,要处理撤单、价格档位的维护、缓存命中、边界判断。但这个骨架说明了核心问题:用一个字典维护活跃委托,用事件序列驱动状态变更,在预设的采样时点导出快照。这个设计的好处是不需要为每个价格维护一个完整数组,内存占用只跟活跃委托数量相关——而活跃委托数量通常远小于全天委托总量,因为绝大多数委托在几秒到几分钟内就终结了。
有个性能上的细节值得提:采样时点导出快照的操作是 O(活跃委托数 × 档位数),如果每秒采样一次而全天有几万笔活跃委托,这个开销会很可观。优化办法是只导出固定档数的累计量,用一个长度固定的价格窗口维护近档总量,超出窗口的委托不参与快照,这样导出成本变成常数。对于大多数盘口类因子,五档到十档完全够用,远端档位的信息密度太低了。
4.3 时间窗口设计:别拍脑袋定参数
逐笔因子几乎都要在一个时间窗口内聚合,窗口长度直接决定因子含义。同样的主动买入占比,在十秒窗口上反映的是瞬时冲击,在一小时窗口上反映的是阶段性资金态度,在一天窗口上反映的是全天情绪。这三个东西不是同一个因子,不能混用。
我的做法是同时算多个尺度,让后续模型自己去选。常见的尺度组合是十秒、一分钟、五分钟、三十分钟、全天,五个尺度覆盖了从微观到日内的完整谱系。代价是因子数量膨胀五倍,对样本外检验的要求也更高。如果算力或样本量有限,至少要算一分钟和全天两个尺度,一个捕捉短期冲击,一个捕捉日内整体状态,这两个的互补性最强。
窗口内聚合的加权方式也有讲究。简单平均会让窗口内所有事件同等重要,但通常越靠近窗口末端的信息越有价值。指数衰减加权能体现这一点,衰减半衰期的设定要看策略频率,做日内高频的可以用几十秒,做日频的可以用几小时。衰减权重要用事件发生的时间来计算,而不是按记录顺序,否则同一毫秒内的密集成交会获得不合理的权重分布。
还有一个容易犯的错是窗口跨界处理。如果你的采样点是每分钟一次,计算每个采样点往前一分钟的因子,那么开盘后的第一个采样点、午休前的最后一个采样点、收盘前的最后一个采样点,它们的窗口都不完整。不完整的窗口因子值不能和完整窗口的值直接比较,必须单独标记或者用可用时长做归一化。这个问题在新股上市首日、临时停牌复牌后格外明显,处理不当会引入一堆异常值。
4.4 一个具体因子的参数计算过程
拿“大单净流入强度”这个因子举例,把参数选择过程完整走一遍。它的定义是:时间窗口内,成交额超过动态阈值的成交中,主动买入额减去主动卖出额,再除以窗口总成交额。
第一步确定动态阈值。取该标的过去六十个交易日的单笔成交额数据,计算每个交易日的八十分位数,再对这六十个分位数取中位数,作为今天的阈值。之所以用过去六十日而不是更长,是为了让阈值跟得上流动性变化;之所以用分位数再取中位数而不是直接用全样本分位数,是因为个别极端日的分布可能完全不同,取中位数更稳健。假设算出来阈值是八万块,那么单笔成交额超过八万的成交才算大单。
第二步确定时间窗口。这个因子是想捕捉日内资金节奏,所以取一分钟窗口,每分钟末往前滚动一分钟统计。一分钟内如果有三十笔大单成交,主动买入二十笔共两百万,主动卖出十笔共八十万,总成交额六百万,因子值就是(200-80)/600,等于0.2。
第三步做截面处理。把当天所有标的、所有时点的因子值收集起来,按每个时点做截面去极值和标准化。如果需要做风格中性化,就在这个时点截面上回归取残差。
第四步检查分布。标准化后的因子理论上均值接近零、标准差接近一。如果实际算出来标准差远大于一,说明去极值没做好;如果均值偏离零太多,说明样本里有系统性偏差,要回去查数据。这一步是最后的质量门,很多数据问题在这一步暴露。
5. 常见问题排查实录
5.1 问题速查表
| 症状 | 可能原因 | 排查动作 |
|---|---|---|
| 盘口深度持续增厚不回落 | 撤单记录丢失或未正确解析 | 与快照盘口逐档对账,看差异是否随时间累积 |
| 主动买入占比长期恒等于0.5 | 方向标志字段失效或全为零 | 抽样检查方向字段取值分布 |
| 因子换手率异常高(日换手超过两三倍) | 未做时间窗口平滑,用了瞬时值 | 加滚动均值,检查因子自相关性 |
| 截面因子值大量重复 | 动态阈值取值不当或分档过粗 | 检查阈值计算是否退化为常数 |
| 因子回测表现远超预期 | 时间戳对齐错误,引入了未来数据 | 检查每个因子的计算终点是否严格早于下单时点 |
| 部分标的因子值全为缺失 | 该标的当日成交笔数过少或数据缺失 | 统计各标的记录数,设置最小样本门槛 |
| 盘中因子值在特定时刻集体跳变 | 采样点跨过了集合竞价或午休边界 | 检查采样时间序列,剔除边界窗口 |
5.2 几个踩过的坑
最坑的一次是时间戳精度问题。原始数据的时间戳精确到毫秒,但同一毫秒内有多条记录,读取时如果直接用时间戳排序,顺序是随机的。这导致订单簿重建时,成交事件可能排在对应的挂单事件之前,于是成交找不到对应的委托,被静默丢弃。表现就是盘口越来越不准,但程序不报错。修复方法是用成交序号做第一排序键,时间戳做第二排序键,保证同一毫秒内的事件顺序正确。
第二个坑是集合竞价和连续竞价的混算。开盘集合竞价的成交是撮合产生的,不存在“主动方”的概念,这些成交的方向标志要么是零要么是随机填的。如果没做时段切分,把开盘那一大笔成交算进主动买入占比里,因子值的分布会在开盘时段出现一个巨大的尖峰,整个截面排名都被这一笔带偏。修复方法很简单,按交易时段硬切,连续竞价因子只用连续竞价时段的数据。
第三个坑是停牌复牌后的盘口重建。停牌期间没有事件,重建逻辑一直维持着停牌前的盘口状态,复牌后第一批事件进来时,和残留状态对不上,导致短时间内盘口严重失真。这个问题在长时间停牌后尤其明显。处理办法是在检测到长时间无事件后重置盘口状态,用于复牌后从第一批委托重新构建,并在复牌后的若干分钟内把因子标记为低置信度。
第四个坑是中性化用了错误的时间点数据。早期我做市值中性化时,直接用了当日的收盘市值,结果因子和市值的关系被“未来信息”污染,回测效果虚高。正确做法是用前一交易日的市值,或者在日内用当日开盘时点可得的市值。这类问题在回测里不一定看得出来,但在实盘会直接体现在成交价与回测价的偏差上。
提示:每次修改数据清洗或重建逻辑后,务必用同一批历史数据重算一遍因子,和上一版做相关性对比。相关性如果低于零点九,说明改动影响很大,必须逐个标的排查,不能想当然认为“新版本肯定更好”。
6. 因子验证:算出来不等于能用
6.1 快速有效性检验的几步流程
因子算完先别急着进模型,单独跑一轮检验。第一步看因子的截面区分度,把每个时点的因子值分成五组或十组,看下一期的收益是否单调。这一点用简单的分组均值就能看出来,不需要复杂的工具。如果分组收益完全无序,这个因子基本可以放弃,或者需要换个窗口长度重试。
第二步看IC序列的稳定性。逐笔因子的IC通常不高,秩相关在零点零几到零点一之间是常见水平,但关键是它的时间序列要稳定,不能一个月正、下个月负、再下个月又正。计算滚动IC的时候,我会同时看IC均值和IC的t统计量,以及IC符号翻转的频率。符号翻转频繁说明因子没有稳定的预测方向,哪怕全样本均值看着还行,实际用起来也会被反复止损。
第三步看因子衰减曲线。把因子值对未来一期、二期、三期直到十期的收益分别算IC,画出一条随期数变化的曲线。逐笔类因子通常衰减很快,可能第二期就掉一半,这决定了策略的调仓频率。如果衰减极快,就必须做高频调仓,交易成本会把收益吃光;如果衰减很慢,反而说明因子捕捉的是相对长期的东西,可能更适合放在低频模型里。
第四步看因子之间的相关性。你算出来的几十个逐笔因子,很可能有一半是高度相关的,因为它们都来自同一份原始数据,只是换了种聚合方式。把相关性矩阵拉出来,把相关系数超过零点七的聚成一类,每类只留一个或者做正交化。这一步能显著减少后续模型的过拟合风险。
6.2 换手率与交易成本的现实约束
逐笔因子的一个共同特点是换手率高。因为它们都是短窗口聚合出来的,随着新事件的到来,因子值更新很快,截面排名也在不停变化。如果一个因子的日换手率达到好几倍,即使毛IC不错,扣掉成本也可能变成负的。
我的做法是在因子层面就做换手控制。具体手段有两个:一是做因子值的滚动平均,用过去若干期的均值替代当期值,相当于主动降低更新频率,代价是信号变慢;二是在因子排序上加约束,比如限制单个标的在组合中的权重变化幅度,或者用换手惩罚项参与优化。第一个手段是因子层面的,第二个是组合层面的,两个都要做。
还有一个容易被忽略的点是因子在不同流动性分组上的表现差异。逐笔因子在高流动性标的上通常更稳定,因为数据质量好、样本量大;在低流动性标的上,几百笔成交算出来的因子噪声极大,方向经常随机。所以使用逐笔因子时,建议按流动性分组分别检验,如果只在流动性好的组有效,那组合构建时就应该把流动性差的标的排除掉,而不是硬塞进去。
6.3 因子失效的早期信号
逐笔因子失效往往不是突然发生的,而是有一个渐进过程。几个可观察的早期信号:IC的方差变大,说明因子在不同时点给出的信号越来越不一致;因子值的分布发生漂移,比如均值系统性偏移、峰度升高,这往往意味着市场结构变了,参与者的行为模式变了;因子与风格因子的相关性上升,说明它越来越像那个风格因子的代理,独立性被侵蚀。
我一般会维护一张因子健康度看板,按周更新,每项指标设一个阈值,触发阈值就做一次深入排查。排查的核心问题是:是数据源变了,还是市场结构变了,还是因子计算方法有问题。这三种情况的应对方式完全不同,前者要修数据管道,中者要考虑因子是否还有效,后者要改计算逻辑。区分方法也简单,把因子拆开看原始中间变量,如果中间变量本身正常,那问题出在因子构造;如果中间变量就不对了,那就是数据或市场的问题。
还有一个经验是不要一次上线太多新因子。逐笔因子数量容易膨胀,几十个一起上,出了问题根本定位不到是哪个环节。我的习惯是每次上两三个,跑一段时间观察,稳定了再加下一批,同时在因子表里保留版本号和计算时间戳,方便回溯。这个流程慢,但稳。
我在实际做这类因子的过程中最大的体会是,公式从来不是难点,数据一致性才是。同一个因子,用不同数据源、不同清洗规则、不同时间对齐方式算出来,结果可能天差地别,而这三者中任何一环出了问题,回测上都不会报错,只会给你一个看起来还不错但实盘完全无法复现的结果。所以真正值得花时间的部分是那套对账机制——每天收盘拿重建的盘口去和真实快照比,拿因子值和原始数据抽样核对,把不可信的部分明确标出来而不是让它悄悄混进去。这套机制建起来之后,加新因子的速度会快很多,因为你不用每次都从头怀疑数据。