Posts LPSignal 的 Smart LP 排行:从 NFT 事件重建仓位与刷分修复
Post
Cancel

LPSignal 的 Smart LP 排行:从 NFT 事件重建仓位与刷分修复

  LPSignal 除了按池子发信号,还有一个 Smart LP 排行榜,按过去 30 天、90 天的 LP 相对持币收益排名。Pro 用户可以在 30 天榜前 20 名或者自己关注的钱包开出至少 1 万美元的仓位时收到推送。功能在 09-30 凌晨提交(41b2fed),随后修了 NFT 转让、重开仓位和评分写回的问题。10-01 又补了机器人过滤和性能优化。

分数怎么定义

  一个仓位关闭时,分数 = 它取回的全部代币(本金加手续费)减去存入的代币,两边都按关闭时刻的池子价格折成 token1,再换成美元(positionScore)。比较对象是「一开始就拿着存进去的那些币」,所以涨跌方向本身不加分,衡量的是做 LP 相对持币的那部分收益。

  一个钱包要上榜,窗口内至少要有 3 条可评分的关闭记录,资本合计至少 1 万美元,按 pnl 排名。这里的资本和 pnl 一样,按各条记录关闭时的池价、附近美元价格快照估值,不是开仓时投入美元数的简单相加。非 Pro 用户只能看到前 10 名,地址打码。

不调 RPC 重建仓位

  Uniswap v3 风格的仓位是 NonfungiblePositionManager(NPM)铸的 NFT。NPM 的 IncreaseLiquidity、DecreaseLiquidity、Collect 事件只带 tokenId,不带池子地址。要知道是哪个池子、哪个区间,标准做法是对每个 tokenId 调一次 positions()。

  仓位归属解析不用逐个调用 positions(),而是配对同一笔交易里先出现的池子事件和后出现的 NPM 事件,池子事件的 owner 必须是 NPM:

1
2
3
IncreaseLiquidity ← pool Mint
DecreaseLiquidity ← pool Burn
Collect           ← pool Collect

  src/ingest/positions.ts 把一笔交易里的日志按 logIndex 排好,记住每个 NPM 最近一次的池子 Mint/Burn/Collect,遇到 NPM 事件就和它配对。池子地址取自日志的 address,tickLower/tickUpper 取自 topics。配不上的就跳过。最后一次 collect 和销毁 NFT 在同一笔交易里时,交易完成后直接调 positions() 已经查不到,但日志仍可配对。代码注释记录了前六条链的验证;10-10 加入 Robinhood 后,也记录了它使用 Uniswap 的 NPM,样本中的 NPM 事件全部配对成功。

  Uniswap v4 不支持:它的事件不带手续费金额,没法打分。金额全为零的调用(比如对一个已经清空的 NFT 调 collect)直接忽略,不算事件(a3f0f03)。

第一版的问题:历史跟着 NFT 走

  第一版按 NFT 记账:一个 tokenId 一行,累加存入和取回,关闭时打分,分数记在当前持有人名下。问题在于 NFT 可以转让。

  最直接的刷法:A 用一个 NFT 赚了钱并平仓,把这个空 NFT 卖给 B,B 往里存一点再平掉,于是 B 继承了 A 的整段历史。修法(b355547)是把仓位拆成「生命周期」,逐个事件推进:

  • 已关闭的 NFT 上出现资金事件,就开一个新的生命周期,累加器清零,记在当时的持有人名下;后来的 83d2135 收紧为只有 Increase 能重开;
  • 一个生命周期只有以 Increase 开头(新 NFT 则必须紧跟在 mint 之后),存入才是完整的,才算「在窗口内开仓」;
  • 关闭时写一条 position_close,记下当时的 owner 和金额。之后 NFT 转让或重开不会把这条记录的历史转给新 owner;关闭 tick 和后续 Collect 补记由后面的提交加入。

  同一个提交里还定了两条:仓位质押进 gauge(Transfer 的目标是 gauge 地址)的生命周期不打分,排放收益无法从这些事件里算出;第一次见到时已经在半路的仓位也不打分,存入不完整。更早的 e5ecb7e 已把「NFT 当前持有人」和「被记分的 owner」拆成两个字段:在同一批里平仓再卖掉空 NFT,分数归卖方,买方只是持有人。

事件证明不了 NFT 里没钱

  下一个问题(e3e4a7f)更隐蔽。我判断关闭的条件是「流动性归零,并且取回的数量不小于减仓的数量」。但 Collect 取回的是本金加手续费。如果 A 在减仓之前先收过一次手续费,取回的数量可能在本金还没全部取出时就已经超过减仓数量,生命周期被判成关闭,NFT 里其实还留着 A 的本金。B 买下这个 NFT,开一个新生命周期,把 A 的余额收走,算成自己的收益。

  事件里没有欠款余额,无法据此确认一个「已关闭」的 NFT 已经清空。所以规则改成:由不同于上一个 owner 的持有人重开的生命周期不打分。同一个 owner 重开时,残留价值仍记在他自己的记录中。

  c74d5d4 处理了再次重开的情况:B 的第一次重开被标记后,再转给 C,或者 B 自己关仓再重开,仍可能带着原来的残留余额。mixed 标记会继承到 NFT 后续的所有生命周期,这些生命周期都不打分。

  关闭条件本身也收紧了(83d2135):只在 Collect 上判关闭,并且要求这时流动性已经是零。提前收的手续费不能让一笔还没 collect 的最后减仓看起来已经结清。关闭之后同一个 owner 再来的 collect(最后一点尾款)加到那条关闭记录上并重新打分;别人来 collect 就污染 NFT。关闭之后只有 Increase 能开新的生命周期。

按哪个价格估值

  原来打分用的是关闭所在小时的收盘 tick。这给了平仓的人一个操作空间:平仓之后、整点之前,在同一个池子里自己推一下价格,就能改变存入和取回两篮子币的相对价值。39727e0 改成按关闭那个事件发生时的池子 tick 估值。扫描器按日志顺序处理时,给每条池子 Mint/Burn/Collect 日志打上「这之前最后一笔 Swap 的 tick」(poolTick),配对时一起带进事件,关闭记录里存的就是它。如果这一批里该池子在这个事件之前还没有成交,就取库里它最后一个小时的收盘 tick,中间没有成交,那个收盘价就是事件发生时的价格。

并发写回

  打分是 worker 里的独立步骤:读一批未打分的关闭记录,算完写回。扫描器在这期间可能给同一条记录追加了一笔尾款。如果写回不加条件,就会把一个基于旧金额算出的分数写到新金额上。1ba8941 把写回条件改成「金额还是我打分时读到的那组」:

1
2
3
4
db.positionClose.updateMany({
  where: { id: r.id, pnlUsd: null, col0: r.col0, col1: r.col1, in0: r.in0, in1: r.in1 },
  data: { capitalUsd: s.capitalUsd, pnlUsd: s.pnlUsd },
});

  没写进去的留给下一轮。e5ecb7e 给仓位行上的估值和当时的评分写回加了 version 检查;改用关闭记录评分后,评分检查的是金额。2695c11 则在标记已推送时同时检查 version 和 lifecycle,并把去重键从(池子, 类型, 时间)改成 仓位id:lifecycle,让同一个块里往同一个池子开仓的多个钱包都能留下信号。如果扫描器在评估期间把 NFT 重开了,新的生命周期留给下一轮。

机器人和金库

  Smart LP 信号要等归档扫描到开仓才能发出,可能比开仓晚几十分钟。10-01 的 1b21e1c 增加了过滤,避免前 20 名推送主要来自短时持仓、频繁调仓的钱包或金库合约:

  • 用 eth_getCode 判断 owner 是不是合约,每条链每个 owner 查一次;
  • 任意一条链上日均关闭超过 10 次(BOT_MAX_CLOSES_PER_DAY),或者持仓中位数小于 1 小时(BOT_MIN_MEDIAN_HOLD_H),所有链的榜单上都排除。

  代码注释记录,这组阈值按 09-30 的分布定,191 个符合条件的钱包里排除了 46 个。过滤不影响用户自己关注的钱包的推送资格。同一个提交加了开仓的快速路径:每分钟给新仓位估值,读取榜单和关注列表比对,只有最近 3 小时内的开仓有资格触发,补历史数据时不会误发。

性能

  10-01 的性能修改主要涉及待处理仓位和市场数据查询。

  估值和打分原来会反复查询没有价格快照的池子的仓位,缺价格时无法完成计算。8ef29e3 的提交说明记录,这部分每 10 小时浪费约 390 万次查询。修改后只处理已有价格快照的池子,池子取得第一份快照后,它的仓位会重新进入处理范围。分页使用待处理行的部分索引(position_pending_value_idx、position_close_pending_score_idx)。索引由手写 SQL 建立,Prisma 的 schema diff 看不到它们,CLAUDE.md 里专门注明了。

  查「最近的开仓」那条查询会扫遍所有仓位,最慢一次 12.6 秒,加了一个部分索引。估值和打分原来每行 3 次查询加 1 次写入,改成一页数据一次查询(marketsAt,用 unnest 加 LATERAL 一次查出每行对应的池子价格和前后两个美元价格快照),一次批量写回(2baccc5)。

  Smart LP 的整套流程(估值、打分、排名、推送)原来挂在小时流水线里,一条链积压会拖住整条流水线和其他链。6bdfb1a 把它拆成每条链自己的循环,在当小时 TVL 快照之后运行。

  最后是数据保留(87b4e24):超过 100 天的已关闭历史(关闭记录、闲置的已关闭仓位)每天清理一次。删除用 SKIP LOCKED,并且在被删的行上重新检查条件,扫描器正持有或者已经重开的仓位不会被删,开着的仓位从不删。

测试

  test/positions.test.ts 里有几条以 ATTACK 开头的用例:A 盈利平仓后把 NFT 卖给 B,B 小额重开不能继承历史;提前收手续费造成提前判关闭,买方收走残留也不能计分;重开两次后仍保留 mixed 标记。这几条测的是纯函数 applyEvents。test/smartlp.int.test.ts 另有数据库集成测试,覆盖按关闭 tick 评分,以及评分期间追加 Collect 后旧分数不能写回。


LPSignal 工程笔记(1/4)

下一篇:LPSignal:给集中流动性池算扣除无常损失的 APR

This post is licensed under CC BY 4.0 by the author.

把账本回填到 08-14:已知的坏形状、512MB 字符串上限和一张只写不删的副本

pmwallets 的成交推送:延迟锚点、LISTEN/NOTIFY 和一条钉住 xmin 的等锁语句