StackMap
Subscribe
Explore / jev-trader
jarrodwatts

jev-trader

One AI trade decision every Monad block: Jev reads the Kuru MON-USDC book every ~300ms, answers buy or sell, and the bot reposts a post-only limit order to earn the spread.

1,241 238 TypeScript MITupdated 2 days ago
View on GitHubDispute this mapping →
Curator's take

The clearest demonstration in the catalog of what a sub-100ms decision model buys you: the whole hot loop is two RPC round trips, no gas estimation, no price lookup, because the decision and the order must fit inside one 300ms block. Read src/trader.ts for the accounting - late blocks become 'hold', fills arrive as someone else's taker transaction, and gas is charged on the limit whether the order lands or not. Run it dry first: with no PRIVATE_KEY you get the real book, real decisions and simulated fills. Treat it as a reference implementation and a latency argument, NOT a strategy - the edge is being post-only inside the touch, the model is a coin-flip-shaped classifier over 100 blocks, and it is wired to one venue and one pair.

Mapped by ShipWithAI editors · links verified

Continue your stack

What teams reach for next — and why each earns a place beside jev-trader. Ranked by curator confidence.

pairs wellalternativealternativealternativeKronosAutoHedgeVibe-TradingOpenAlicejev-trader
pairs wellalternativebuilt withpick a node for the why · open it from the panel
Weekly digest
README.md3 min read

jev-trader

One decision every Monad block. A TypeSafe Jev model watches the Kuru MON-USDC order book and answers buy or sell every ~300 ms. Every block posts a real post-only limit order on that side, one tick inside the touch, replacing the last one. Fills happen when a taker hits it, so the bot earns the spread instead of paying it. A small server streams every block to the dashboard.

Run

cp .env.example .env
bun install
bun run start

With no PRIVATE_KEY it dry-runs: real book, real decisions, simulated fills. Set MODEL=jev and TYPESAFE_AI_API_KEY to use Jev; the default mock is a momentum heuristic stand-in.

Endpoints

Deployed (dry run, mock model): https://jev-trader-production.up.railway.app

  • GET / snapshot: model, wallet, dryRun, latest block event
  • GET /history last 1000 block events
  • GET /events SSE: snapshot on connect, then one block event per block, plus a fill event whenever a live order's receipt lands

Every event (see src/trader.ts for types):

{
  "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 }
}

Every block the model is asked about the move over HORIZON_BLOCKS (default 100, ~30 s) and answers buy or sell. quote is the order that block put on the book: a post-only limit order of TRADE_SIZE_MON on that side, QUOTE_INSIDE_TICKS inside the touch (clamped to the touch when the spread is too tight), in one batchUpdate that also cancels everything we had resting (cancel). hold appears only with decision.late: true, when the model missed the block and nothing was posted. When the position cap (or, live, margin funds) blocks a side, the quote goes on the other side with capped: true and probabilities still show the model's call. resting is our size known to be on the book after this block. upIn10 equals the buy probability.

Live sends are fired and forgotten, so the block event carries the intent: status: "sent", gasMon is gasLimit x (last known base fee + priority). Monad charges the gas limit, so that is the real cost whether the order lands or not. The receipt arrives a block or two later as its own SSE event:

event: quote
data: { "block": 105488269, "quote": { …, "status": "placed", "orderId": 100295812, "gasMon": 0.0357 } }

status becomes placed (with the order id) or reverted (the book moved through the price before the tx landed, or a cancelled order had already filled). No receipt after 10 blocks gives lost. Fills are not in our own transactions: someone else's taker order hits our resting one, and the Trade log for it arrives via the same eth_getLogs poll that feeds the model. Each block with fills gets its own SSE event, and position, realizedUsd and fills update then:

event: fill
data: { "block": 105488271, "fill": { "side": "buy", "size": 200, "price": 0.022629, "txHash": "0x…", "orderId": 100295812, "simulated": false } }

txHash is the taker's transaction. In a dry run the quote is status: "sim": the order rests for one block and a real print crossing its price fills it (simulated: true).

Layout

src/config.ts   env
src/chain.ts    block