LPSignal 第一版的机会信号全部从 finalized 归档算。docs/design-head-signals.md 记录了 10-01 的延迟测量:以太坊和它的 L2 的 finalized 区块落后链头 13 到 21 分钟,呈锯齿状;归档再按 50 个块一段写入,一个小时结束后约 20 到 35 分钟才能发信号。BSC 和 Polygon 几秒就 finalize,只晚 1 到 2 分钟。这次修改的目标是所有链都在小时结束后 2 到 5 分钟内发出。下面记 10-01 到 10-04 的实现,以及 10-10 加链时的参数调整。
先改了两处显示和告警
先调整了归档空闲或缺口告警:原来是 10 分钟,而归档正常就会落后到 30 分钟左右,改成 60 分钟(6a8e8cc)。随后调整机会页的链同步状态,在 finality 窗口内显示「Synced」,悬停说明原因,超过 45 分钟才显示「Delayed」(b01bcae)。
被否掉的第一版
第一版设计是一个持续运行的链头采集器,从归档游标开始往后追加「临时小时」。设计评审时被否掉了,列出的问题有这几条:
- 衔接的那一个小时一半来自归档、一半来自链头,要么拼接,要么重复计数;
- 归档和链头两个评估器会互相覆盖共用的
pool_metric; - 开仓价
positionNow和 burst 信号的成交笔数门槛直接读归档,和临时数据不是同一个视角; - reorg 检查有时间窗口,fee growth 的基线可能横跨两个小时;
- 归档追上之后再算一次,可能对同一个小时发出第二条、而且结论相反的信号。
采用的方案:整小时抓取
最后的做法是不做持续采集。链头(减去几个块)越过某个小时 H 的结束时间后,一次性把 H 整个抓下来,存成一份不可变的临时快照(head_hour + head_pool_hour),实现在 src/ingest/head.ts。步骤是:
- 二分查找 H 的精确区块范围:时间戳小于 H 的最大块号和小于 H+1h 的最大块号(
lastBefore)。和扫描器按块时间戳分桶的规则一致。 - 用
eth_getLogs拉这段范围内所有活跃池子的 Swap 日志。v3 风格按池子地址和两个 Swap topic 过滤,每批最多 100 个地址;v4 按 PoolManager 地址和 pool id topic 过滤,每批最多 100 个 pool id。 - 用扫描器自己的函数解码、分桶。
- 在 H 的最后一个块和上一小时的最后一个块读
feeGrowthGlobal,得到每单位流动性的手续费差值。某个池子缺边界状态时仍写入 Swap 估算,feeExact=false,不能作为精确信号输入。 - 检查通过后在一个事务里写入快照,
createMany ... skipDuplicates,已经存在就不覆盖。
「小时结束」用的是链头往回 HEAD_DEPTH 个块(默认 5)的那个块的时间戳,给浅层 reorg 留一点时间。
日志是不是完整的
整件事成立的前提是 eth_getLogs 返回的日志是完整的。RPC 服务商后面是一组节点,如果请求落到一个落后的节点上,它可能返回一个被截断的范围,看起来完全正常。
我依赖的是一个可以测量的行为:用数字块号请求 eth_getLogs,toBlock 超过应答节点自己的链头时,节点会报错,而不是返回它有的那部分。我查了 geth、op-geth、nitro、bor、bsc 几个客户端的源码,又写了 scripts/ops/getlogs-failclosed.mjs 实测:每条链 20 轮,每轮请求 head+1 和 head+50 两个 toBlock。head+1 可能成功(两次请求之间刚好出了新块),head+50 必须报错。10-01 在六个私有端点上的结果是 120/120 报错。注释里写着换 RPC 服务商时要重跑。
每次日志请求还打包成一个 JSON-RPC batch:eth_getBlockByNumber 取「安全链头」块,eth_getLogs 取日志。代码按 batch 由同一节点应答的前提检查安全链头哈希,这属于提供方行为假设,batch 本身不能证明后端节点相同。安全链头返回 null 时记为 incomplete;哈希与先前读取不一致时记为 reorg。两种情况都丢弃这次抓取,下一轮再来。
状态和日志对账
fee growth 和 tick 都按块哈希读(EIP-1898,canonical only)。每个活跃池子还要做 tick 对账:有成交的,结束块 tick 必须等于最后一笔 Swap 留下的 tick;无成交且两端 tick 都可读的,要求两端相同,无成交池子缺一端状态时不会因此拒绝整小时。对不上就丢弃抓取,记为 incomplete。相等只能说明这项检查通过,不能单靠 tick 证明没有漏日志。最后再读一次两个边界块,哈希必须仍与最初相同。
每次请求多少块
第一版每次请求 2000 个块,在 Optimism 上超时了。代码注释将日志量大记为 JIT 机器人频繁成交,随后改成 500 个块一段(2b6cbe6)。请求失败时把范围对半拆开重试,to - from < 50 时仍失败就抛出,也就是请求范围包含至多 50 个块时不再拆分。
上线前用 scripts/ops/head-hour-feasibility.mjs 拿一个已经结束的小时做了可行性测量,500 块一段:六条链上 RPC 拉到的每个池子的 Swap 笔数和归档完全相等。耗时 Base 1.7 秒(1800 个块、11094 笔 Swap),Ethereum 0.4 秒,Optimism 4.7 秒,Polygon 14 秒,Arbitrum 38 秒(12943 个块),BSC 60 秒(8000 个块)。最慢的两条里,BSC 本来就几秒 finalize,从链头抓省不了多少时间。
一个评估器,每个小时只判一次
每条链只有一个机会评估器(src/signals/opportunities.ts,worker 里的 oppLoop 每 15 秒跑一轮),它是 pool_metric 唯一的写入者。单个小时的输入要么是归档(已扫描且精确手续费已处理),要么是链头快照,不在同一小时里混源。跨多个小时的回测窗口可以同时包含较早的归档小时和较新的链头小时。开仓价、成交笔数门槛也按这份视图读取。loadHours 接受一个 headFromMs,之前的小时从 pool_hour 读,从这个时间点起从 head_pool_hour 读。
评估按小时顺序推进一个游标 opp_cursor,每个小时边界只判一次。发出去的结果就是最终结果,归档追上来也不重判,所以不会出现同一小时先发一条、过半小时再发一条相反的。几个时间常量:
1
2
3
export const CAUGHT_UP_MS = 2 * HOUR_MS; // 归档落后超过 2h 时不评估机会信号
export const HEAD_LOOKBACK_H = 3; // 只从链头抓最近 3 个小时
export const FRESH_MS = 2 * HOUR_MS; // 超过 2h 的边界算历史,跳过
链头抓不到(RPC 挂了、日志不完整),评估器就等,等到归档把这几个小时变成 final 再用归档的数据。抓链头失败不会拖住归档已经有的东西。
归档追上来之后,对应的链头快照会和归档逐池对比成交笔数,结果打日志(head hours differ from the finalized archive 或 head hours match the finalized archive),并写到指标 lps_head_archive_differ_rows,然后删除快照。
Smart LP、机会信号的结果评分和自定义风险规则仍用归档。net_apr 在发出 7 天后评分,burst 在 24 小时后评分;链头发出的这两类信号也用归档回测。自定义机会规则则跟着机会评估器走,使用同一份指标和小时视图。
风险提醒:每分钟读一次链
流动性撤出和脱锚两类风险提醒原来跟着小时快照走,在以太坊及 L2 上也会晚约 15 到 25 分钟。10-01 改成每分钟从链上读(cb37587,src/signals/risk-live.ts),沿用风险阈值、池子筛选和 24 小时冷却。脱锚确认从两条小时收盘价改成两次链上读数;流动性撤出用链上当前余额和过去 3 小时快照比较,按最新快照的美元价格重估。小时检查保留作兜底。
每一轮只读一个块,默认是新鲜链头往回 5 个块。读之前和读之后这个块的哈希要一致,否则丢弃这一轮。每个条件要在两次读数上都成立,两次相隔 1 到 10 分钟,块号在前进,并且第一次读的块仍然是 canonical。这些检查用来过滤停滞节点、分叉变化和短暂状态,跳过的轮次按原因计入 lps_risk_live_skipped_total。
小时检查和每分钟检查是两个写入者,冷却判断必须放在信号插入的锁里做,两边才不会对同一件事各发一条。
多个写入者和 signal id 的顺序
WebSocket 推送按 signal id 游标扫表,早期项目规范依赖单个 worker 串行插入信号来保证 id 按顺序提交。10-01 零点多给 Smart LP 加了每分钟检查开仓的快速路径(1b21e1c),插入信号的循环可以并发运行,同一个 worker 进程已经不足以保证提交顺序。这个提交同时给信号插入加了事务锁。
PostgreSQL 的序列在插入时分配 id,但事务提交的顺序不一定和 id 顺序一致。如果 id 101 的事务比 102 晚提交,WSS 泵在两者之间扫表,看到 102 就把游标推到 102,之后 101 提交了也不会再被扫到。
修法是所有插入都走 createSignal,它在事务开头拿一把事务级 advisory lock,从分配 id 一直持有到提交:
1
2
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${SIGNAL_INSERT_LOCK})`;
// 冷却检查、插入、pg_notify 都在这把锁里
随后加入的每条链独立的 Smart LP 循环(6bdfb1a)、每分钟风险检查和链头评估器都走这个函数。风险提醒的冷却检查也放进了锁内,避免小时检查和每分钟检查各发一条。
10-04:给外部读者一个通知
10-04 我的 LP 机器人(另一个仓库 LPSignal-Operator,用只读数据库角色)要读 pool_metric,需要知道某条链某个小时的指标什么时候全部写完。在推进 opp_cursor 的同一个事务里加了 pg_notify(8414680),通道是 lpsignal_metrics,payload 是 {chain, asOf, source} 的 JSON 字符串。通知到达时这一小时的 pool_metric 已经全部提交。通知只负责唤醒,监听方离线期间错过的小时要自己读 opp_cursor 补上。这个通道的约定写进了 CLAUDE.md。
10-10:0.1 秒出块的链
10-10 加 Robinhood Chain 时,默认参数不再适用。提交 445a523 记录它大约 0.1 秒一个块,「往回 5 个块」只有半秒,「500 块一段」约覆盖 50 秒。src/chains.ts 给它单独设了 headDepth 50(约 5 秒)和 logChunk 5000;head.ts 读这两个参数,risk-live.ts 只读 headDepth。该提交也记录了在它的端点上重跑 fail-closed 测试,head+50 的 20 次请求全部报错。
设计文档里的 2b 阶段还没开始做:burst 信号改成每 5 分钟评估一次滚动 60 分钟窗口,需要在整小时抓取之上再做小时内的分桶。
LPSignal 工程笔记(3/4)