L1 事件流缓存设计
解决「事后才知道要什么逐笔因子,但又不想每次重碰原始 gz」的矛盾。体积数字来自真实实测(74k 事件/股·日)。
核心思想:不要物化「成品因子」,而是物化一个「与因子无关、却已付掉最贵开销」的中间层。
因为因子是挖出来的,你永远无法预知未来要什么 filter。但有一件事是确定的:把原始 gz 变成 「干净、类型化、带方向、带窗口标签的逐笔事件流」这一步——与算什么因子完全无关,且最贵。 把它做一次存下来 = L1。未来任何 filter 都在 L1 上现算。
因为因子是挖出来的,你永远无法预知未来要什么 filter。但有一件事是确定的:把原始 gz 变成 「干净、类型化、带方向、带窗口标签的逐笔事件流」这一步——与算什么因子完全无关,且最贵。 把它做一次存下来 = L1。未来任何 filter 都在 L1 上现算。
① 三层架构:L1 卡在哪一层
L0
原始 gz(共享盘)
字符串、×10000整数、压缩、含坏文件;沪深两套 schema;方向/撤单未解析。
L1
标准化事件流缓存 ← 本页设计
已解析、已判向、已分成交/撤单、按日按股分区的逐笔原子流。与因子无关。
L2
T×C 字段(32字段 + 衍生)
具体因子的物化结果,filter 写死。挖掘阶段只碰这层。
L1 是「灵活性 vs 速度」的折中点:比 L0 快数倍(跳过解析/判向/join),比 L2 灵活(不锁 filter)。
② 为什么 L1 能做到灵活:先看「未来 filter 会引用什么」
任何逐笔因子的 filter,引用的东西只有三类来源。L1 只存第一类,后两类从 L1 现算——这就是灵活性的机制。
| 引用源 | 例子 | 谁提供 | 为什么 |
|---|---|---|---|
| 记录级(每笔自带) | price, qty, dir, is_cancel, ts | L1 存 | 原子信息,无法重算(判向/撤单解析很贵) |
| 窗口广播量(每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_ms | Int32 | 当日毫秒时刻 | 原子;任何时间 filter(尾盘/精确ms)都要 |
| is_cancel | Bool | 成交 / 撤单 | 沪深机制不同(深市Trade ExecType=4 / 沪市Order OrderType=D),判定贵 |
| dir | Int8 | +1买 / −1卖 / 0未知 | 方向判定贵(深市比序号、沪市读 BSFlag) |
| price | Float32 | 真实价(已÷10000) | 解析+还原的产物 |
| qty | Int32 | 股数 | 原子 |
每行约 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事件/股·日 · 接「因子可表示性对照」的❌项