239 lines
20 KiB
Markdown
239 lines
20 KiB
Markdown
|
|
# JEV‑Trader Execution Flow Study
|
|||
|
|
|
|||
|
|
**Subject:** `https://github.com/jarrodwatts/jev-trader` (tree at commit `b587759e`, `main`).
|
|||
|
|
**Scope:** this document studies the **execution logic outside Jev itself** — i.e. the order‑placement / book‑interaction state machine in `src/trader.ts`, `src/market.ts`, `src/trades.ts`, `src/chain.ts`, `src/server.ts`. Jev (the LLM decision model) is only the **decision point**; it is treated as an opaque oracle below. **Read‑only study: no files in the working tree were altered.**
|
|||
|
|
|
|||
|
|
**TL;DR:** Jev‑trader is a **single‑in‑flight‑order, post‑only, replace‑every‑block maker‑capture bot**. One order rests on the book at a time; every ~300 ms block it cancels the resting order and posts a fresh one **one tick inside the touch on the model’s side, never crossing**. It earns the spread by being the counterparty taker flow hits; it does **not** chase, does **not** do TP/SL, and has **no venue‑truth reconciliation** — it relies on `eth_getTransactionReceipt` + a 10‑block `lost` timeout. The parts that are portable to Hyperliquid are the *book‑placement policy* and the *serialized one‑send‑per‑block cadence*, **not** the Kuru/Monad‑specific plumbing.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 0. Repository surface (what exists, what is dead‑simple)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
jev-trader/
|
|||
|
|
├── package.json # bun runtime; deps: ethers@5, @kuru-labs/kuru-sdk, ai + @ai-sdk/typesafe-ai
|
|||
|
|
├── tsconfig.json # strict, verbatimModuleSyntax, noUncheckedIndexedAccess
|
|||
|
|
├── index.ts # 1) market.init(); 2) createModel(); 3) startServer(...); 4) new Trader(...);
|
|||
|
|
├── # trader.attachTradeFeed(log10(sizePrecision)); startBlockFeed(onBlock)
|
|||
|
|
├── src/
|
|||
|
|
│ ├── config.ts # env→typed config (23 knobs; see §1)
|
|||
|
|
│ ├── chain.ts # JSON‑RPC eth_call/sendRaw/BlockNumber; WS newHeads + poll backstop
|
|||
|
|
│ ├── book.ts # ONE eth_call getL2Book decoder (mirrors SDK bit‑for‑bit)
|
|||
|
|
│ ├── market.ts # Kuru Market: book read, order build/encode (batchUpdate), send, receipt, gas
|
|||
|
|
│ └── model.ts # Model interface: {buy,sell,hold} + upIn10; JevModel vs MockModel
|
|||
|
|
├── src/trader.ts # ★ the FSM: onBlock, send→receipt, fill, position, totals
|
|||
|
|
├── src/trades.ts # Trade‑log feed (eth_getLogs Trade event, chunked ≤100 blocks)
|
|||
|
|
└── src/server.ts # Bun.serve HTTP + SSE (snapshot/history/events, ping 15s)
|
|||
|
|
```
|
|||
|
|
`src/index.ts` is the **only** program entry. There are **no tests, no services, no worker pool** — one process, one loop, one order. The `scripts/` dir (`probe‑*.ts`, `dry‑encode.ts`, `bench‑read.ts`, `trace‑rpc.ts`) are offline probes/dry‑run signers, **not** on the live path.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Configuration (the 23 levers that bound the exec)
|
|||
|
|
|
|||
|
|
From `config.ts` (the only place behavior is tunated at runtime):
|
|||
|
|
|
|||
|
|
| knob | value | exec meaning |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `tradeSizeMon` | 200 (min Kuru order) | **fixed** order size every block — not notional‑scaled |
|
|||
|
|
| `maxPositionMon` | 1000 | position cap = **5× trade size**; hard stop‑gap |
|
|||
|
|
| `bankrollUsd` | 100 | PnL% denominator only (cosmetic) |
|
|||
|
|
| `quoteInsideTicks` | 1 | quote **1 tick inside the touch** |
|
|||
|
|
| `marginMon` / `marginUsdc` | 600 / 20 | Kuru margin account top‑up targets |
|
|||
|
|
| `gasLimit` / fallback | 350,000 | **hardcoded** per block (Monad charges the limit) |
|
|||
|
|
| `maxFeeGwei` / priority | 400 / 2 | static type‑2 fees (EIP‑1559) |
|
|||
|
|
| `pendingBlocks` | 10 | receipt‑wait before `lost` |
|
|||
|
|
| `refreshBlocks` | 200 | fee estimate + margin + vault refresh cadence |
|
|||
|
|
| `horizonBlocks` | 100 | the model’s prediction window (~30 s) |
|
|||
|
|
| `model` | `mock`\|`jev` | Jev is swap‑in only here |
|
|||
|
|
| `jevUsdPerMTok` | 0.042 | inference cost accounting |
|
|||
|
|
|
|||
|
|
Key structural facts: **size is constant** (200 MON), **quote distance is fixed at 1 tick**, and **gas is a static ceiling** — nothing adapts to book depth or volatility at runtime. The exec is deliberately stateless across blocks except for `orders`/`inflight`/`position`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. The 300‑ms hot path (why it is “exactly two round trips”)
|
|||
|
|
|
|||
|
|
Per README §“The 300ms budget” and `trader.onBlock`:
|
|||
|
|
|
|||
|
|
1. `confirmPending(block)` → off‑hot‑path `pollPending` (receipts for prior sends) — overlaps the read.
|
|||
|
|
2. `market.readBook()` → **one `eth_call` `getL2Book`** on `READ_RPC_URL` (~18 ms p50 on the public RPC). Optional vault read batched into the same HTTP request.
|
|||
|
|
3. `model.decide(state)` → the Jev call (≈ real inference; `MockModel` sleeps 80 ms to emulate).
|
|||
|
|
4. `market.send(...)` → `signTransaction` + **`eth_sendRawTransaction`** on `RPC_URL` (returns the hash as soon as the pool accepts it).
|
|||
|
|
|
|||
|
|
That is it on the hot path: **one read + one write per block**. Fees, margin, vault, estimateGas, receipts, and fills are **never** on the hot path (they run on later blocks or once at `init()` — see `market.init` / `refresh` / `pollPending`). `gasLimit` is estimated **once at startup** (`initGasLimit`: 1 `estimateGas` + 90k headroom × 1.15) and then **hardcoded** — no per‑block `eth_estimateGas`. Effective gas price = `MAX_FEE_GWEI` cap + `PRIORITY_FEE_GWEI`=2gwei; the cap is “free” so the real cost is base+priority. `gasMon = gasLimit × effectiveFeeWei`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. The decision point (how Jev is *asked*, not what it answers)
|
|||
|
|
|
|||
|
|
`model.ts` `QUESTIONS.direction.type = "choice"` with `instructions`:
|
|||
|
|
|
|||
|
|
> **“Will MON be higher or lower than the current mid after `horizonBlocks` more blocks?”**
|
|||
|
|
> goal: trade MON‑USDC on Kuru. Blocks ~300 ms; horizon ≈30 s is the move window. A decision is made every few blocks and held until the next one. The trade crosses the spread (`spreadBps`), so the move must beat that cost.
|
|||
|
|
> criteria: buy = “mid more likely HIGHER after horizon, by > spread”; sell = “LOWER”.
|
|||
|
|
> timing: “immediate‑or‑cancel market order in the next block” (note: this is the mock’s doc; the live path is post‑only maker rest).
|
|||
|
|
|
|||
|
|
The model returns `Action ∈ {buy,sell}` (hold only when late) + `probabilities:{buy,sell,hold}` + `upIn10` (= buy prob, the “will price go up” number). `trader.ts:105` maps it: `wanted = action==="sell" ? "sell" : "buy"` (buy wins ties / default). **Jev never says “how big” or “when to exit”** — size is fixed at 200 MON and there is no TP/SL; the model is asked the same binary every block and a fresh order is placed. This is the crux of how Jev is used: a **stream of independent per‑block binary direction calls**, each consumed by one post‑only maker order.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. HOW IT EXECs AGAINST THE BOOK (the portable core)
|
|||
|
|
|
|||
|
|
### 4.1 Book reading — one eth_call, exact‑match decode (`book.ts`)
|
|||
|
|
`readBook()` issues a **single `eth_call getL2Book`** (`0x46fdfbb1`) tagged `latest`; if the Kuru AMM vault is active (`readVaultParams` `getVaultParams` `0x88bb4f60`), **both calls go in one HTTP batch** — no second round trip. This exists only to beat the SDK path (SDK does 2 sequential eth_calls; jev does 1, or 1 batched). Decoding (`decodeL2Book`, `buildBook`) replicates the Kuru SDK `getFormattedL2OrderBook` **bit‑for‑bit** (parseFloat, floor bids / ceil asks to tick, group by price, same ordering) so `bid/ask/mid/imbalance` match exactly. `buildBook` then emits `levels` (top 5 each side), `depthBps` (10/25/50 bps), `spreadBps`, `imbalance` — the full state fed to `buildState`. A book with an empty side **throws** (`empty book side at block …`), i.e. jev would rather skip a block than guess.
|
|||
|
|
|
|||
|
|
### 4.2 The placement policy — post‑only, 1 tick inside, never crosses (`market.quotePrice`)
|
|||
|
|
```ts
|
|||
|
|
quotePrice(side, book):
|
|||
|
|
bidU = round(book.bid * scale); askU = round(book.ask * scale) // tick units
|
|||
|
|
step = QUOTE_INSIDE_TICKS * tickSize
|
|||
|
|
p = side==="buy" ? bidU + step : askU - step // buy rests BELOW touch, sell ABOVE
|
|||
|
|
if (buy && p >= askU) p = bidU // clamp to touch (spread too tight)
|
|||
|
|
if (sell && p <= bidU) p = askU // never cross
|
|||
|
|
return p / scale
|
|||
|
|
```
|
|||
|
|
Two independent guarantees the order **cannot cross the spread**: (a) the price math clamps to the touch when the spread is < the tick step, and (b) the on‑chain call is encoded with `postOnly=true` (the 5th arg of `batchUpdate` — see `encode`). The quote sits **one tick inside the touch** whenever the book is wide enough — this is the “capture” edge: a taker printing at the touch fills our resting order one tick early and we earn the tick.
|
|||
|
|
|
|||
|
|
### 4.3 The replace‑every‑block mechanic — atomic cancel‑all + one place (`trader.onBlock` + `market.send`)
|
|||
|
|
Every block (when not busy and when `side` is allowed):
|
|||
|
|
```ts
|
|||
|
|
cancel = [...this.orders.keys()].filter(id => id > 0) // confirmed RESTING order ids only
|
|||
|
|
// (inflight txs awaiting receipt are NOT cancelled — they live in this.inflight, keyed by txHash)
|
|||
|
|
quote = await market.send(block, side, TRADE_SIZE_MON, book, cancel, side !== wanted)
|
|||
|
|
```
|
|||
|
|
`market.send` builds **one type‑2 tx `batchUpdate(buyPrices,buySizes,sellPrices,sellSizes, orderIdsToCancel, postOnly=true)`** — the cancel list + the single new post‑only order in **one Kuru call** (atomic from jev’s view: the book never sees a gap where we have no quote). `value: 0` (funded from the margin account). On send: `nonce++`, `inflight.set(hash, quote)`, status=`sent` (`gasMon = gasLimit × feeWei` charged upfront — see §4.5). **Dry run**: `status:"sim"`, no signature; `orders.clear(); orders.set(--simId, {…})` (negative id marks sim).
|
|||
|
|
|
|||
|
|
This is the replace‑every‑block loop: each block cancels the **last confirmed resting order** and posts a fresh one on the (possibly new) model side. Because cancellations ride in the same tx as the new place (same cloid family on Kuru? — no: it’s cancel‑by‑oid + place, but the cancel list is the prior receipt’s `orderId`), the resting leg moves atomically relative to the book. (Compare FLIGHT §6.4, which uses a same‑cloid atomic **modify** to the touch — fee‑free and truly stateless; jev re‑uses an order‑id cancel list instead.)
|
|||
|
|
|
|||
|
|
### 4.4 The one‑in‑flight gate (the “never fire two” rule) — `trader.onBlock` busy flag
|
|||
|
|
```ts
|
|||
|
|
if (this.busy) {
|
|||
|
|
this.totals.lateBlocks++
|
|||
|
|
if (this.lastBook) this.emit(block, this.lastBook, null, null, true) // decision=null → emit "hold"
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
this.busy = true
|
|||
|
|
...
|
|||
|
|
} finally { this.busy = false }
|
|||
|
|
```
|
|||
|
|
A block arriving while the prior `readBook→decide→send` is still running is **not** a new opportunity: it is emitted as a **late block** (`decision.hold {buy:0,sell:0,hold:1}`, no quote, `late:true`), and **no order is placed**. This is jev’s serialization primitive: at most **one send per block cadence**. It also means jev never races itself — there is exactly one resting order (the newest confirmed), and the cancel list always names the one prior confirmed resting order.
|
|||
|
|
|
|||
|
|
### 4.5 Receipt handling — placed / reverted / lost (`market.pollPending` + `parseReceipt`)
|
|||
|
|
Receipts are polled **off the hot path**: `confirmPending` runs at the top of the next `onBlock`, `Promise.all` over `pending`, one `eth_getTransactionReceipt` per in‑flight hash.
|
|||
|
|
```ts
|
|||
|
|
parseReceipt(r, p):
|
|||
|
|
if (r.effectiveGasPrice) this.feeWei = BN.from(r.effectiveGasPrice) // refresh fee estimate for real
|
|||
|
|
gasMon = this.gasMon(p.gasLimit, effectiveGasPrice ?? feeWei) // charged on reverts too
|
|||
|
|
status = (r.status === "0x0") ? "reverted" : "placed" // 0x0 = reverted
|
|||
|
|
if (status === "placed"):
|
|||
|
|
scan logs for OrderCreated(owner=me) → orderId
|
|||
|
|
scan logs for OrdersCanceled(owner=me) → canceled[]
|
|||
|
|
// status: sent | placed | reverted | lost | sim
|
|||
|
|
```
|
|||
|
|
Lifecycle of a quote: `sent` → (receipt) `placed` (orderId recorded, **only then** inserted into `this.orders` for next block’s cancel list) **|** `reverted` (book moved through the price or a cancelled id had already filled; `reverted` counter++; gas still charged) **|** `lost` (no receipt within `pendingBlocks=10` blocks; `status:"lost"`, `gasMon:0`, nonce resynced). `applyQuoteResult` then mutates `orders` (delete canceled, set placed orderId), deletes the inflight entry, adds `gasMon` to totals, and **patches the historical BlockEvent’s `quote`** and fires `onQuote` (SSE `quote` event).
|
|||
|
|
|
|||
|
|
Critical property: **jev never assumes** — an order is only “resting” once its receipt proves `OrderCreated`. Until then it is `sent` (counts toward the position cap via `restingMon`/`inflight` but is not in the cancel list).
|
|||
|
|
|
|||
|
|
### 4.6 Fill observation — taker hits our resting order (`trades.ts` + `harvest`)
|
|||
|
|
Fills are **not** our own transactions. They arrive two ways:
|
|||
|
|
- **Live:** `TradeFeed.poll()` issues `eth_getLogs` for the Kuru `Trade` event (topic `0xf169…21581`) over `[lastBlock+1..block]`, chunked at `MAX_RANGE=100` blocks, windowed to `MAX_CATCHUP=1000`. Each `Trade(uint40 orderId, makerAddress, isBuy, price[1e18], updatedSize, …, filledSize)` is decoded; if `makerAddress === our wallet`, it is a **maker fill** (our side = opposite of taker `isBuy`) with `updatedSize` so a fully‑filled order is dropped. `liveFills` reduces `orders` size by the fill; a fill for a resting order **closes that order out** (`orders.delete`).
|
|||
|
|
- **Dry run:** `simFills(prints)` — a simulated order placed at block N is on the book from N+1; a taker print (sell at/below our bid, or buy at/above our ask) for `min(o.size, print.size)` fills it. (No real tx — `txHash:null`, `simulated:true`.)
|
|||
|
|
|
|||
|
|
`harvest()` aggregates same‑block fills (`aggregate`: total size, size‑weighted price, side with more size) and calls `applyFill`.
|
|||
|
|
|
|||
|
|
### 4.7 Position + PnL — signed inventory, FIFO cost basis, realized on close (`trader.applyFill` / `emit`)
|
|||
|
|
```ts
|
|||
|
|
position = { mon: signed_inventory, costUsd: FIFO_basis }
|
|||
|
|
applyFill(f):
|
|||
|
|
signed = (f.side==="buy") ? +size : -size
|
|||
|
|
if flat || same sign: costUsd += signed * price // add to / average position
|
|||
|
|
else: closing = min(|signed|,|mon|) * sign(signed)
|
|||
|
|
realizedUsd += -closing*(price - entry) ; costUsd += closing*entry
|
|||
|
|
remainder = signed-closing ; costUsd += remainder*price // flip opens other way
|
|||
|
|
mon += signed ; if |mon|<1e-9 {mon=0; costUsd=0}
|
|||
|
|
```
|
|||
|
|
Unrealized = `mon * (mid - entryPrice)`; `gasUsd = gasMon * mid`; `pnlUsd = realizedUsd + unrealized - gasUsd`; `pnlPct = pnlUsd / bankrollUsd * 100`. Note the **bankroll is $100** — so `pnlPct` is intentionally dramatic/small‑base; the real P&L is `pnlUsd`/`pnlMon`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. State‑machine map (single slot)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[IDLE] -- busy=true, onBlock --> [READING_BOOK] -- model.decide(modelSide)
|
|||
|
|
| |
|
|||
|
|
| busy=false but (cap blocks both sides) |
|
|||
|
|
v v
|
|||
|
|
[REJECTED_NO_SIDE: emit no quote, model call still recorded, capped?]
|
|||
|
|
|
|
|||
|
|
allowed(wanted) ? wanted : allowed(other) ? other
|
|||
|
|
| (cap = maxPositionMon; live: margin)
|
|||
|
|
v
|
|||
|
|
[SEND: cancel confirmed resting + post post-only 1-tick-in]
|
|||
|
|
| gasMon charged up front, status=SENT, inflight[hash]=quote
|
|||
|
|
v
|
|||
|
|
[RESTING? NO — emitted this block, receipt lands LATER]
|
|||
|
|
|
|
|||
|
|
receipts (off-path, pollPending) resolve:
|
|||
|
|
PLACED -> orderId in `orders` (becomes next block's cancel list)
|
|||
|
|
REVERTED -> reverted++ , gasMon kept, slot freed, nonce resync
|
|||
|
|
LOST (>10 blocks) -> status=lost, gasMon=0 [~]
|
|||
|
|
fills (off-path, Trade feed -> harvest -> applyFill):
|
|||
|
|
live: order size shrinks / order drops [FILL]
|
|||
|
|
dry : cross print fills sim order [FILL]
|
|||
|
|
|
|
|||
|
|
v
|
|||
|
|
[position.mon += signed ; realized/cost-basis fold ; totals update]
|
|||
|
|
|
|
|||
|
|
v
|
|||
|
|
emit(BlockEvent) --> next block IDLE again
|
|||
|
|
```
|
|||
|
|
There is **no explicit EXIT state**. When the model flips side (e.g. was long, now `sell`), the existing bid is cancelled and a new post‑only **ask** is placed one tick inside — the old position is closed by the new flow naturally (a taker lifts the new ask). There is no stop‑loss, no TP, no time‑based exit; a position is only ever reduced by the model deciding the opposite side and posting the opposite quote. This is the single biggest structural difference from any directional strategy and the reason jev is a **spread‑capture / flow‑reversal machine**, not a directional holder.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 9. Guardrails and failure modes (the safety surface)
|
|||
|
|
|
|||
|
|
| mechanism | code | effect |
|
|||
|
|
|---|---|---|
|
|||
|
|
| one send / block cadence | `Trader.onBlock` `busy` | prevents over‑submission; late blocks become `hold` |
|
|||
|
|
| replace‑every‑block (cancel all on each send) | `cancel = orders.keys` in `send` | no stale resting orders; fresh quote every block at the touch |
|
|||
|
|
| post‑only never crosses | `quotePrice` clamp + `batchUpdate(...,postOnly=true)` | can never pay the spread / cross the book |
|
|||
|
|
| position cap | `allowed()`: `|position.mon + resting ± size| > maxPositionMon` | hard 5× cap; caps the **gross** exposure |
|
|||
|
|
| margin check (live) | `allowed()`: usdc ≥ size·ask / mon ≥ size | dry run skips (no wallet) |
|
|||
|
|
| receipt lost timeout | `block - p.block >= pendingBlocks(10)` | frees stuck slot, resyncs nonce |
|
|||
|
|
| reorg/empty book | `readBook` throws on empty side | block skipped, bot does not guess |
|
|||
|
|
| book depth | `readBook` reads full depthBps(10/25/50) — **but exec is size‑blind** | sees depth, but `tradeSizeMon` is fixed regardless |
|
|||
|
|
| gas ceiling | static `MAX_FEE_GWEI=400` + `gasLimitFallback` | no per‑block estimation; pays limit, not gasUsed |
|
|||
|
|
|
|||
|
|
Notable **absences** (vs a hardened shop‑till‑stops engine): no **venue‑truth reconciliation** (no poll of open orders vs the venue; relies on receipt + Trade log), no **network‑partition handling** beyond the WS+poll backstop for block notifications, no **partial‑fill remainder re‑quote** (a partially‑filled resting order is simply replaced next block), no **rate‑limit backoff** (Kuru/Monad don’t seem to surface 429s in this path), and **no TP/SL/MAX_HOLD** exit authority — jev just keeps replacing quotes every block.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 10. What is actually attractive / portable from Jev’s exec
|
|||
|
|
|
|||
|
|
1. **The replace‑every‑block post‑only‑inside‑the‑touch policy** — a fresh maker order at the touch every cadence, capturing taker flow, with zero stale‑order drag. This is the “be the one whose order flow gets picked up” mechanic.
|
|||
|
|
2. **The busy‑gate serialization** — at most one send per cadence; no self‑racing; clean ack/fill ordering.
|
|||
|
|
3. **The book‑reader exactness** (`book.ts`) — 1 eth_call, SDK‑identical decode, vault batched.
|
|||
|
|
4. **Model‑agnosticism of the exec** — the model is a black‑box oracle returning `{buy,sell}` + probs; the exec never imports Jev.
|
|||
|
|
|
|||
|
|
What is **NOT** portable as‑is: the Kuru `batchUpdate` (cancel+place atomic), the `eth_getLogs` Trade‑feed maker matching, the 1e18 price / tick math, the Ethereum nonce, the Monad gas‑on‑limit pricing, and the 300‑ms on‑chain block assumption (HL is off‑chain orderbook + REST/WS, ~50‑300 ms depending on venue).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 11. File‑to‑file code map (for the reader)
|
|||
|
|
|
|||
|
|
| concern | file:line |
|
|||
|
|
|---|---|
|
|||
|
|
| state machine loop, busy/late gate, position cap, emit | `src/trader.ts:85` `onBlock`, `:89` busy, `:108` `allowed`, `:125` emit |
|
|||
|
|
| single atomic cancel‑all + post‑only place, fire‑and‑forget, inflight | `src/trader.ts:116` `market.send(block, side, size, book, cancel, capped)`, `market.ts:135` `send` |
|
|||
|
|
| post‑only price, never cross | `src/market.ts:121` `quotePrice` |
|
|||
|
|
| batchUpdate encoding (cancel + place, postOnly flag) | `src/market.ts:185` `encode` |
|
|||
|
|
| receipt → placed/reverted/lost, orderId from OrderCreated | `src/market.ts:155` `pollPending`, `:193` `parseReceipt` |
|
|||
|
|
| Trade‑log feed + maker‑fill matching + sim‑fills | `src/trades.ts:80` `poll`, `:113` `decode`, `:171` `liveFills`, `:186` `simFills` |
|
|||
|
|
| FIFO cost basis + realized PnL | `src/trader.ts:245` `applyFill` |
|
|||
|
|
| one‑eth_call book reader (exact SDK match) | `src/book.ts:95` `readBook`, `:121` `decodeL2Book`, `:224` `buildBook` |
|
|||
|
|
| block feed (WS newHeads + poll coalesce) | `src/chain.ts:30` `startBlockFeed` |
|
|||
|
|
| server (snapshot/history/SSE) | `src/server.ts:11` `startServer` |
|
|||
|
|
| the question actually asked of Jev | `src/model.ts:42` `QUESTIONS.direction` |
|