逐笔成交和逐笔委托这两类数据,几乎是每个做日内信号的人绕不开的原料。但现实里常见的场景是:有人拉了一整年的逐笔成交,吭哧吭哧算出几十个因子,回测夏普看着还行,一到实盘就哑火;也有人手里同时有成交和委托两套表,却只拿成交量做了个VWAP偏离,委托表压根没打开过。问题往往不在因子本身,而在于对这两类数据的语义理解停留在“一列价格一列量”的层面。逐笔成交告诉你的是“已经发生了什么”,逐笔委托告诉你的是“有人打算让什么发生”,这两件事的信息含量、噪声结构、可用时间点完全不同,混在一起算因子,出来的东西大概率是自欺欺人。
这篇文章想聊的就是把这两类数据变成可用因子的完整链路:字段语义怎么读、脏数据怎么清、订单流类因子的公式怎么在逐笔粒度上落地、委托流里那些不太显眼但信息量很高的行为特征怎么提、以及工程上怎么组织计算才不至于跑一晚上。适合已经能拿到逐笔数据、但因子还没做出差异化的朋友,也适合刚接触高频数据、想搞清楚“委托表到底能干嘛”的新手。下面全部基于公开市场数据和常见开源工具链的实践,不涉及任何具体标的和机构的内部口径。
1. 先把字段语义吃透:三类逐笔记录的边界在哪
1.1 成交、委托、快照是三种不同分辨率的东西
很多人把逐笔数据当成一张表,实际上它至少是三类记录。逐笔成交是撮合结果的流水,每条记录代表一次成交,字段通常包含时间戳、成交价、成交量、成交编号,部分市场还会带上买方订单号和卖方订单号。逐笔委托是订单进入撮合系统的流水,字段通常包含时间戳、订单号、方向、价格、数量、订单类型。快照则是每隔固定间隔(常见三秒)对订单簿做一次切片,给出十档价量。
这三者的关键差别在于:成交是“稀疏事件”,只在有撮合时才有记录,冷门标的可能几十秒没有一条;委托是“更密集的事件”,但依然稀疏;快照是“等间隔采样”,无论市场发生什么都会产生一条。用快照算出来的因子,天然带着采样误差——三秒内的价格剧烈波动会被压成一个点;用逐笔成交算出来的因子,则带着事件密度不均的问题——活跃时段一秒几百条,清淡时段一分钟三条。
我做过的对比里,用逐笔成交重算的分钟级波动率,和用三秒快照重算的同一指标,在活跃标的上差异能到 15% 以上。这个差异不是bug,是分辨率差异。如果你的标签是分钟级收益,逐笔粒度算的因子能捕捉到快照漏掉的路径信息;但如果你的因子窗口只有几秒,快照就完全不够用。
一个常见误区:以为快照的买卖十档就是“订单簿”。它只是订单簿在某个瞬间的截断投影,档位之外的挂单、队列内部的排序、隐藏单,全都不在里面。
1.2 时间戳精度和序列号,是因子能不能对齐的第一道坎
逐笔数据的时间戳精度在不同数据源之间差异极大。有的给到毫秒,有的给到微秒,还有的只给到秒级但附带一个单调递增的序列号。序列号往往比时间戳更可靠,因为时间戳可能来自不同机器的本地时钟,存在毫秒级的漂移甚至回退,而序列号是撮合系统内部严格递增的。
我在处理某个市场的数据时就遇到过时间戳乱序:同一秒内出现了 A 记录时间戳 10:00:00.123、B 记录 10:00:00.121,但按序列号 A 在前。这种情况如果不按序列号重排,后面构建订单簿时会出现“先撤后挂”的荒谬逻辑,直接导致委托生命周期表里出现负的存活时间。
处理方式很直接:排序键固定为 (标的代码, 交易日, 序列号),时间戳只作为辅助字段用于跨市场对齐,绝不作为唯一排序依据。如果数据源没给序列号,那就退而求其次用 (时间戳, 成交编号) 组合排序,成交编号一般也是单调的。
另外要注意不同表之间的时间语义。逐笔委托的时间戳是“订单进入系统的时刻”,逐笔成交的时间戳是“撮合完成的时刻”。同一笔主动单,从进系统到成交可能有几十微秒到几毫秒的延迟。如果你的因子要计算“委托到成交的响应速度”,这个差值本身就是个信号;如果不需要,那就得明确用哪张表的时间戳做基准,别混着用。
1.3 成交方向推断:带订单号和不带订单号是两套做法
主动买卖方向是订单流因子的地基,但这个字段在很多数据源里是缺失的,必须自己推。
情况一:逐笔成交带买卖双方订单号。这是最好的情况。做法是把成交记录和委托记录按订单号 join,比较买方订单和卖方订单的进入时间,后进入的那一方就是主动方。这个逻辑背后的道理很简单:限价单挂在簿上等着,只有当后来者愿意以对手价成交时才会触发撮合,所以后到的订单必然是价格接受者。
情况二:不带订单号,只有成交价和量。这时候只能退回到 tick rule 或者 Lee-Ready 类的启发式。tick rule 的逻辑是:成交价高于上一笔成交价记为主动买,低于记为主动卖,相等则沿用上一次的方向。这个规则在流动性好的标的上准确率不错,但在价格频繁在同一档位来回成交时会产生大量误判——因为同一价位上的成交,可能是买方主动打上去的,也可能是卖方主动砸下来的,tick rule 完全区分不了。
我实测过的对比是:在有订单号的市场,用 join 法得到的主动买卖量和 tick rule 的结果,在日内累计层面相关性大约 0.85,但在单笔层面一致率只有 70% 左右。对分钟级以上的因子影响不大,对秒级以下的因子就是灾难。
如果你的数据不带订单号,又想做秒级以下的方向类因子,劝你放弃,或者换个数据源。这不是算法能弥补的信息缺失。
下面是一段用订单号 join 推断方向的示意代码,用 polars 写,因为逐笔数据动辄上亿行,pandas 在这个量级上会很吃力:
import polars as pl # trades: trade_id, ts, price, qty, buy_order_id, sell_order_id # orders: order_id, ts, side, price, qty trades = pl.read_parquet("trades.parquet") orders = pl.read_parquet("orders.parquet").select( pl.col("order_id"), pl.col("ts").alias("order_ts") ) buy_ts = orders.select( pl.col("order_id").alias("buy_order_id"), pl.col("order_ts").alias("buy_ts") ) sell_ts = orders.select( pl.col("order_id").alias("sell_order_id"), pl.col("order_ts").alias("sell_ts") ) trades = ( trades .join(buy_ts, on="buy_order_id", how="left") .join(sell_ts, on="sell_order_id", how="left") .with_columns( pl.when(pl.col("buy_ts") > pl.col("sell_ts")) .then(pl.lit(1)) # 买方后到,主动买 .otherwise(pl.lit(-1)) # 卖方后到,主动卖 .alias("aggr_side") ) )join 完之后建议抽查一批边界样本:买卖双方时间戳完全相同的记录有多少?占比高说明数据源的时间精度不足,方向推断不可信,这批记录应该单独标记而不是硬判。
2. 清洗环节:五个必须处理但常被跳过的细节
2.1 集合竞价时段的成交,方向毫无意义
开盘和收盘的集中撮合阶段,成交是批量匹配的结果,不存在“主动方”这个概念。这段数据如果混进连续竞价的因子窗口里,会把方向类因子污染得很厉害——尤其是在开盘前几分钟,集合竞价的巨量成交会让订单流不平衡因子瞬间打到极端值。
处理方式有两种:直接剔除,或者单独打标后排除在方向类因子之外。我一般选后者,因为集合竞价的成交量本身是个有用的因子(反映隔夜信息消化程度),只是不能用来算方向。具体做法是给每条记录加一个session字段,取值区分连续竞价、开盘集合竞价、收盘集合竞价,因子计算时按 session 分组过滤。
还有一个更隐蔽的坑:部分市场在集合竞价结束后会补发一批成交记录,时间戳落在连续竞价开始之后。如果不按 session 字段过滤,只看时间戳,这批记录会被当成连续竞价的成交,方向全部推断错误。
2.2 撤单记录的还原:委托生命周期表怎么建
逐笔委托表里,撤单通常有两种表达方式:一是数量字段为负的记录,二是专门的订单类型标记。无论哪种,都需要把它和原始委托关联起来,才能还原出每笔订单的完整生命周期:提交时间 → 是否有部分成交 → 撤单时间 → 最终剩余量。
这张生命周期表是后面所有委托类因子的基础。构建逻辑用一句话概括:以订单号为键,把委托记录和成交记录合并,按时间排序,逐步累加已成交量,遇到撤单记录就封口。
# 简化版:只保留限价单,撤单以负数量表示 life = ( pl.concat([ orders.filter(pl.col("qty") > 0).select( "order_id", "ts", "side", "price", pl.col("qty").alias("submit_qty"), pl.lit(0).alias("filled_qty"), pl.lit(None).alias("cancel_ts"), ), orders.filter(pl.col("qty") < 0).select( pl.col("order_id"), "ts", pl.lit(None).alias("side"), pl.lit(None).alias("price"), pl.lit(0).alias("submit_qty"), pl.lit(0).alias("filled_qty"), pl.col("ts").alias("cancel_ts"), ), ]) .group_by("order_id") .agg( pl.col("ts").min().alias("submit_ts"), pl.col("cancel_ts").drop_nulls().min().alias("cancel_ts"), pl.col("submit_qty").max().alias("submit_qty"), ) )实际生产中还要把成交表 join 进来累加filled_qty,这里省略了。做完之后你就有了一张带submit_ts、cancel_ts、submit_qty、filled_qty的表,可以派生出存活时间、撤单率、部分成交比例等一堆特征。
注意:有些市场的撤单记录里不带原始订单的价格和方向,只有订单号。这时候必须靠 join 回委托表补全,join 不上的记录(比如当日之前的挂单)要单独标记,别直接丢掉,这些往往是隔夜单,行为特征和日内单不一样。
2.3 数值单位和复权,跨标的比较前的必做动作
逐笔数据的单位混乱程度超出想象:成交量有的按股,有的按手(100股),有的按千股;价格有的用整数最小变动价位,有的用浮点元。这些差异在单标的内部算因子时看不出来,一旦做横截面标准化就会爆炸。
我一般会在数据加载层做统一:成交量一律转成股,价格一律转成元并保留原始最小变动价位字段。最小变动价位这个字段别丢,后面算“价格偏移了几个 tick”的时候要用,用元做单位会引入浮点误差。
复权的问题更微妙。逐笔数据是当日原始价格,除权除息日会有跳空。如果你的因子窗口跨越了除权日(比如用过去 20 日的逐笔数据算某个统计量),不处理复权会导致价格类因子出现虚假的极值。处理方式是在日级别维护一个复权因子表,加载逐笔数据时按交易日乘上对应因子。但要注意:日内因子通常不需要复权,因为窗口不跨日;跨日因子才需要。这个判断做错了,反而会引入人为的价格扭曲。
2.4 重复记录与乱序,靠序列号去重最稳
数据供应商的推送机制偶尔会导致重复记录,判断依据是 (标的, 序列号) 组合唯一。去重的时候保留第一条即可,不要用 drop_duplicates 对整个 DataFrame 做,那样在大数据量下会慢得离谱,用 polars 的unique(subset=[...], keep="first")会快很多。
乱序的处理前面提过了,排序键固定为 (标的, 交易日, 序列号)。这里补一句:排序要在分区内做,不要全局排序。逐笔数据按日期和标的分区存储,每个分区内排序,比全表排序快一到两个数量级,内存占用也小得多。
2.5 停牌与异常时段的识别
盘中临时停牌期间,委托可能继续进入但不产生成交。这段数据的特征是:有委托记录,没有成交记录,且委托簿的买卖价差会异常扩大。如果不识别出来,撤单率、委托到达强度这些因子会在停牌前后出现剧烈跳变。
识别方法不复杂:计算滚动窗口内的成交笔数,连续 N 个窗口为 0 且同期有委托记录,标记为疑似停牌。N 的取值看标的流动性,我用的是 60 秒无成交即标记。标记之后,因子计算时把这段窗口整体置为 NaN,让后续的缺失值处理逻辑统一兜底,而不是让它带着错误的数值进入模型。
3. 订单流类因子:从逐笔事件到可计算的数值
3.1 订单流不平衡的两种写法,别混着用
订单流不平衡(Order Flow Imbalance,OFI)是这类数据最经典的产物。它的核心思想是:订单簿最优档的变化,可以拆解成“买方推力”和“卖方推力”的净差。
标准写法(基于快照的最优档变化)是这样的:设 $P^b_n$、$q^b_n$ 为第 n 个时刻的最优买价和买量,$P^a_n$、$q^a_n$ 为最优卖价和卖量,那么
$$e_n = I(P^b_n \ge P^b_{n-1})\cdot q^b_n - I(P^b_n \le P^b_{n-1})\cdot q^b_{n-1} - I(P^a_n \le P^a_{n-1})\cdot q^a_n + I(P^a_n \ge P^a_{n-1})\cdot q^a_{n-1}$$
这个式子的直觉是:买价上移或持平,说明买方的挂单是新增的(正向贡献);买价下移,说明原来的买一被吃掉了或者撤了(负向贡献);卖侧反过来。四项加起来就是这一时刻的净推力。
但这是快照粒度的写法。在逐笔粒度上,我更常用的是另一个版本:直接对每笔成交按主动方向赋符号,然后在滚动窗口内累加。
$$OFI_t = \frac{\sum_{i \in (t-W, t]} s_i \cdot v_i}{\sum_{i \in (t-W, t]} v_i}$$
其中 $s_i$ 是主动方向(+1 或 -1),$v_i$ 是成交量。这个版本计算简单、对数据质量要求低,代价是丢失了挂单侧的信息——它只看“谁打了谁”,不看“谁在挂”。
两种写法的信息含量不一样:快照版对挂单变化敏感,适合捕捉流动性提供的动向;逐笔版对主动成交敏感,适合捕捉短期价格压力。我的经验是,在分钟级预测上逐笔版更稳,在秒级预测上快照版(如果能拿到毫秒级快照)信息更丰富。千万不要把两个版本算出来的值放在同一个模型里当独立特征,它们的相关性很高,容易造成多重共线性。
3.2 窗口选择:事件窗口还是时间窗口
滚动窗口有两种切法。时间窗口是按固定时长切(比如过去 30 秒),事件窗口是按固定事件数切(比如过去 500 笔成交)。这两个在活跃度差异大的标的之间表现完全不同。
时间窗口的问题在于,清淡时段窗口内可能只有几笔成交,统计量极不稳定;事件窗口的问题在于,活跃时段窗口对应的真实时间可能只有几百毫秒,语义上不好解释。
我的做法是主用时间窗口,但加一个最小事件数约束:窗口内成交笔数少于阈值(比如 20 笔)时,因子置 NaN。这样既保证了时间语义的一致性,又避免了极小样本的噪声。阈值不要设太高,太高会让大量清淡时段的数据被浪费,样本利用率下降。
还有一个细节:窗口边界的包含关系要统一。用左开右闭 $(t-W, t]$ 还是左闭右开 $[t-W, t)$,在窗口很短的时候会导致因子值差异明显。我统一用左开右闭,理由是 t 时刻只能使用 t 之前已经完成的信息,右端点包含当前时刻的成交在实盘上是拿不到的,容易造成前视偏差。这个坑后面还会专门讲。
3.3 大单识别的阈值,静态和动态要分开
“大单”这个概念在因子计算里没有统一定义,但阈值定得合不合理,直接决定因子有没有信息。常见的静态阈值是“单笔成交量超过过去 N 日均值的 K 倍”,问题在于不同标的的成交分布差异极大,同一个 K 值在蓝筹股上可能筛出 30% 的成交,在小盘股上一笔都筛不出来。
更稳的做法是分位数阈值 + 动态更新:对每个标的、每个交易日,用前一日或过去若干日的逐笔成交量分布,取 90% 或 95% 分位数作为大单阈值。这样阈值随标的流动性自适应,跨标的可比性也好很多。
但分位数阈值有个副作用:它总是会筛出固定比例的“大单”,哪怕当天根本没有真正的大额成交。所以我在实际使用时会再加一层绝对约束:分位数阈值和“过去 20 日成交量中位数的 3 倍”取较大值。这样既能自适应,又不会在极端清淡的日子里把几百股当成大单。
算出来之后,大单相关因子一般包括:大单主动买占比、大单净流入、大单成交量的时间聚集度(比如大单是否集中在某个时段)。最后一个特征比较有意思,它反映的是“有没有人在持续吸筹”,比单纯的净流入更能区分不同的资金行为。
3.4 撤单率:分母的选择决定了因子的方向
撤单率是最容易被算错的因子,因为“撤单量”和“委托量”这两个分母的口径有好几种。
| 口径 | 分子 | 分母 | 适用场景 |
|---|---|---|---|
| 笔数撤单率 | 撤单笔数 | 委托笔数 | 反映订单的犹豫程度 |
| 数量撤单率 | 撤单量 | 委托总量 | 反映流动性的撤出强度 |
| 金额加权撤单率 | 撤单金额 | 委托总金额 | 跨标的可比性最好 |
| 存活撤单率 | 存活时间内撤单量 | 同存活时间的委托量 | 排除时间因素干扰 |
笔数撤单率和数量撤单率经常给出相反的信号:如果散户大量挂小额单又快速撤掉,笔数撤单率会很高但数量撤单率不高;如果是机构撤大单,情况反过来。我一般两个都算,用它们的差值作为“大额撤单倾向”的代理变量,这个差值在识别虚假挂单时比单一指标有效得多。
还有一个必须处理的细节:撤单率的时间窗口要和委托到达的时间窗口对齐。如果你统计“最近 1 分钟的撤单量”除以“最近 1 分钟的委托量”,但撤单的往往是 1 分钟之前挂的单,这个比值就会失真。正确做法是先建生命周期表,按撤单发生的时刻归集撤单量,按委托提交的时刻归集委托量,两边各自滚动求和后再相除。这个改动听起来很细,但在我做过的对比里,两种算法的因子值相关性只有 0.6 左右,选错了等于白做。
4. 委托流里的行为特征:那些不那么显眼但很有效的因子
4.1 委托存活时间,是“耐心”的直接度量
每笔订单从提交到成交或撤销的时间间隔,是一个信息密度很高的变量。存活时间长的订单,通常来自有耐心的大资金;存活时间极短的订单,大概率是做市或高频的快速试探。
计算上很直接:从生命周期表里拿cancel_ts - submit_ts,对于最终成交的订单,用成交时间和提交时间的差。然后取滚动窗口的中位数或者偏度。中位数比均值稳健,因为偶尔会有隔夜单把均值拉爆。
我自己的经验是,存活时间的分布偏移比水平值更有预测力。具体做法是维护一个滚动基准(比如过去 20 个交易日同时段的中位数),然后看当前窗口的中位数相对基准的比值。这个比值上升,说明市场上出现了更多有耐心的挂单,往往对应着趋势的酝酿;比值下降,说明做市和试探性挂单占比升高,对应震荡。
还有一个变体是生存曲线:把窗口内的订单按存活时间分桶(比如 0-100ms、100-500ms、500ms-2s、2s 以上),统计各桶的成交量占比。这个向量比单一中位数包含更多结构信息,但维度高,一般降维后再用。
4.2 委托价格的相对位置,透露的是信心强度
每笔限价单提交时,价格相对于当时中间价的位置,是个很好的“信心”代理。挂在中间价附近的单子是保守的,挂在远离中间价(甚至越过对手价变成可立即成交)的单子是激进的。
具体指标可以这样构造:设提交时中间价为 $m$,半价差为 $s = (P^a - P^b)/2$,那么标准化偏移为
$$\text{offset} = \frac{P_{order} - m}{s}$$
买单的 offset 越接近 +1,说明离卖一越近、越激进;越接近 -1 甚至更负,说明挂得越远、越保守。这个指标的问题是在价差极窄的时候会放大噪声,所以分母要加一个下限,比如 $\max(s, \text{tick size})$。
按方向分别统计 offset 的分布,能得到几个有用的特征:主动买方的 offset 均值、主动卖方的 offset 均值、两者的差。最后一个差值我把它叫“进攻性差”,在某些标的上和后续几十秒的价格变动有稳定关系——买方比卖方更激进的时候,短期价格倾向于上行。
这个因子的一个陷阱是:在价差只有 1 个 tick 的标的上,offset 只有三种取值(-1、0、+1),分布退化,因子的区分度很差。用之前先看一下标的的价差分布,价差长期在 1 tick 的标的直接跳过这类因子。
4.3 委托到达强度的突变,比水平值更值得关注
单位时间内的委托笔数和委托量,是最基础的委托流指标。但直接用它做因子的效果通常一般,因为它和成交量高度相关,而成交量的预测能力已经被挖掘得很充分了。
真正有增量信息的是到达强度的突变:在成交没有明显放大的情况下,委托笔数突然增加,说明有人在密集挂单试探;反过来,成交放大但委托笔数没跟上,说明是在消耗存量流动性。
构造方式:对委托笔数序列做滚动标准化,取 |z-score| 超过阈值的时段作为“突变事件”,然后统计这些事件的频率和方向分布。方向分布这块要分开看买单和卖单,因为买单密集涌入和卖单密集涌入的含义完全不同。
这里有个时间尺度的选择问题。用 100ms 的窗口,捕捉到的是高频做市的行为;用 5 秒的窗口,捕捉到的是算法单的切片节奏;用 1 分钟的窗口,才更接近人为决策。不同窗口捕捉的是完全不同主体的行为,不要指望一个窗口能覆盖所有。我一般会在 3 到 5 个不同尺度上各算一遍,作为独立特征输入。
4.4 队列位置,需要逐笔委托的序号才能算
如果能拿到逐笔委托的完整序列(不只是成交),就可以重建订单簿的队列结构。队列位置指的是:某笔挂单在它所在价位的排队顺序,排在前面的更容易成交。
这个特征对预测“挂单能否成交”非常有用,但对价格预测的增量信息有限,因为它反映的是被动方的处境,而不是主动方的意图。实际使用中,我更多把它用在撤单预测上:队列位置靠后的订单,撤单概率明显更高(因为成交希望渺茫)。
计算队列位置的难点在于需要完整重建每个价位的挂单链表。这个操作的复杂度是 O(事件数),在单标的上完全可接受,但在全市场、全年度的数据上就必须考虑增量计算和缓存。工程上的做法是只对关注的标的、关注的时段做重建,而不是全量跑。
5. 工程落地:从能算到算得快、算得对
5.1 存储格式:列式 + 分区,别用 CSV
逐笔数据的体量很容易让人踩坑。一个中等活跃度的标的,一天的逐笔成交加委托大概几十万到上百万条,全市场一天的记录数在千万到亿级。用 CSV 存,压缩率低、读取慢、类型推断还容易出错。
我的做法是Parquet + 按日期和标的分区,分区路径形如date=20240101/symbol=XXXXXX/。这样做的理由不只是读取快:按需扫描特定日期和标的时,可以完全跳过无关分区,配合谓词下推,读取量能降一到两个数量级。
排序键在分区内固定为 (序列号),因为单个标的单日的数据量在百万级,按序列号排序后,后续的滚动窗口计算可以利用顺序访问,缓存命中率高很多。字段类型方面,价格用 int64 存最小变动价位的整数倍,成交量用 int64 存股数,时间戳用 int64 存纳秒。全部用整数,既省空间又避免浮点误差,只在最终输出因子值时转成浮点。
| 存储方案 | 单日全市场读取耗时(相对值) | 压缩率 | 适用场景 |
|---|---|---|---|
| CSV + gzip | 100 | 1x | 小样本调试 |
| Parquet 无分区 | 35 | 3x | 单标的全量分析 |
| Parquet 按日分区 | 12 | 3x | 日常因子计算 |
| Parquet 按日+标的二级分区 | 5 | 3x | 增量更新、单标的回溯 |
上面的相对耗时是基于我自己的机器跑的粗略对比,绝对值会随硬件变化,但量级关系是稳定的。
5.2 计算引擎:pandas 能跑通,但别指望它跑得快
小样本用 pandas 完全没问题,几百万行以内的操作都很流畅。但逐笔数据的规模上来了之后,pandas 的瓶颈很明显:groupby-apply 是纯 Python 循环、字符串操作慢、内存占用是数据本身的几倍。
我现在的组合是:polars 做数据整理和分组聚合,numpy 做核心的滚动计算,必要时用 numba 加速热点循环。polars 的惰性求值能把多个操作合并成一次扫描,配合流式执行,内存占用比 pandas 小很多;numpy 的累加和卷积适合做滚动求和类的操作;numba 用在需要逐事件更新状态的场景,比如重建订单簿。
性能对比上,同一个因子在 pandas 里跑一天全市场需要 40 分钟,polars 重写后降到 6 分钟,把滚动部分换成 numpy 的sliding_window_view之后到 2 分钟出头。这个提升幅度在需要反复迭代因子的场景里是决定性的——等你 40 分钟才看到结果,思路早就断了。
import numpy as np from numpy.lib.stride_tricks import sliding_window_view def rolling_ofi(signed_vol: np.ndarray, w: int) -> np.ndarray: """signed_vol 是按序列号排好序的带符号成交量""" v = np.abs(signed_vol).astype(np.float64) sv = signed_vol.astype(np.float64) num = sliding_window_view(sv, w).sum(axis=1) den = sliding_window_view(v, w).sum(axis=1) out = np.full_like(sv, np.nan) with np.errstate(invalid="ignore", divide="ignore"): out[w-1:] = np.where(den > 0, num / den, np.nan) return out注意这个版本是事件窗口而不是时间窗口,因为sliding_window_view天生按行切。要做时间窗口,得先用searchsorted把时间戳转成索引边界,再做分段聚合。
5.3 前视偏差:逐笔数据里最隐蔽的一类错误
前视偏差在低频因子里比较好防,在逐笔数据里却极其隐蔽。常见的几种表现:
第一种,窗口端点包含当前时刻。前面提过,用 $[t-W, t]$ 的闭区间会把你正在预测的那个时刻的信息也算进去。如果标签是从 t 开始的未来收益,因子必须严格只用 t 之前的信息。
第二种,用成交时间戳而不是系统时间戳。一笔成交在 10:00:00.500 完成撮合,但成交回报可能延迟到 .505 才被推送到你这里。如果你的因子按成交时间戳对齐,实盘上就会用到“未来”的 5 毫秒。这个延迟在毫秒级因子上是致命的,在分钟级因子上可以忽略。
第三种,快照和逐笔混用时的对齐错误。三秒快照的时间戳是采样时刻,但快照里的订单簿状态反映的是采样时刻之前的状态。如果把快照时间戳直接当成信息可用时刻,会引入最多一个采样周期的前视。正确做法是把快照的信息可用时刻设为下一个快照到来的时刻,或者留出足够的缓冲。
第四种,标签计算用了同一根 bar 的收盘价。如果因子在 bar 内计算,标签用这根 bar 的收盘价到下一根 bar 收盘价的收益,那没问题;如果标签用了这根 bar 的最高价,那就是彻头彻尾的前视。
我排查前视偏差的办法是做一个极端测试:把因子值随机打乱,重新跑回测。如果打乱后的因子还能有显著收益,说明流程里存在信息泄漏。这个测试很粗暴,但能筛掉大部分低级错误。
5.4 采样频率与标签对齐,决定了因子的天花板
逐笔因子天然是高频率的,但标签不一定是。如果你用 1 分钟标签,那么 1 分钟内产生的大量因子值必须做聚合。聚合方式的选择会影响因子表现:取均值会抹平极端事件,取最大值会放大噪声,取最后一个值会丢失窗口内的路径信息。
我的默认做法是在标签对应的时点上取因子值,而不是对窗口内的因子值做聚合。也就是说,如果标签是 t 到 t+1min 的收益,因子就在 t 时刻计算一次,用 t 之前的窗口。这样因子和标签是一一对应的,避免了聚合方式引入的额外自由度。
另外一个选择是按成交量采样(volume clock)而不是按时间采样。逐笔数据的到达强度不均匀,按时间采样会在清淡时段产生大量低信息量的样本,按成交量采样则能让每个样本的信息含量更均匀。代价是采样时间点不规则,跨标的对齐比较麻烦。我一般在对单标的做研究时用成交量采样,做横截面因子时用时间采样,两者不混。
6. 几个真实翻过车的场景,以及事后看应该怎么做
6.1 因子相关性 0.95,白算一场
最打击人的一次经历是:花了两周做了一整套委托流因子,回测效果不错,结果和已有的几个中低频因子一做相关性分析,最高的到 0.95。回过头看原因很简单——我用的窗口太长了。5 分钟窗口的委托流不平衡,本质上就是 5 分钟的价格动量换个马甲,信息早被吃干净了。
教训是:在做因子之前先想清楚“这个因子应该在什么时间尺度上有效”。订单流类因子的信息半衰期通常在秒级到几十秒,窗口拉长到分钟级,信号会被价格本身的均值回复抵消掉。后来我把窗口压到 10 秒和 30 秒两档,和已有因子的相关性降到 0.4 以下,增量信息才出来。
6.2 换手率高到成本吃掉全部收益
逐笔因子的天然属性是变化快,因子值的自相关性可能只有 0.2 到 0.3。如果直接按因子值排序做多空,每天的换手率会高得离谱,交易成本轻松吃掉全部账面收益。
解决办法不是简单加个平滑,那样会把信号一起抹掉。我的做法是在因子层面做衰减加权:把过去 K 个时点的因子值按指数衰减加权,衰减系数选得让半衰期和因子的信息半衰期匹配。这样既降低了换手,又保留了近期信息的主导地位。K 和衰减系数的选择要看因子的自相关函数,不能拍脑袋。
另外一个思路是在组合层面控制换手,比如加一个换手惩罚项,或者只在因子值变化超过阈值时才调仓。这个更偏向组合优化,不展开。
6.3 供应商口径变了,因子一夜失效
有一段时间某个因子的表现突然断崖式下跌,排查了很久才发现是数据供应商调整了撤单记录的字段含义——原来用负数量表示撤单,改成了用订单类型字段标记,负数量的记录变成了“部分成交后的剩余量修正”。代码没改,撤单率直接算错,因子当然是废的。
这件事之后我养成了一个习惯:每次数据更新后,跑一组数据质量断言。具体包括:每日记录数是否在历史区间内、撤单量占比是否突变、主动买卖比例是否接近 50%、订单号 join 的成功率是否正常。这些断言写在数据加载的流水线里,一旦触发就报警,比等因子失效再回头查要省事得多。
6.4 全市场一起算,内存直接爆
早期图省事,把全市场一天的逐笔数据一次性读进内存做 groupby。数据量大的日子直接 OOM,机器卡死。后来改成按标的分片、按日增量:每天只处理当天的数据,每个标的独立计算,结果写入因子库。这样单次计算的内存占用被限制在单标的的量级,稳定得多。
增量计算还有一个额外好处:可以随时回补。某个标的某天的数据有问题,只需要重跑那一天那一个标的,不用动其他部分。这个特性在数据质量不稳定的环境里非常值钱。
6.5 样本外失效,问题往往在样本内就埋下了
最后一个,也是最难避免的:因子在样本内表现很好,样本外一塌糊涂。除了过拟合这个老生常谈的原因,逐笔因子还有几个特有的陷阱。
一是参数对流动性极度敏感。在活跃标的上调好的窗口长度和阈值,换到清淡标的上完全不适用。解决办法是参数按标的的流动性分档设置,而不是全局一套。
二是市场结构变化。撮合规则、最小变动价位、交易时段的调整,都会让历史数据的统计特征发生漂移。用跨越多规则变更期的数据训练,等于在拟合一个已经不存在的市场。做样本外测试时,一定要确认训练期和测试期的市场结构是一致的。
三是因子拥挤。订单流类因子的逻辑是公开的,用的人多,alpha 衰减得快。这不是技术问题,是市场问题,只能通过持续挖掘新的信息维度来对冲——比如从单纯的订单流转向订单流和委托行为的联合分布,或者引入跨标的的订单流传导。
我自己的体会是,逐笔数据这块,最难的不是算因子,是想清楚每个因子在赚谁的钱。想不清楚的因子,回测曲线再漂亮也不敢上。这个判断标准听起来很虚,但它确实帮我躲过了不少看似精妙实则没有经济逻辑的东西。