jev-trader
每个 Monad 区块做出一次决策。一个 TypeSafe 的 Jev 模型监控 Kuru 的 MON-USDC 订单簿,每约 300 毫秒回答买还是卖。每个区块都会在该方向上挂出一笔真实的 post-only 限价单,价格位于盘口内侧一个 tick,并替换上一笔订单。只有当吃单方打到该订单时才会成交,因此机器人是赚取价差而不是支付价差。一个小型服务器将每个区块的数据流式推送到仪表盘。
Run
cp .env.example .env
bun install
bun run start
在没有 PRIVATE_KEY 的情况下以 dry-run 方式运行:真实的订单簿、真实的决策、模拟的成交。设置 MODEL=jev 和 TYPESAFE_AI_API_KEY 即可使用 Jev;默认的 mock 是一个动量启发式替身模型。
Endpoints
已部署(dry run,mock 模型):https://jev-trader-production.up.railway.app
GET /快照:模型、钱包、dryRun、最新区块事件GET /history最近 1000 个区块事件GET /eventsSSE:连接时发送snapshot,之后每个区块一个block事件,每当挂单回执到达时还会推送fill事件
每个事件(类型定义见 src/trader.ts):
{
"block": 105488269, "ts": 1789593630676,
"mid": 0.022636, "bestBid": 0.022628, "bestAsk": 0.022644, "spreadBps": 7.07,
"decision": { "action": "buy", "probabilities": { "buy": 0.77, "sell": 0.23, "hold": 0 }, "upIn10": 0.77, "latencyMs": 81, "late": false },
"quote": { "side": "buy", "price": 0.022629, "size": 200, "txHash": "0x…", "gasMon": 0.0357, "cancel": [100295801], "status": "sent", "orderId": null, "capped": false },
"fill": null,
"resting": { "bidMon": 200, "askMon": 200 },
"position": { "side": "short", "size": 200, "entryPrice": 0.022633, "unrealizedUsd": -0.0006, "unrealizedMon": -0.027 },
"totals": { "blocks": 3, "decisions": 3, "quotes": 3, "fills": 1, "reverted": 0, "lateBlocks": 0, "jevUsd": 0.000004, "gasMon": 0.107, "gasUsd": 0.0024, "realizedUsd": 0, "pnlUsd": -0.003, "pnlMon": -0.13, "pnlPct": -0.003 }
}
每个区块,模型都会被问及未来 HORIZON_BLOCKS(默认 100,约 30 秒)的走势,并回答 buy 或 sell。quote 是该区块挂到订单簿上的订单:一笔数量为 TRADE_SIZE_MON、位于盘口内侧 QUOTE_INSIDE_TICKS 个 tick 的 post-only 限价单(当价差太窄时钳制到盘口),在同一个 batchUpdate 中完成,同时取消我们所有已挂出的订单(cancel)。hold 仅在 decision.late: true 时出现,即模型错过了该区块而没有挂出任何订单。当仓位上限(实盘中则是保证金资金)阻塞某个方向时,报价会挂到另一方向并标记 capped: true,而 probabilities 仍显示模型的判断。resting 是本区块后我们已知仍在簿的订单量。upIn10 等于 buy 概率。
实盘发送采用即发即忘(fire and forget)模式,因此 block 事件携带的是意图:status: "sent",gasMon 为 gasLimit x (最近已知基础费 + 优先费)。Monad 按 gas 上限收费,因此无论订单是否上链,这都是实际成本。回执会在一两个区块后作为独立的 SSE 事件到达:
event: quote
data: { "block": 105488269, "quote": { …, "status": "placed", "orderId": 100295812, "gasMon": 0.0357 } }
status 会变为 placed(附带订单 id)或 reverted(在交易上链前订单簿价格已穿过该价位,或已取消的订单其实已经成交)。10 个区块后仍无回执则为 lost。成交并不发生在我们自己的交易里:别人的吃单订单打到了我们的挂单上,对应的 Trade 日志通过与喂给模型的同一个 eth_getLogs 轮询到达。每个有成交的区块都会触发独立的 SSE 事件,届时 position、realizedUsd 和 fills 会随之更新:
event: fill
data: { "block": 105488271, "fill": { "side": "buy", "size": 200, "price": 0.022629, "txHash": "0x…", "orderId": 100295812, "simulated": false } }
txHash 是吃单方的交易。在 dry run 中,报价的 status 为 "sim":订单挂出一个区块,若有真实成交价穿过其价格则视为成交(simulated: true)。
Layout
src/config.ts 环境变量
src/chain.ts 区块流(WebSocket newHeads + 轮询兜底,仅取最新区块)、原生 RPC
src/book.ts 单次 eth_call 的订单簿读取器(解码 getL2Book,合并 AMM 金库)
src/market.ts Kuru:读取订单簿、手工编码的 batchUpdate(取消 + post-only 挂单)、保证金存入、本地 nonce、异步确认
src/model.ts Model 接口、JevModel(AI SDK experimental_evaluate)、MockModel
src/trader.ts 主循环:单笔在途、迟到时 hold、仓位与盈亏核算
src/server.ts Bun.serve:快照、历史、SSE
The 300 ms budget(300 毫秒预算)
一次决策和一笔订单必须塞进一个区块内完成,因此热路径循环只做恰好两次 RPC 往返:
一次 eth_call 读取订单簿(公共 RPC 上约 18 ms,READ_RPC_URL),一次
eth_sendRawTransaction(RPC_URL),交易一被接受就立即返回。路径上没有任何其他东西——没有
eth_estimateGas(Monad 按 gas 上限收费,因此上限被硬编码或在启动时推导一次),没有
eth_sendRawTransactionSync(它会阻塞到交易被 Proposed),没有 gas 价格查询
(静态 type-2 费用:MAX_FEE_GWEI 上限,2 gwei 优先费;实际价格为 base + priority)。回执、费用估算和金库检查都在后续区块的非热路径上运行。在使用 mock 模型的 dry run 中实测:读取 p50 18 ms,整个循环 p50 100 ms(其中 80 ms 是 mock 模型的推理替身)。
bun run scripts/bench-read.ts # 订单簿读取器 vs SDK:精确性与延迟
bun run scripts/dry-encode.ts # 离线签署一笔买单和一笔卖单,断言 calldata 与 SDK 一致