Featured image of post 一个372倍的价格,把7天模拟交易的净利从-376美元刷成了+74万

一个372倍的价格,把7天模拟交易的净利从-376美元刷成了+74万

HyperEVM MEV纸面交易模拟器跑了10分钟、267笔,真实净利−$376.26,报告器却输出+$741,670.47。差额来自2笔把88.71的市价当成33000.11的伪造成交;直接成因是均值回归策略的出口绑在下一笔成交上,以及缺失的价格合理性校验。

模拟器:2026-09-28T16:55:54Z → 17:05:43Z,267 笔交易,真实净利 −$376.26。报告器输出的净利:$+741,670.47。 差额来自 2 笔交易,来自 1 个被解析错的池价,和真实市价差 372 倍。 直接成因两条:均值回归策略的出口绑在「下一笔成交」上(59% 同块进出),以及整条链路上没有任何一道 px 合理性校验。 同一份代码里还有第三个缺陷:WS 订阅字段名写错,行情修订从未抵达——但它不是这个 74 万的成因(价格经 REST 兜底一直有效),只是让 mid 始终停留在慢通道上。 全部事实点在本轮对现场文件复核过,复核命令与实测值见文末附录,可重跑。


1. 选题

主标题

一个 372 倍的价格,把 7 天模拟交易的净利从 -376 美元刷成了 +74 万

(42 字符,≤70 达标。数字具象型 + 反差型;结果在前,成因在后。)

description

HyperEVM MEV 纸面交易模拟器跑了 10 分钟、267 笔,真实净利 −$376.26,报告器却输出 +$741,670.47。差额来自 2 笔把 88.71 的市价当成 33,000.11 的伪造成交;直接成因是均值回归策略的出口绑在「下一笔成交」上,以及缺失的价格合理性校验。同代码另有一个不影响该数字的缺陷:WS 订阅字段名写错,行情修订从未抵达。

体裁:深度分析 / 拆解(以「报告数字离谱 → 逐层定位 → 2 条直接成因 + 1 条无关缺陷」推进)

为什么这个选题值得写

近 7 天本机的高信息密度变更里,这条最符合「具体到能复现」的标准:

  • 有可引用的现场数字(267 笔、−$376.26、+$741,670.47、372 倍)
  • 有明确的代码位置(paper.js:46 / 55 / 56 / simS2)
  • 有可重跑的验证命令(见附录)
  • 主题是「仪表盘数字骗人」这类普遍存在的工程问题,不依赖 MEV 领域知识也能读懂

与前几轮 content 产出刻意错开

轮次主题误判类型
content-20260927-211550账号身份误判实体归属
content-20260928-004757mmtls 诊断方向误判逆向方向
content-20260928-020756fleet 调度闸门自身的三个 bug调度基础设施
content-20260928-021448微信图片库物理层复核数据资产完整性
本轮模拟交易报告器的数字自欺度量层 / 指标可信度

前几轮都在回答「这个东西到底是不是它声称的那个东西」;本轮回答的是「这个数字到底是不是它声称的那个数字」。度量层可信度是本系列尚未覆盖的角度。


2. 正文草稿

报告说赚了 74 万,真实是亏了 376 块

先看两个数字,它们出自同一批数据、同一分钟:

1
2
3
$ node report.js          # 对本文的冻结快照(267 笔)运行
=== 7天模拟报告(0.02天数据,267笔) ===
总体: { n: 267, winPct: '29.6', net: 741670.47, profitFactor: '1457.30' }
1
2
3
4
# 剔除 |net| > $1000 的两笔后
n=265  net=$-376.26
S1 n=111 win=67.6% gross=$412.71 fees=$408.80 net=$3.90
S2 n=154 win=1.3% gross=$14.23 fees=$394.40 net=$-380.17

同一个 trades.jsonl,同一时刻读。一个说净赚 74 万、盈亏比 1450;另一个说净亏 376 块、S2 策略胜率 1.3%。

这不是四舍五入的差别,是符号的差别。

同一批数据的两种读法
同一批数据:报告值与剔除两笔后的真实值,符号相反

这是榜单上唯一一个「模拟器声称自己赢了、其实是全输」的案例,而且它本来会被自动发进日报。

那 74 万从哪来:两笔把 88 块当成 3 万 3 的成交

把 267 条记录按净利绝对值排序,前两名:

1
2
3
4
5
6
7
{"ts":"2026-09-28T17:05:24.743Z","strat":"S2","pool":"prjxA","blk":47131232,
 "side":"sell","entry":33000.10650784375,"exit":88.65938380111707,"holdBlks":7,
 "notional":1000,"gross":371212.225,"fees":1.1,"net":371211.125}

{"ts":"2026-09-28T17:05:06.811Z","strat":"S2","pool":"prjxA","blk":47131225,
 "side":"buy","entry":88.74891975134871,"exit":33000.10650784375,"holdBlks":10,
 "notional":1000,"gross":370836.7119,"fees":1.1,"net":370835.6119}

两条的 entry / exit 里都出现了同一个数字:33000.10650784375。

全部267笔交易的单笔净利分布
267笔交易单笔净利分布(对称对数轴):两笔离群值贡献了全部利润

同一时间窗内 HYPE 的实时市价是多少?直接查 Hyperliquid 的 REST 接口:

1
2
3
4
5
$ node -e "fetch('https://api.hyperliquid.xyz/info',{method:'POST',
    headers:{'Content-Type':'application/json'},
    body:JSON.stringify({type:'allMids'})}).then(r=>r.json())
    .then(j=>console.log('@107 =', j['@107']))"
@107 = 88.713

33000.1065 ÷ 88.713 = 372.0。 这个价格不是市场给的,是解析器算错的。

这两笔伪造成交贡献了多少?fake gross sum = $742,048.94。全表净利 +$741,670.47。去掉这两笔,剩下 265 笔合计 −$376.26。

也就是说:整个「盈利 74 万」的结论,100% 由这 2 笔制造,且方向完全相反。

缺陷一(与 74 万无因果):WS 订阅字段名写错,行情修订从未抵达

先讲一个不是成因的缺陷,因为它最容易被误当成成因。

paper.js 的 connectWs() 里,订阅语句是这样写的(paper.js:46):

1
ws.send(JSON.stringify({ method: 'subscribe', subscription: { type: 'orderBook', coin } }));

而实际连上去发同一条消息,Hyperliquid 直接退回错误:

1
2
channel=error data="Error parsing JSON into valid websocket request:
{\"method\":\"subscribe\",\"subscription\":{\"type\":\"orderBook\",\"coin\":\"@107\"}}"

把 orderBook 换成 l2Book,立刻有数据:

1
2
$ node wsprobe4.mjs
BID=88.724 ASK=88.739 mid=88.73150000000001

哪怕订阅名改对了,下一层还是错的——paper.js:55-56 的处理逻辑是:

1
2
if (j.channel === 'orderBook' && j.data?.levels) {
  const bid = Number(j.data.levels.b?.[0]?.px), ask = Number(j.data.levels.a?.[0]?.px);

真实 l2Book 消息里,levels 是 [bids, asks] 两个数组(每条是 {px, sz, n} 对象数组),不是 {b, a} 对象。所以修复不是改一个字段名,而是订阅名 + 解析结构两处。

后果:WebSocket 通道全程零有效数据,state.obMid 永远拿不到值。

边界:价格来源其实没断,断的是「显示」

这里我要先否掉一个看起来更严重的假设。

看到心跳日志里连续 9 条 mid=?,第一反应是「价格输入断了,策略在裸奔」。但往下查,这个结论只对一半。

paper.js 的 pollLoop() 里有兜底:mid 为空时调用 REST allMids()。而 REST 通道是通的:

1
2
3
type=object len=1119
has @107: true | has @207: true
value @107: 88.713 | @207: 88.7195

那么 mid=? 从哪来?出在打印上,不在策略上。allMids 返回的 @107 是字符串 "88.713",而心跳打印表达式写的是 state.obMid?.toFixed?.(4) ?? '?':

1
2
"88.713" typeof string => display ?
88.713  typeof number => display 88.7130

字符串没有 toFixed 方法,可选链短路成 ?。每一条 mid=? 的心跳,实际上都拿着一个有效市价,只是没能打出来。

这个区分很重要:如果结论停在「价格断了」,修错地方;真实情况是策略一直有价可用,WS 修订更新这条慢通道只是没生效。「日志里看不到」不等于「数据不存在」——这是本次最容易被误判的一点。

成因一:S2 的出口选错,59% 的交易同块进出

价格有了,那 33000 这种离谱值是怎么进到成交记录里的?看设均值回归策略的 simS2。

它的设计意图是:池价偏离 mid 超过阈值就入场,等价格回归后离场。但离场条件写成了「同池的下一笔成交」——不管中间隔了多久、价格回了没回。

统计 holdBlks 分布:

1
2
S2 holdBlks==0: 92/156 = 59%     ← 中位持仓 0 块
S2 holdBlks 全分布: mean=10.65  median=0  max=137

156 笔 S2 交易里,92 笔在同一区块内开仓又平仓,持仓块数的中位数是 0。 但均值是 10.65、最长一笔扛了 137 个区块——这是个双峰分布:要么立刻出,要么拖很久。没有一档是策略原本设想的「等价格回归」的那个时间尺度。

这不是均值回归,是把「两个相邻 swap」当成了一次「回归」。

后果是费用被反复摩擦:

1
S2 holdBlks==0: net=$-203.74 gross=$-2.54 fees=$201.20

同块组的毛利是 −$2.54(意味着连方向都没对),费用却照收 $201.20。费用项 size*(feePct/100)*2 + gasUsd*2 与持仓时长无关,同块进出照样付两次 AMM 费 + 两次 gas。毛利接近零乘以高频,等于把费用乘了几百遍。

而那个 33000 的价格,正因为出口选在「deviation 超阈的下一笔成交」上,完全不受任何合理性约束——一个解析异常值可以和任意正常的"下一笔"配对成一次「回归」。

成因二:没有合理性校验,所以 372 倍的价差被当成套利机会

回到最上游。池价 px 来自 eth_getLogs 拉回的 Uniswap V3 Swap 事件,按 a0/a1 的符号与 decimals 还原:

1
if (a0 < 0n && a1 > 0n) { px = (Number(a1)/D1) / (Number(-a0)/D0); notional = Number(a1)/D1; }

这条路径上没有任何一步校验 px 是否落在合理区间。Number(a1)/D1 算出来的值,不代表池子的真实价格:Uniswap V3 的 Swap 事件只带 amount0 / amount1 / sqrtPriceX96 / liquidity / tick,价格应当从 sqrtPriceX96 还原,而代码直接拿转账量做除法——只有在这笔 swap 恰好不动价格时,两者才相等。偏离越大,|px/mid-1| 越大,越容易同时触发 S1 的套利判定和 S2 的入场。异常值不是被过滤掉,而是被当成「最大的机会」优先交易。

至于那个 33000.10650784375 具体是怎么算出来的,我没能定位到根因:eth_getLogs 对该区块的历史查询返回空(区块 47131225 与 47131232 我去拉了,但 RPC 没给我原始 data),无法逐字段复算。 这里只陈述已经验实的两件事:一是这个价格与同时刻真实市价差 372 倍,二是在生产它的那段代码里,不存在任何一道会拦住它的检查。机制待定,缺口确凿。

把这三节收拢一下。真正造成那 74 万的,只有后两条:

  1. S2 出口绑「下一笔成交」→ 任意异常值都能凑成一次「回归」(直接成因)
  2. 无 px 合理性校验 → 异常值被当成套利机会入场(直接成因)
  3. WS 修订没生效 → mid 只从 REST 慢通道取(真实缺陷,但与本例无因果:mid 一直是有效的 88.7)

换句话说:就算现在就修好 WS 订阅,那个 33000 的价格照样会入场、照样会被记成一笔 37 万的盈利。 挡不住它的是第 2 条,不是第 3 条。

这个数字本来会去哪里

如果这条流水线按原计划跑完 7 天,mev_report.py 会这样播报:

1
2
title = f"MEV模拟 {span_days:.1f}天:{len(trades)}笔 胜率{win_pct:.1f}% 净${net:+.0f}"
level = "info" if net >= 0 else "warn"

代入本次数据:

1
2
title would be: MEV模拟 X天:267笔 胜率29.6% 净$+741670
level assigned: info   <-- positive net => info (no warning flag)

一个净亏的模拟,会以「净$+741670」的标题、info 级别,进入日报收件箱。 级别判定只看 net >= 0,不看盈亏比是否荒唐、单笔是否占了全表净利的 100%。

这里有个值得记住的教训:当上游指标本身失真时,基于该指标的健康分级会反向工作——赚得越假,级别越安静。profitFactor: 1457.30 本该是个刺眼的信号,但没有任何一行代码检查它。

改动优先级(尚未实施)

按「堵住本次漏口 → 修复并存缺陷 → 加固下游报告」排序,都是小改动:

  1. 给 px 加区间校验(最小改动、最高收益)。在 pollLoop 还原 px 后加一道 Math.abs(px/mid - 1) > 0.05 直接丢弃;任何偏离 5% 的池价,在这个交易对上都只可能是解析错误。
  2. 修 WS 订阅与解析。type 从 orderBook 改 l2Book;levels 从 .b/.a 改成数组下标 [0]/[1],元素取 .px。
  3. S2 出口加时间约束。移除或收紧 holdBlks==0 的同块平仓——同块进出在均值回归语义下不成立。
  4. 报告器加健全性断言。在 mev_report.py 加一条:单笔 |net| 超过全表 |net| 中位数的 N 倍就当异常值剔除或标红;profitFactor > 100 直接升级为 warn。

收束

回到最早的那个发现。

跑这条模拟的初衷,是用真金白银之前的零成本环境验证「S1 taker 搬砖 / S2 均值回归」两个策略到底能不能赚钱。第一轮 10 分钟的数据已经给出了答案,而且不是模拟器播报的那个答案:

1
2
S1 n=111 win=67.6% net=$3.90     ← 扣完费,基本打平
S2 n=154 win=1.3%  net=$-380.17  ← 真金白银会亏掉本金的 38%

S1 胜率高、净利接近零,说明「偏离阈值 0.05%」对 prjxA/nest 这两个 0.05% 费率的池子刚好在盈亏平衡线上;S2 则是负期望,费用项吃掉了一切。

如果没有在报告器上多看一眼,这本会是一篇「7 天模拟净赚 74 万」的结论——发布出去,然后在下一次真实交易里被市场纠正。7 天模拟的全部价值,都押在「报告数字是真的」这一条上;而这条恰恰是整条流水线里唯一没被校验过的地方。

附录:事实点复核记录

复核时间:2026-09-29 01:00–01:10 CST 复核对象:/home/li/mev-monitor/(非 git 仓库,现场文件) 重要前提:mev-paper.service 是常驻进程(systemd Restart=always),trades.jsonl 在我写稿期间持续增长。本文所有数字锁定在一份冻结快照上,快照文件 /tmp/mev_final.jsonl(267 笔,末笔时间 2026-09-28T17:05:43.160Z)。复跑时数字会变,但结构性结论不变——下面每条都给验证方法。

#事实点实测值来源 / 复核命令
1服务在跑、且是常驻active (running) since 2026-09-29 00:54:34 CST;ExecStart=/home/li/.local/bin/node paper.js;Restart=alwayssystemctl --user status mev-paper;cat mev-paper.service
2快照规模与时间窗267 笔,16:55:54.244Z → 17:05:43.160Z(9 分 49 秒,600 区块 ≈ 0.98 s/块)python3 -c "import json;...;print(min/max ts, max-min blk)"
3报告器输出净利(对冻结快照跑)n: 267,net: 741670.47,profitFactor: '1457.30',winPct: '29.6'把 267 行冻结集复制为 trades.jsonl 后跑 node report.js
4真实净利(剔除 2 笔离群)−$376.26(265 笔)过滤 abs(net)>1000 后求和
52 笔伪造成交的 gross 合计$742,048.94取 gross > 1000 两条求和
6伪造价格与真实市价之比33000.10650784375 / 88.713 = 372.0node -e "fetch(info,allMids)" 取 @107
7REST allMids 通道正常has @107: true;value @107: 88.713同上;注意返回是字符串
8WS 订阅字段名错误channel=error ... "type\":\"orderBook\"...发 {type:'orderBook',coin:'@107'} 给 wss://api.hyperliquid.xyz/ws
9正确订阅名可拿到盘口BID=88.724 ASK=88.739 mid=88.7315同上,改发 {type:'l2Book'}
10WS 处理层结构不匹配代码读 levels.b[0].px;真实 l2Book 为 levels[bids[],asks[]],元素 {px,sz,n}paper.js:55-56;wsprobe4.mjs 打印 keys
11mid=? 是显示 bug 非输入断供"88.713".toFixed?.(4) ?? '?' → ?;88.713.toFixed(4) → 88.7130node -e 复现。判据用「有没有打出过数字」而非计数:grep -cE 'mid=[0-9]' paper-stdout.log = 0(全程无一条心跳打出过数值);grep -c 'mid=?' 随进程增长,写作时 9、复核时 25
12S2 同块进出占比与持仓分布holdBlks==0:92/156 = 59%;全分布 mean=10.65 median=0 max=137按 holdBlks 分组计数/统计
13同块组毛利与费用gross −$2.54 / fees $201.20 / net −$203.74同上分组求和
14分策略真实盈亏S1: n=111 win=67.6% net=+$3.90;S2: n=154 win=1.3% net=−$380.17剔除离群后按 strat 分组
15费率配置(解释池间差异)prjxA/nest = 0.05%,kitt/prjxB = 0.30%;obTakerFeePct=0.045config.json
16日报会播报什么title = "MEV模拟 X天:267笔 胜率29.6% 净$+741670",level = "info"mev_report.py 第 4 步代入
17该报告已注册进调度mev_report → /usr/bin/python3 /home/li/mev-monitor/mev_report.py,flock=/tmp/cron-mev-report.lockgit show 4ed2477 -- scripts/windmill_registry.json

一处自我纠错(写进正文第 4 节)

初查时看到 9 条心跳全是 mid=?,第一反应是「WS 断了、策略没有价格输入」。往下追发现只对一半:WS 修订通道确实从未生效(#8、#10),但 pollLoop 有 REST allMids 兜底且该通道正常(#7);mid=? 是打印层的字符串 toFixed 调用失败(#11),不是数据缺失。结论修正为「慢通道可用、修订通道失效、日志显示失真」三者并存。

未复核/存疑

  • 33000.10650784375 的精确成因未定位。本轮试拉了 blk 47131225/47131232 的 prjxA 池 eth_getLogs,RPC 返回空(未能取回原始 data),因此无法逐字段复算。文中正文第 6 节只陈述两件已验实的事:该价格与同时刻真实市价差 372 倍;生产它的代码段里没有任何合理性检查。机制待定,缺口确凿——不写成已验证结论。
  • 由此产生的连带不确定性:上述两笔的 gross(合计 $742,048.94)是错误输入的算术结果,而非真实的交易机会。文章对「74 万从哪来」的归因到「2 笔离群 + 1 个错价」为止,不再往下推测具体解析路径。
  • paper.js 启动时 stderr 有 3 行 /usr/bin/env: 'node': No such file or directory,而当前服务运行正常、/usr/bin/node 不存在。可能是历史上曾用 shebang/env node 方式启动留下;未影响当前运行,未深究。
  • 快照仅 267 笔 / 10 分钟,任何「策略长期期望」的结论都不成立。文中所有策略评估均限定在这个窗口内。

复跑方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1) 抓一份快照(避免与常驻进程竞争读写)
cp /home/li/mev-monitor/trades.jsonl /tmp/snap.jsonl

# 2) 离群检测:看是否有 gross 远超其余记录的交易
python3 -c "
import json
rows=[json.loads(l) for l in open('/tmp/snap.jsonl') if l.strip()]
for r in sorted(rows,key=lambda r:-abs(r['gross']))[:3]: print(r)
print('net(all)=\$%.2f'%sum(r['net'] for r in rows))
print('net(clean)=\$%.2f'%sum(r['net'] for r in rows if abs(r['net'])<=1000))
print('S2 holdBlks==0: %d/%d'%(sum(1 for r in rows if r['strat']=='S2' and r.get('holdBlks')==0),
                                sum(1 for r in rows if r['strat']=='S2')))
"

# 3) 验 WS 订阅名(需要 node ≥18 自带 WebSocket)
node -e "const w=new WebSocket('wss://api.hyperliquid.xyz/ws');
w.onopen=()=>w.send(JSON.stringify({method:'subscribe',subscription:{type:'l2Book',coin:'@107'}}));
w.onmessage=e=>{const j=JSON.parse(e.data);if(j.data&&j.data.levels){console.log(j.data.levels[0][0].px,j.data.levels[1][0].px);w.close()}}"

# 4) 验 REST 市价(注意返回是字符串)
node -e "fetch('https://api.hyperliquid.xyz/info',{method:'POST',headers:{'Content-Type':'application/json'},
  body:JSON.stringify({type:'allMids'})}).then(r=>r.json()).then(j=>console.log(typeof j['@107'], j['@107']))"