09-29 深夜我建了 LPSignal 的仓库,09-30 凌晨提交了后端的第一个版本(da3592f,提交说明记了 103 个单元/集成测试),随后提交卡支付,当天下午提交网页和稳定币付款;10-02 的项目规范记下了 Stripe 已切到 live。项目当时支持 Ethereum、BSC、Base、Arbitrum、Optimism、Polygon,对白名单代币的集中流动性池按「手续费减无常损失」的净 APR 排序,满足条件就通过 Telegram、webhook、WebSocket 推送信号。
DEX 上的 APR 为什么不够用
LPSignal 也展示池子级别的 24 小时 fee APR,用过去一天的估算手续费除以 TVL 再年化。这个数没有区分仓位的价格区间,也没有扣除无常损失。集中流动性仓位能拿到多少手续费,需要结合它的区间和窗口内的价格路径计算。
我想算的是:在窗口开始时按一个指定区间开仓,持有一周后,相对一直持有开仓时的两种代币,收益是多少。
数据从哪来
历史日志从自己维护的原始链归档读取:S3 上按链分目录的 <start12>-<end12>.jsonl.zst 文件,只收 finalized 区块,每段约 50 个块。读取器从 pmwallets 移植过来,自带哈希链和行数校验。归档在 AWS 新加坡区,早期项目规范记下的压缩数据量约为每天 13GB,从区外读取要付 S3 出网费用。池子参数、历史手续费状态等仍需要 RPC。
扫描器不保存原始数据。它将 V3 风格和 v4 的 Swap 日志按池子聚合成小时桶写进 pool_hour,同时处理 Smart LP 的仓位事件,记下跨过的小时的最后一个块(chain_hour)。这些更新和 chain_cursor 在同一个事务里提交。提交前检查游标上一次停在的块号和哈希,防止同一段数据重复计入。归档里出现缺口时扫描器会等,不会跳过。
一个地址发出了 Swap 事件不代表它是真池子,任何人都能部署一个合约发同样签名的事件。src/dex/pools.ts 里,V3 风格的池子要变成 active 得满足三条:factory() 是 src/chains.ts 里列出的工厂;那个工厂用 getPool(token0, token1, fee 或 tickSpacing) 反查回来正好是这个地址;两个代币都在白名单里。解析结果(active 或 ignored 加原因)落库,通常不再重复解析;10-02 扩大白名单时,另加了按 WHITELIST_REV 对 ignored 池子重新解析的路径。
解析池子时,必要的合约调用 revert 按不支持或无效池子处理,标成 ignored,避免扫描器一直重试同一个地址。这不能证明合约恶意。RPC 传输错误则必须抛出重试;viem 的 multicall 在 allowFailure 模式下可能把它包装成单个调用失败,src/rpc.ts 会识别这类错误并重新抛出,避免网络抖动导致真池子被记成 ignored。
三家 DEX 的差别
Uniswap v3、PancakeSwap v3、Aerodrome/Velodrome Slipstream 都是 v3 风格,但细节不一样,这些差别都集中在解码和参数读取两处。
Swap 事件:Uniswap v3 和 Slipstream 共用同一个签名,PancakeSwap v3 在后面多了两个协议费字段。前五个数据字(amount0、amount1、sqrtPriceX96、liquidity、tick)位置相同,我只读这五个,用 topic0 区分数据长度是 5 个字还是 7 个字。这段代码每笔 Swap 都要跑,所以手写解码,没用 ABI 库。
工厂反查:Uniswap 和 PancakeSwap 的 getPool 第三个参数是 fee,Slipstream 是 tickSpacing。
手续费里有多少归 LP(lpShares)三家各不相同:
| DEX | 读哪里 | 含义 |
|---|---|---|
| Uniswap v3 | slot0.feeProtocol | 两个 4 位分母,协议拿 fee/d,0 表示关闭 |
| PancakeSwap v3 | slot0.feeProtocol | 两个 16 位数,单位是万分之一 |
| Slipstream | unstakedFee() | 没有协议费,但池子从「未质押」流动性的手续费里抽一部分 |
PancakeSwap 的 slot0 返回结构里 feeProtocol 是 uint32,Uniswap 是 uint8,两套 ABI 分开写。Slipstream 的特殊之处在于部分流动性质押在 gauge 里,质押者放弃手续费换取排放奖励。我报的是未质押 LP 的视角,gauge 排放 APR 单独算出来展示(568f50f),只作参考,不参与信号判断。
09-30 当天还加了 Uniswap v4(86beba4)。v4 的池子是 PoolManager 里的一个 id,Swap 从 PoolManager 发出,key 要从 PositionManager 的 poolKeys 读,再哈希回 id 验证。实现只接受无 hook、静态费率的池子。v4 Swap 事件的金额是交易者视角,和 v3 的池子视角符号相反;解码器注释记录了主网上 541 笔 Swap 的核对结果,解码时取反。
手续费:从估算到精确
扫描时先用 Swap 日志估算:输入金额乘当前费率乘 LP 份额,再除以成交后的区间内流动性。这份估算先写入小时桶,随后由链上 fee growth 差值覆盖。
精确的做法是读池子的 feeGrowthGlobal0X128 / feeGrowthGlobal1X128。这是「每单位区间内流动性累计赚到的手续费」,已经扣掉了协议费,包含动态费率,Slipstream 上则是扣掉 unstaked 抽成之后的值。我在每个小时的最后一个块读一次,相邻两小时的差除以 2^128,就是这一小时一单位流动性的手续费。累加器按设计会模 2^256 回绕,差值要做取模(growthDelta)。读历史块的状态需要归档节点,读失败的小时保持待处理,下一轮重试。
早期项目规范记录了 Base 上的对比:Uniswap 和 PancakeSwap 的池子,Swap 估算和 fee growth 相差约 1%;Slipstream 上最多差到 1.8 倍,记录的原因包括动态费率、质押流动性和跨 tick 成交。信号和结果统计要求 pool_metric.exact:小时行的手续费已补齐,还要满足开盘价已知和区间边界的不确定量限制。没有 Swap 的小时不建 pool_hour 行,回测按零手续费处理,这类小时可能产生的 flash loan 手续费没有建模。
回测怎么算
回测在 src/metrics/clmath.ts,一律按「每单位流动性 L、以 token1 原始单位计」来算。价格 p = 1.0001^tick 本身就是原始单位之比,所以代币精度和仓位大小在所有比值里都约掉了。
一个窗口的做法:在窗口开始价 p0 处开一个对称区间 [p0/(1+r), p0·(1+r)],向外对齐到池子的 tickSpacing(真实仓位只能有这种边界),一直持有到窗口结束,不调仓。区间宽度按交易对类型给几档:
| 类型 | 测试的半宽 |
|---|---|
| stable | ±0.05%、±0.1%、±0.5% |
| correlated | ±0.5%、±1%、±3% |
| volatile | ±5%、±10%、±20%、全区间 |
逐小时判断这个区间能不能拿到当小时的手续费。小时聚合记录开盘、收盘、最高、最低 tick,计算时还带入上一小时收盘价。hourCredit 分三种情况:记录的 tick 范围全在区间内,拿全部手续费;全在区间外,拿零;跨过区间边界,无法分配,按零计,同时把该小时手续费记为「不确定量」。如果不确定量超过已计入手续费的 10%(MAX_UNCERTAIN_SHARE),结果就标成非精确。这里的路径判断基于成交后的 tick 和窗口前价格,不是逐笔重建成交内部的价格路径。
开盘价也有讲究。安静的池子窗口开始前可能好几个小时没成交,开盘价要取它窗口前最后一次真实的收盘 tick,而不是窗口内第一笔成交之后的价格。loadHours 会给每个池子额外带上窗口之前的最后一行,不管它有多旧;实在没有,就把第一小时当成跨边界处理,结果标成非精确。
无常损失是窗口结束时仓位的价值减去「一开始就拿着这两个币」的价值,两者都按结束价估值,手续费也按结束价折算。再除以初始价值、按窗口时长年化:
1
2
3
feeApr = fees / v0 / years
ilApr = (valuePerL(pEnd) - (a0 * pEnd + a1)) / v0 / years // ≤ 0
netApr = feeApr + ilApr
窗口有 1、24、168、720 小时四种,每小时对每个活跃池子、每档区间重算一遍。
从回测到信号
一个池子发 net_apr 信号,要求某一档区间同时满足:7 天和 24 小时的净 APR 都达到门槛(stable 8%、correlated 10%、volatile 30%),7 天内小时收盘 tick 在区间内的比例不低于 80%,两个窗口都是精确数据。池子还需满足最低 TVL 和 TVL 快照新鲜度检查。发过之后 72 小时内不再发,除非 7 天净 APR 达到上次的 1.5 倍。链的归档落后超过 2 小时时不发机会信号,避免补历史数据时发送过期信号。
推荐区间保留选中档位的 tick 宽度,重新居中到发信号时的最近收盘价,并写入信号。7 天后用同一对 tick 回测后续 7 天,把结果和发信号时的数字并排公开,亏的也留着。缺精确手续费时会先等待补齐;最终仍不满足 exact 的结果记为 inexact,不计入精确结果的统计。
另外两类风险提醒对所有人免费,不受 24 小时付费延迟限制。小时检查里,tvl_outflow 将窗口内各次余额按最新快照的美元价格重估,与前 3 小时的最高值相比下降至少 30% 时触发;depeg 检查 stable 和 correlated 交易对的最近两条小时收盘价,是否同向偏离此前 7 天窗口的 tick 中位数至少 0.5%。10-01 又加入每分钟读取链上状态、用两次读数确认的快速检查,小时检查留作兜底。
推送
Telegram 和 webhook 走同一张 delivery 表:每个(信号, 账户, 通道)一行,唯一键保证只建一次。发送方用 SKIP LOCKED 认领,发完标记;中途崩溃会在认领过期后重发,所以是至少一次,delivery id 作为 webhook 的幂等键带给接收方。套餐、账户删除、订阅类型都在真正发送前再查一次,不信任建行时的状态。
webhook 地址是用户填的,属于 SSRF 面:只允许 https,URL 里不能带凭据,不跟随重定向,域名解析出的每个地址都必须是公网地址。检查放在建立连接的那一刻做,校验和请求之间的 DNS rebind 绕不过去。请求头带 x-lpsignal-signature: sha256=HMAC(secret, "<timestamp>.<body>")。
WebSocket(/v1/stream)用数据库游标投递。worker 插入信号时 NOTIFY,API 进程收到后被唤醒,按 signal id 游标扫表,按 id 顺序广播后推进游标。LISTEN 重连和定时器也会唤醒扫描,通知断线或查询失败后可以再查。客户端断线后用 ?since=<最后收到的 id> 补发最近 24 小时内的记录,补发期间实时消息先缓存;补发超过上限就以 4409 关闭连接,不进入实时模式。这些检查保证服务端发送顺序,不能代替客户端确认收到消息。
免费和付费
机会信号付费用户实时收到,免费用户 24 小时后才能看到;风险提醒所有人实时;WebSocket 和 webhook 只给付费用户。这条规则在 API、fan-out 的 SQL、发送时、WSS 的实时和补发路径上各执行一次。价格是 Basic $9.9、Pro $29 每月。
部署
一台 VPS 跑全部东西:原生安装的 PostgreSQL 16(不用 Docker)、pm2 管的 worker 和 API、Caddy。worker 单实例,包含各链扫描器、小时流水线、推送分发和 Telegram 机器人。部署脚本建新 release 目录、迁移、切 symlink,并在切换失败时尝试恢复旧版本。已经成功应用的迁移不会随代码回退,所以要求迁移只做加法,旧代码要能在新 schema 上跑;项目笔记记录,回滚路径尚未演练。
到 10-02,仓库已有链上信号、网页、两条支付通道和 Node/Python 两个 SDK。早期机会信号等待 finalized 归档,在以太坊和 L2 上大约晚 20 到 35 分钟;10-01 已改成抓取链头处刚结束的整小时,具体实现另写一篇。
LPSignal 工程笔记(2/4)