Featured image of post A 372x Price Turned 7-Day Paper Trading's Net From -$376 Into +$741K

A 372x Price Turned 7-Day Paper Trading's Net From -$376 Into +$741K

A HyperEVM MEV paper-trading simulator ran for 10 minutes over 267 trades with a real net of −$376.26, yet its reporter printed +$741,670.47. The delta comes from 2 fake fills that treated a market price of 88.71 as 33,000.11; the direct causes are the mean-reversion strategy's exit being tied to the next fill, and the absence of any price sanity check.

Simulator: 2026-09-28T16:55:54Z → 17:05:43Z, 267 trades, real net −$376.26. Reporter-printed net: $+741,670.47. The delta comes from 2 trades, from 1 mis-parsed pool price that differs from the real market price by 372x. Two direct causes: the mean-reversion strategy’s exit is tied to “the next fill” (59% enter and exit in the same block), and there is not a single px sanity check anywhere along the pipeline. The same code also contains a third defect: the WS subscription field name is spelled wrong, so quote revisions never arrive — but it is not the cause of the 741k (prices stayed valid via the REST fallback); it merely leaves mid permanently on the slow channel. Every fact point was re-checked against the live files this round; the verification commands and measured values are in the appendix at the end and can be re-run.


1. Topic Selection

Main title

A 372x price turned 7-day paper trading’s net from -$376 into +741k

(42 characters, ≤70, passes. Concrete-number type + contrast type; result first, cause after.)

description

A HyperEVM MEV paper-trading simulator ran for 10 minutes over 267 trades with a real net of −$376.26, yet its reporter printed +$741,670.47. The delta comes from 2 fake fills that treated a market price of 88.71 as 33,000.11; the direct causes are the mean-reversion strategy’s exit being tied to “the next fill”, and the absence of any price sanity check. The same code has another defect that does not affect that number: the WS subscription field name is spelled wrong, so quote revisions never arrive.

Genre: deep analysis / breakdown (progressing as “absurd reported number → layer-by-layer localization → 2 direct causes + 1 unrelated defect”)

Why this topic is worth writing

Among the high-information-density changes on this machine in the last 7 days, this one best meets the “concrete enough to reproduce” bar:

  • It has citable on-site numbers (267 trades, −$376.26, +$741,670.47, 372x)
  • It has explicit code locations (paper.js:46 / 55 / 56 / simS2)
  • It has re-runnable verification commands (see appendix)
  • Its theme — “dashboard numbers lie” — is a generic engineering problem understandable without MEV domain knowledge

Deliberately staggered from the previous rounds of content output

RoundTopicMisjudgment type
content-20260927-211550Account identity misjudgmentEntity attribution
content-20260928-004757mmtls diagnostic direction misjudgmentReverse-engineering direction
content-20260928-020756Three bugs in the fleet scheduling gate itselfScheduling infrastructure
content-20260928-021448Physical-layer re-check of the WeChat image libraryData asset integrity
This roundNumerical self-deception in a paper-trading reporterMeasurement layer / metric credibility

The earlier rounds all answered “is this thing actually the thing it claims to be”; this round answers “is this number actually the number it claims to be”. Measurement-layer credibility is an angle this series has not yet covered.


2. Draft Body

The report says it made 741k; the reality is it lost 376

Start with two numbers, both from the same batch of data, the same minute:

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

Same trades.jsonl, read at the same moment. One says a net gain of 741k and a profit factor of 1450; the other says a net loss of 376 and an S2 strategy win rate of 1.3%.

This is not a rounding difference — it is a sign difference.

Two readings of the same batch of data
Same data: reported value vs. the real value after removing two trades — opposite signs

This is the only case on the board of “a simulator claiming it won while actually losing everything”, and it would have been auto-sent into the daily report.

Where the 741k came from: two trades that treated 88 as 33,000

Sort the 267 records by absolute net, top two:

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}

Both trades’ entry / exit contain the same number: 33000.10650784375.

Per-trade net distribution of all 267 trades
Per-trade net distribution of 267 trades (symmetric log axis): two outliers contribute all the profit

What was HYPE’s live market price during that same window? Query Hyperliquid’s REST endpoint directly:

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. This price did not come from the market; the parser computed it wrong.

How much did these two fake fills contribute? fake gross sum = $742,048.94. Full-table net +$741,670.47. Remove these two and the remaining 265 sum to −$376.26.

In other words: the entire “profit of 741k” conclusion is 100% manufactured by these 2 trades, and the direction is exactly opposite.

Start with a defect that is not a cause, because it is the easiest one to mistake for a cause.

In paper.js’s connectWs(), the subscribe statement reads (paper.js:46):

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

But when the same message is actually sent over a connection, Hyperliquid rejects it outright:

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

Change orderBook to l2Book and data appears immediately:

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

Even if the subscription name is corrected, the next layer is still wrong — the handling logic at paper.js:55-56 is:

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);

In a real l2Book message, levels is two arrays [bids, asks] (each an array of {px, sz, n} objects), not a {b, a} object. So the fix is not one field name — it is two places: the subscription name and the parse structure.

Consequence: the WebSocket channel delivers zero valid data for its entire lifetime, and state.obMid never gets a value.

The boundary: the price source isn’t actually broken; the display is

Here I want to first rule out a hypothesis that looks more severe.

Seeing 9 consecutive mid=? in the heartbeat log, the first reaction is “the price feed is dead and the strategy is running naked”. But looking further down, that conclusion is only half right.

paper.js’s pollLoop() has a fallback: when mid is empty it calls the REST allMids(). And the REST channel works:

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

So where does mid=? come from? It comes from printing, not the strategy. The @107 returned by allMids is a string "88.713", while the heartbeat print expression is written as state.obMid?.toFixed?.(4) ?? '?':

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

A string has no toFixed method, and the optional chaining short-circuits to ?. Every heartbeat that logged mid=? was in fact holding a valid market price — it just failed to print it.

This distinction matters: if the conclusion stops at “the price is dead”, you fix the wrong thing; the reality is the strategy always had a usable price, and the WS-revision update of that slow channel simply never took effect. “Invisible in the logs” is not “the data doesn’t exist” — this is the single most easily misjudged point in this case.

Cause one: S2’s exit is chosen wrong; 59% of trades enter and exit in the same block

With the price present, how does an absurd value like 33000 get into the trade records? Look at the mean-reversion strategy’s simS2.

Its design intent is: enter when the pool price deviates from mid past a threshold, exit after the price reverts. But the exit condition was written as “the next fill in the same pool” — regardless of how much time passed or whether the price reverted at all.

Tabulating the holdBlks distribution:

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

Of 156 S2 trades, 92 open and close within the same block, and the median holding duration is 0 blocks. But the mean is 10.65 and the longest held 137 blocks — a bimodal distribution: either it exits immediately, or it drags on for a long time. Neither mode is the timescale the strategy originally envisioned for “waiting for reversion”.

This is not mean reversion; it treats “two adjacent swaps” as one “reversion”.

The consequence is fees being rubbed over and over:

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

The same-block group’s gross profit is −$2.54 (meaning even the direction was wrong), yet it still charges $201.20 in fees. The fee term size*(feePct/100)*2 + gasUsd*2 is unrelated to holding duration, so same-block entry/exit still pays two AMM fees + two gas charges. Gross profit near zero multiplied by high frequency equals multiplying the fees hundreds of times.

And that 33000 price, precisely because the exit is chosen on “the next fill after deviation crosses the threshold”, is completely unconstrained by any sanity check — a parse outlier can pair with any normal “next fill” into one “reversion”.

Cause two: no sanity check, so a 372x price gap is treated as an arbitrage opportunity

Back to the top of the pipeline. The pool price px comes from the Uniswap V3 Swap events pulled back by eth_getLogs, reconstructed from the signs of a0/a1 and the decimals:

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

Along this path not a single step checks whether px falls in a reasonable range. The value computed as Number(a1)/D1 does not represent the pool’s true price: a Uniswap V3 Swap event carries only amount0 / amount1 / sqrtPriceX96 / liquidity / tick, and the price should be reconstructed from sqrtPriceX96, whereas the code divides the transfer amounts directly — the two are equal only when the swap happens not to move the price. The larger the deviation, the larger |px/mid-1|, and the more likely it is to trigger both S1’s arbitrage test and S2’s entry. The outlier is not filtered out — it is prioritized for trading as “the biggest opportunity”.

As for exactly how that 33000.10650784375 was computed, I was not able to localize the root cause: eth_getLogs returned empty for that block’s historical query (I did pull blocks 47131225 and 47131232, but the RPC did not give me the raw data), so I cannot recompute field by field. Here I state only the two things that are verified: first, this price differs from the real market price at the same moment by 372x; second, in the code segment that produced it, there does not exist a single check that would stop it. Mechanism undetermined, gap confirmed.

Let me pull these three sections together. What actually caused the 741k is only the latter two:

  1. S2’s exit tied to “the next fill” → any outlier can be assembled into a “reversion” (direct cause)
  2. No px sanity check → the outlier enters as an arbitrage opportunity (direct cause)
  3. WS revisions never took effect → mid only comes from the REST slow channel (a real defect, but with no causal link to this case: mid was always the valid 88.7)

Put another way: even if the WS subscription were fixed right now, that 33000 price would still enter and still be recorded as a 370k profit. What stops it is item 2, not item 3.

Where this number was headed

If this pipeline had run its full planned 7 days, mev_report.py would have announced:

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

Substituting this round’s data:

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

A net-loss simulation would enter the daily-report inbox with the title “净$+741670” at info level. The level decision only looks at net >= 0; it does not look at whether the profit factor is absurd or whether one trade accounts for 100% of the full-table net.

There is a lesson worth remembering here: when the upstream metric itself is distorted, health grading built on that metric works in reverse — the more fake the gain, the quieter the level. profitFactor: 1457.30 should have been a glaring signal, but not a single line of code checks it.

Change priorities (not yet implemented)

Ordered as “plug this leak → fix co-existing defects → harden the downstream report”, all are small changes:

  1. Add a range check for px (smallest change, highest payoff). After pollLoop reconstructs px, add a Math.abs(px/mid - 1) > 0.05 that discards directly; any pool price deviating 5% on this trading pair can only be a parse error.
  2. Fix the WS subscription and parsing. Change type from orderBook to l2Book; change levels from .b/.a to array indices [0]/[1], taking .px from the elements.
  3. Add a time constraint to S2’s exit. Remove or tighten the same-block close at holdBlks==0 — same-block entry/exit does not hold under mean-reversion semantics.
  4. Add sanity assertions to the reporter. In mev_report.py add: when a single trade’s |net| exceeds N times the median of the full-table |net|, treat it as an outlier and discard or flag it in red; profitFactor > 100 escalates directly to warn.

Wrap-up

Back to the earliest finding.

The whole point of running this simulation was to validate, in a zero-cost environment before real money, whether the two strategies “S1 taker arbitrage / S2 mean reversion” can actually make money. The first round’s 10 minutes already gave the answer, and it is not the answer the simulator announced:

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

S1’s high win rate and near-zero net show that “deviation threshold 0.05%” sits right on the break-even line for the two pools prjxA/nest at 0.05% fee; S2 is negative expectancy, with the fee term eating everything.

Without that extra look at the reporter, this would have been a conclusion of “7-day simulation nets 741k” — published, and then corrected by the market in the next real trade. The entire value of the 7-day simulation rides on the one condition “the reported number is real”; and that is exactly the only thing in the whole pipeline that was never verified.

Appendix: Fact-point verification record

Verification time: 2026-09-29 01:00–01:10 CST Verification target: /home/li/mev-monitor/ (not a git repo; live files) Important premise: mev-paper.service is a long-running process (systemd Restart=always), and trades.jsonl kept growing while I was writing. All numbers in this article are locked to a frozen snapshot, snapshot file /tmp/mev_final.jsonl (267 trades, last trade time 2026-09-28T17:05:43.160Z). Numbers will change on a re-run, but the structural conclusions do not — the verification method is given for each item below.

#Fact pointMeasured valueSource / verification command
1Service is running, and is long-runningactive (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
2Snapshot size and time window267 trades,16:55:54.244Z → 17:05:43.160Z(9 min 49 s, 600 blocks ≈ 0.98 s/block)python3 -c "import json;...;print(min/max ts, max-min blk)"
3Reporter-printed net (run against the frozen snapshot)n: 267,net: 741670.47,profitFactor: '1457.30',winPct: '29.6'Copy the 267 frozen lines as trades.jsonl, then run node report.js
4Real net (after removing 2 outliers)−$376.26(265 trades)Filter abs(net)>1000 then sum
5Combined gross of the 2 fake fills$742,048.94Take the two rows with gross > 1000 and sum
6Ratio of fake price to real market price33000.10650784375 / 88.713 = 372.0node -e "fetch(info,allMids)" take @107
7REST allMids channel is normalhas @107: true;value @107: 88.713Same as above; note the return is a string
8WS subscription field name is wrongchannel=error ... "type\":\"orderBook\"...Send {type:'orderBook',coin:'@107'} to wss://api.hyperliquid.xyz/ws
9The correct subscription name yields an order bookBID=88.724 ASK=88.739 mid=88.7315Same as above, change to {type:'l2Book'}
10WS handling layer structure mismatchCode reads levels.b[0].px; real l2Book is levels[bids[],asks[]], elements {px,sz,n}paper.js:55-56;wsprobe4.mjs prints keys
11mid=? is a display bug, not an input failure"88.713".toFixed?.(4) ?? '?' → ?;88.713.toFixed(4) → 88.7130Reproduce with node -e. The criterion is “was a number ever printed” rather than a count: grep -cE 'mid=[0-9]' paper-stdout.log = 0 (not one heartbeat ever printed a numeric value); grep -c 'mid=?' grows with the process — 9 while writing, 25 at verification
12S2 same-block entry/exit share and holding distributionholdBlks==0: 92/156 = 59%; full distribution mean=10.65 median=0 max=137Group and count/stat by holdBlks
13Same-block group gross and feesgross −$2.54 / fees $201.20 / net −$203.74Same grouping, summed
14Real P&L by strategyS1: n=111 win=67.6% net=+$3.90;S2: n=154 win=1.3% net=−$380.17After removing outliers, group by strat
15Fee config (explains the difference between pools)prjxA/nest = 0.05%,kitt/prjxB = 0.30%;obTakerFeePct=0.045config.json
16What the daily report would announcetitle = "MEV模拟 X天:267笔 胜率29.6% 净$+741670",level = "info"Substitute into step 4 of mev_report.py
17That report is already registered in the schedulermev_report → /usr/bin/python3 /home/li/mev-monitor/mev_report.py,flock=/tmp/cron-mev-report.lockgit show 4ed2477 -- scripts/windmill_registry.json

One self-correction (written into section 4 of the body)

On first inspection, 9 heartbeats all showing mid=? prompted the reflex conclusion “WS is down, the strategy has no price input”. Following it down showed that is only half right: the WS revision channel indeed never took effect (#8, #10), but pollLoop has a REST allMids fallback and that channel is normal (#7); mid=? is a string toFixed call failing at the printing layer (#11), not missing data. The conclusion was corrected to three co-existing facts: “the slow channel is usable, the revision channel is dead, the log display is distorted”.

Unverified / in doubt

  • The exact cause of 33000.10650784375 is not localized. This round I tried pulling the prjxA pool’s eth_getLogs for blk 47131225/47131232, and the RPC returned empty (it did not give me the raw data), so I cannot recompute field by field. In the body, section 6 states only two verified things: the price differs from the real market price at the same moment by 372x; and no sanity check exists in the code segment that produced it. Mechanism undetermined, gap confirmed — not written up as a verified conclusion.
  • Connected uncertainty from that: the gross of the above two trades (combined $742,048.94) is the arithmetic result of an erroneous input, not a real trading opportunity. The article’s attribution of “where the 741k came from” stops at “2 outliers + 1 wrong price” and does not speculate further on the specific parse path.
  • paper.js’s stderr at startup has 3 lines of /usr/bin/env: 'node': No such file or directory, while the current service runs fine and /usr/bin/node does not exist. It may be left over from a past start via shebang/env node; it did not affect the current run and was not investigated.
  • The snapshot is only 267 trades / 10 minutes, so any conclusion about “long-term strategy expectancy” does not hold. All strategy assessments in the article are scoped to this window.

How to re-run

 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']))"