L1 事件流缓存设计

解决「事后才知道要什么逐笔因子,但又不想每次重碰原始 gz」的矛盾。体积数字来自真实实测(74k 事件/股·日)。
核心思想:不要物化「成品因子」,而是物化一个「与因子无关、却已付掉最贵开销」的中间层。
因为因子是挖出来的,你永远无法预知未来要什么 filter。但有一件事是确定的:把原始 gz 变成 「干净、类型化、带方向、带窗口标签的逐笔事件流」这一步——与算什么因子完全无关,且最贵。 把它做一次存下来 = L1。未来任何 filter 都在 L1 上现算。

① 三层架构:L1 卡在哪一层

L0
原始 gz(共享盘)
字符串、×10000整数、压缩、含坏文件;沪深两套 schema;方向/撤单未解析
啥都能算,但每次都贵(解压+解析+判向+join ≈ 650ms/股·日)
L1
标准化事件流缓存 ← 本页设计
已解析、已判向、已分成交/撤单、按日按股分区的逐笔原子流。与因子无关。
一次性付掉那 650ms;未来任意 filter 在它上面 ≈ 10ms 现算
L2
T×C 字段(32字段 + 衍生)
具体因子的物化结果,filter 写死。挖掘阶段只碰这层。
便宜(10ms/字段)、快(挖掘只在矩阵上跑),但 filter 锁死、信息有损
L1 是「灵活性 vs 速度」的折中点:比 L0 快数倍(跳过解析/判向/join),比 L2 灵活(不锁 filter)。

② 为什么 L1 能做到灵活:先看「未来 filter 会引用什么」

任何逐笔因子的 filter,引用的东西只有三类来源。L1 只存第一类,后两类从 L1 现算——这就是灵活性的机制。
引用源例子谁提供为什么
记录级(每笔自带)price, qty, dir, is_cancel, tsL1 存原子信息,无法重算(判向/撤单解析很贵)
窗口广播量(每10s)win.close, win.vwap, win.qty分位现算从 L1 一次 groupby 廉价得到,存了反而锁死
日级量day.prev_close, day.float_shares现算/小表每股每日一个值,外部小表即可

③ L1 Schema(每行 = 一个逐笔事件)

分区:L1/date=YYYYMMDD/code=000001.sz.parquet —— 按日按股,挖掘时只读需要的分区。

核心列 core(必存:这就是那 650ms 的产物)

类型含义为什么必须存(不能现算)
ts_msInt32当日毫秒时刻原子;任何时间 filter(尾盘/精确ms)都要
is_cancelBool成交 / 撤单沪深机制不同(深市Trade ExecType=4 / 沪市Order OrderType=D),判定贵
dirInt8+1买 / −1卖 / 0未知方向判定贵(深市比序号、沪市读 BSFlag)
priceFloat32真实价(已÷10000)解析+还原的产物
qtyInt32股数原子
每行约 14 字节。这5列覆盖约90%的 filter:价格位置、时间位置、方向、大小(配amt)、撤单。

故意不存(现算,省一半体积 + 保灵活)

不存怎么来为什么不存
amt = price×qty一次乘法派生量,存了浪费
win = ts_ms//10000一次除法派生量
size(大/小单)amt 比阈值 θ关键:θ 是 config 选择(绝对/分位/截面)。存 size 就锁死阈值,存 amt 则任意 θ 现切
win.close / vwap按窗口 groupby窗口广播量,现算;存了等于赌死参考价

可选扩展 L1-ext(只在需要时才建,平时不背体积)

解锁的因子族代价
seq排序、链接
passive_order_ts挂单存活时长、订单耐心需 join Order 表,贵
bid_seq / offer_seq订单簿重建相关
分层关键:core 覆盖大多数 filter;ext 只为「订单生命周期」这类小众因子才建。

④ 上下文层:filter 里的 win.* / day.* 解析到这里(不入库)

一个「广播函数注册表」,要什么参考量就注册一个函数,从 L1 现算后广播回每笔。灵活性就在这:未来要新参考量,只加一个函数,不碰 L0、不改 L1。
win.close = 该10s窗口最后一笔 price win.vwap = Σ(price·qty) / Σqty win.qty_q(0.8) = 窗口内单笔金额的80%分位 # 大单阈值 day.prev_close = 前一交易日收盘 # 外部小表 day.float_shares = 流通股本 # 外部小表

⑤ 一个因子的完整落地:HCVOL(买入浮亏占比)

声明式,不写死。HCVOL = 买在 close 上方、现在浮亏的买盘占比。
HCVOL: filter: dir == +1 and price > win.close # 记录级price + 窗口广播close value: qty agg: sum normalize: win.total_qty
执行:① 读 L1 分区(已解析,无需碰 gz) → ② 注册表算 win.close / win.total_qty 广播回每笔 → ③ filter + reduce ≈ 10ms → ④ 落 HCVOL.parquet(T×C,进入 L2) 挖掘阶段照常在 T×C 上跑算子,完全不受影响

⑥ 体积与代价(诚实)

数字说明
单笔事件core 14 字节ts_ms+is_cancel+dir+price+qty
实测事件量~74k 事件/股·日样本偏活跃股;全市场真实均值更低
单股·日~1 MB(未压)74k×14B
全市场全历史~0.3 ~ 1.3 TB(zstd压缩后)5100股×725日;TB级跑不掉,用磁盘换灵活性

缓解

列类型已最省Float32/Int32/Int8,不用Float64
按日按股分区挖掘/补算只读需要的分区,不全量加载
可只缓存近 N 年 / 活跃股冷门股或远古数据按需回 L0
ext 列按需订单生命周期类因子才建,平时只有 core

⑦ 两个必须标清的点 ⚠️

1. 前视偏差win.close 是窗口末才确定的,窗口内的买单跟它比 = 轻微前视。参考量选 win.close(含当窗) / prev_win.close(纯历史) / day.prev_close 语义与前视性不同, spec 里要显式声明,做预测因子时只用无前视的那种。
2. L1 schema 本身仍是一次「赌」——赌它留的原子列够全。所以 core 要留够原子(价/量/向/时/撤); 真遇到要十档盘口那种因子,才回 L0。但那是极少数。
一句话总结:别物化因子(你不知道要什么),物化那个与因子无关、却付掉了最贵开销的标准化逐笔层 L1。 未来任何 filter 都在 L1 上现算 —— 既不必预知,又比重碰原始数据快数倍, 且每个因子自带可读 spec → 天然可解释溯源
L1=标准化逐笔原子流 · 参考量现算不入库 · 体积数字实测自 74k事件/股·日 · 接「因子可表示性对照」的❌项