feat(sentiment): add trade asset aliases & known entities; add Glassnode/WSJ RSS sources
- Added 15 trade assets (ZIL, ONG, ONE, STX, ALGO, DASH, LTC, FET, XTZ, ENJ, XLM, ETC, TRX) to asset_aliases.yaml - Added same assets with metadata to known_entities.yaml (chain, type, market_cap_rank) - Added Glassnode Insights (0.85 cred) and WSJ Crypto (0.85 cred) to sources.yaml - Trade log analysis shows these small-caps had zero news coverage - aliases enable entity extraction
This commit is contained in:
@@ -1,238 +1,155 @@
|
||||
# JEV‑Trader Execution Flow Study
|
||||
# Jev Trader: Execution Flow and Decision Semantics
|
||||
|
||||
**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.**
|
||||
## Scope and source identity
|
||||
|
||||
**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.
|
||||
This is a read-only study of the public Jev Trader repository at commit b587759e459ea049590102e54a0b07800864cdc3 (main, cloned 2026-09-22), the full tracked file inventory, and its backend implementation. The Jev clone is at /tmp/jev-exec-study-b587759. No Jev process, wallet, provider call, or order was run. No execution test was added or run.
|
||||
|
||||
---
|
||||
The relevant upstream files are src/index.ts, src/config.ts, src/chain.ts, src/book.ts, src/market.ts, src/trader.ts, src/trades.ts, src/model.ts, src/server.ts; scripts/*; package.json, SPEC.md, README.md; and web/src/app/*, web/src/components/*, web/src/lib/*. Source citations below link to the pinned commit.
|
||||
|
||||
## 0. Repository surface (what exists, what is dead‑simple)
|
||||
## Executive finding
|
||||
|
||||
```
|
||||
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.
|
||||
Jev is a compact asynchronous single-market strategy loop. On each newly observed Monad block, it reads a Kuru book, asks Jev for a buy/sell classification, applies local inventory and margin limits, then submits a signed post-only batchUpdate containing a new limit order and cancellations for previously confirmed order IDs. It later polls transaction receipts and Kuru trade logs.
|
||||
|
||||
---
|
||||
The critical correction to the preliminary draft is that the busy flag serializes the book-read/decision/send routine, not venue transactions or outstanding orders. Receipt polling runs concurrently; send returns after the RPC returns a hash; a later block can send another transaction while earlier transactions remain pending. Unknown/late receipts, overlapping polls, fills racing receipt handling and slow model responses can therefore produce more than one live or uncertain order. “Exactly one resting order” is not an invariant in this source.
|
||||
|
||||
## 1. Configuration (the 23 levers that bound the exec)
|
||||
Jev is a decision provider, not an order-management agent here. It returns an action and provider-reported choice distribution from one structured question. The code does not validate that distribution as a calibrated forecast, derive size from it, choose execution style, or decide an exit. The actual prompt describes IOC execution while the code submits post-only orders. Jev's confidence must not be interpreted as fill probability, maker edge or realized expectancy.
|
||||
|
||||
From `config.ts` (the only place behavior is tunated at runtime):
|
||||
## Boot and feed
|
||||
|
||||
| knob | value | exec meaning |
|
||||
src/index.ts performs this sequence:
|
||||
|
||||
1. Instantiate Market and await market.init().
|
||||
2. Construct the selected Model.
|
||||
3. Start the HTTP/SSE server.
|
||||
4. Construct Trader with event callbacks and attach its trade feed.
|
||||
5. Start the block feed, whose callback invokes Trader.onBlock.
|
||||
|
||||
The package entry is src/index.ts. The repository-root index.ts only prints “Hello World.” MODEL selects Jev only for the exact value “jev”; otherwise createModel returns MockModel. Dry run is enabled when DRY_RUN is exactly “true” or no private key is configured. Numeric environment values are converted without comprehensive range validation. There is no durable trader-state restore or startup reconciliation of open orders.
|
||||
|
||||
chain.startBlockFeed combines HTTP eth_blockNumber polling (default 150 ms) with optional WebSocket newHeads and a newest-block coalescer. The callback is invoked without awaiting its promise. Async interval polls can overlap; WS JSON parsing is not guarded; older block numbers are ignored with no reorg rollback. RPC has no explicit timeout, retry policy, HTTP status check or result schema validation. Book and log reads are independent latest/range requests, not guaranteed snapshots of the triggering block.
|
||||
|
||||
## Per-block decision and order path
|
||||
|
||||
Trader.onBlock increments counters and launches receipt polling for previous sends. Periodic market.refresh is also off-path. If busy is already true, the block is counted late and may be emitted with lastBook; no new model call is started. Otherwise busy is set and the routine:
|
||||
|
||||
1. Awaits Market.readBook, stores the latest book and appends its mid to a process-local 400-value history.
|
||||
2. Starts TradeFeed.poll(block) without awaiting it.
|
||||
3. Builds TradeState and awaits model.decide.
|
||||
4. Uses the model's sell action as sell; other values default to buy.
|
||||
5. Calls allowed() for the desired side. If disallowed, it tries the opposite side as a risk-reducing alternative. If neither is allowed, no order is sent.
|
||||
6. Overwrites decision.action with the actually selected side, then calls Market.send with cancellations for currently known positive order IDs.
|
||||
7. Stores the returned hash as in-flight, emits the block, and clears busy in finally.
|
||||
|
||||
An exception is logged; it does not cancel existing orders. There is no decision-age deadline before send, so a slow model may submit using a stale captured book.
|
||||
|
||||
Book decoding in src/book.ts parses Kuru L2/vault data, applies tick rounding and derives mid, spread, imbalance, depth and top levels. Empty sides throw. buildState passes current book and process-local price/trade histories, returns and allowed-side flags. Coalesced/missed blocks mean its history is not guaranteed to be N consecutive canonical observations.
|
||||
|
||||
## Jev's decision and its implications
|
||||
|
||||
### What the live adapter asks
|
||||
|
||||
src/model.ts constructs a TypeSafe evaluation model using JEV_MODEL_ID (default jev-latest). JevModel.decide calls experimental_evaluate with the current TradeState, one choice question named direction and maxRetries=0.
|
||||
|
||||
The instructions ask whether MON will be higher or lower than the current mid after horizonBlocks, say the move should exceed spread, and describe an “immediate-or-cancel market order in the next block.” Inputs named include taker flow, book depth and imbalance, returns, recent mids, and allowed buy/sell flags. Jev is told that a disallowed side will be traded in the other direction.
|
||||
|
||||
The response becomes Action plus buy/sell/hold values. If probabilities are absent, the adapter makes a one-hot fallback for the selected choice. It sets hold to zero and upIn10 equal to buy probability. Despite the name, upIn10 is not a separate 10-block forecast in this source. The code does not normalize or range-check probabilities, calibrate them, set an abstain threshold or record a stable model version in each decision.
|
||||
|
||||
### What Jev controls
|
||||
|
||||
The model's choice is an input to side selection, not the final execution instruction. Quantity is fixed at TRADE_SIZE_MON (default 200); code selects price from the book and post-only execution. Jev does not choose quantity, limit price, IOC/taker use, cancellation timing, stop, take-profit, exit reason or post-entry holding period.
|
||||
|
||||
When allowed() rejects Jev's side but permits the other, Trader changes decision.action while leaving probability values untouched. A displayed sell can therefore coexist with a high buy probability because risk constraints selected the reducing side. These are distinct facts, and must not be collapsed into a single calibrated Jev output.
|
||||
|
||||
The prompt's “by more than the spread” language does not create a numerical expected-value calculation. A provider choice distribution is not by itself a calibrated probability that a trade profits after fees, fill selection, queue position, adverse selection and inventory costs.
|
||||
|
||||
### The prompt/execution mismatch
|
||||
|
||||
The prompt describes IOC market execution crossing the spread. The implementation encodes batchUpdate with postOnly=true and chooses a price one tick better than the same-side best quote when the spread permits; if that reaches the opposite touch, it falls back to the same-side touch. Post-only guards against crossing at acceptance, but this is a different economic event from immediate taker execution.
|
||||
|
||||
Maker fills are conditional: the counterparty chooses to trade against a resting quote, often while price is moving through it. That creates adverse-selection risk. “One tick better than the same-side touch” is a price concession and queue-priority reset on every replacement, not a guaranteed captured tick or profit. Net expectancy requires actual fill-conditional markouts, fees/gas, queue age, cancellation latency and inventory costs.
|
||||
|
||||
The official TypeSafe documentation describes Jev as a structured decision/classification layer and stresses measuring classification quality on the deployer's labelled data. Tool routing is a separate capability; this repository's Jev call has one choice output and no tools. Jev does not send a transaction in this code: Market signs and submits only after Trader applies local constraints. See [Pydantic TypeSafe / Jev documentation](https://pydantic.dev/docs/ai/models/typesafe/), especially decision measurement and tool routing.
|
||||
|
||||
## Price and transaction lifecycle
|
||||
|
||||
### Quote construction
|
||||
|
||||
Market.quotePrice converts best bid and ask to integer tick units. A buy is best bid plus quoteInsideTicks; a sell is best ask minus quoteInsideTicks. If the candidate reaches/crosses the opposite touch, it reverts to the same-side touch. Market.encode builds one batchUpdate with one side's price/size arrays, known IDs to cancel, and postOnly=true.
|
||||
|
||||
A single batchUpdate can atomically apply its listed cancellations and new order within one EVM transaction. This does not give the bot a one-order invariant: it cancels only IDs already learned from confirmed receipts, cannot include pending or unknown IDs, and does not wait for earlier transactions. A reverted transaction leaves the book unchanged.
|
||||
|
||||
### Send, nonce and uncertainty
|
||||
|
||||
Market.send signs locally and calls eth_sendRawTransaction. It increments its local nonce and adds the transaction to Market.pending only after the RPC returns a hash. If the call throws, it attempts latest-nonce resynchronization and rethrows. If a node accepted a transaction but its response is lost, the source does not first preserve the signed hash in its pending map. There is no explicit RPC deadline or transaction replacement strategy.
|
||||
|
||||
After the send RPC returns, busy clears. A later block can send another transaction while one or more earlier hashes remain pending. Thus busy is a decision-routine gate, not a transaction mutex.
|
||||
|
||||
### Receipt polling
|
||||
|
||||
confirmPending launches Market.pollPending without awaiting it. Pending hashes are polled concurrently on each block; calls from successive blocks can overlap. A map-presence check prevents duplicate application after another poll removed the same hash, but one slow RPC within Promise.all can delay delivery of other results from that invocation.
|
||||
|
||||
A non-null receipt is removed and parsed. Status 0x0 is reverted; any other value, including an absent or unexpected status, is treated as placed. Parsed OrderCreated and OrdersCanceled logs for the local wallet update known IDs. There is no open-order query to verify the resulting state.
|
||||
|
||||
After pendingBlocks (default 10) without a receipt, the pending record is removed and emitted as lost; gas is shown as zero for that event and nonce resync is attempted. “Lost” means local polling gave up, not venue proof the transaction cannot still land. The path has no subsequent reconciliation of that uncertain order.
|
||||
|
||||
### Fill logs and accounting races
|
||||
|
||||
TradeFeed polls Kuru Trade logs in chunks of up to 100 blocks, maintains a process-local cursor and ring, and suppresses fills on its first catch-up poll. It does not persist its cursor, deduplicate by stable transaction/log identity or roll back removed logs after a reorg.
|
||||
|
||||
Live harvest applies fills to position even if Trader.orders does not know the order ID. It deletes/resizes a known local order, but has no lifecycle record for an unknown ID. A fill can precede the order-creation receipt. Later applyQuoteResult can insert the original full order size, even if already filled or partially filled. A late receipt can similarly re-add an order after a subsequent replacement. These are concrete ordering gaps.
|
||||
|
||||
The dry-run simulator matches prints against local resting quotes by price and print size. It does not model queue position, latency, cancellation races, conditional maker fills, fees or adverse selection; it cannot establish live maker profitability.
|
||||
|
||||
Inventory uses signed position and weighted-average cost basis, not FIFO. Opposite fills realize against the average basis and excess quantity flips the position. Gas estimates are attached to quotes, while totals charge gas when receipt results are applied. Displayed trading PnL includes realized/unrealized USD value minus tracked gas; inference spend is separate and not deducted. Venue maker fees are not included.
|
||||
|
||||
### Local exposure checks
|
||||
|
||||
allowed() uses signed position plus known resting orders and full-size in-flight quotes on the side being considered, then checks absolute exposure against MAX_POSITION_MON. This is side-specific projected signed exposure, not a universal gross-risk guarantee across uncertain transactions. A funded wallet also uses cached margin balances refreshed periodically; source does not reserve/decrement local margin for each unconfirmed order. These are local guards, not exchange-authoritative reconciliation.
|
||||
|
||||
## Source map
|
||||
|
||||
| Full local path | Role | Limitation relevant to execution |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| /tmp/jev-exec-study-b587759/src/index.ts | Runtime composition | no restore/open-order reconciliation |
|
||||
| /tmp/jev-exec-study-b587759/src/config.ts | Environment and defaults | limited range/enum validation |
|
||||
| /tmp/jev-exec-study-b587759/src/chain.ts | RPC, block WS/poll | no timeout/reorg; polls may overlap |
|
||||
| /tmp/jev-exec-study-b587759/src/book.ts | ABI decode and features | independent latest reads |
|
||||
| /tmp/jev-exec-study-b587759/src/model.ts | Jev and mock model | IOC prompt, post-only runtime; no calibration |
|
||||
| /tmp/jev-exec-study-b587759/src/market.ts | quote, signer, receipts, nonce, margin | unknown transaction state not reconciled |
|
||||
| /tmp/jev-exec-study-b587759/src/trader.ts | decision gate, orders, fills, PnL | receipt/fill races; busy is not tx serialization |
|
||||
| /tmp/jev-exec-study-b587759/src/trades.ts | trade-log polling | in-memory cursor, no reorg-safe dedupe |
|
||||
| /tmp/jev-exec-study-b587759/src/server.ts | snapshot/history/SSE | telemetry only |
|
||||
| /tmp/jev-exec-study-b587759/web/src/lib/useFeed.ts | EventSource client reducer | UI-live does not prove venue freshness |
|
||||
| /tmp/jev-exec-study-b587759/web/src/components/ | chart, decisions, stats and feed | presentation only |
|
||||
| /tmp/jev-exec-study-b587759/scripts/ | ABI/RPC probes and smoke scripts | not all scripts are offline/read-only |
|
||||
|
||||
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`.
|
||||
The UI consumes in-memory snapshots and SSE updates; it has no order command endpoint. A live SSE connection only proves UI-to-server reachability, not that model calls, book data, orders or venue truth are current.
|
||||
|
||||
---
|
||||
## Failure and recovery summary
|
||||
|
||||
## 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 |
|
||||
| Condition | Source behavior | Recovery in source |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| book/model stalls | block marked late; current routine remains busy | no age deadline or stale-result cancellation |
|
||||
| read/model/send error | log only; existing order map remains | no automatic cancel/reconcile |
|
||||
| node accepts tx but response is lost | hash may never enter pending | nonce resync on thrown request only |
|
||||
| multiple pending transactions | receipt polls run off-path | not serialized by busy |
|
||||
| no receipt for 10 blocks | removed as lost | no authoritative open-order lookup |
|
||||
| fill precedes receipt | position updates without local order | receipt can restore stale size |
|
||||
| unknown order ID in successful receipt | status may be placed without usable ID | no venue-state reconciliation |
|
||||
| trade log replay/reorg | cursor/ring are in memory | no stable dedupe/rollback |
|
||||
| process restart | orders, position and cursor reset | no restore/reconcile |
|
||||
| bad/un-calibrated Jev output | same-size order can still be posted | no quality gate |
|
||||
|
||||
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.
|
||||
## What is portable
|
||||
|
||||
---
|
||||
Jev's structured directional output and feature prompt are research inputs worth testing. Keep order identity, fill economics, unknown-state handling, cancellation truth, exposure reservation, exit authority and reconciliation under FLIGHT's existing execution owner. Treat Jev output as policy input. A separate bounded execution policy should decide price, post-only versus IOC, size and deadline.
|
||||
|
||||
## 10. What is actually attractive / portable from Jev’s exec
|
||||
For model evaluation, record input, raw output, model ID and latency. Score the stated directional horizon on every signal, whether or not it filled; separately score fill-conditional markout, fees/gas, queue age, cancel latency and inventory cost. Calibrate out of sample by regime and side before using probabilities for sizing or risk.
|
||||
|
||||
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.
|
||||
## Inventory and limitations
|
||||
|
||||
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).
|
||||
Pinned source: https://github.com/jarrodwatts/jev-trader/tree/b587759e459ea049590102e54a0b07800864cdc3
|
||||
Local clone: /tmp/jev-exec-study-b587759/
|
||||
Official Jev documentation: https://pydantic.dev/docs/ai/models/typesafe/
|
||||
|
||||
---
|
||||
|
||||
## 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` |
|
||||
The full tracked inventory is available with git -C /tmp/jev-exec-study-b587759 ls-files. Besides runtime code it includes package/lock/config metadata, docs, authoring instructions, RPC/ABI scripts and the Next.js dashboard. The lockfiles pin dependencies, not execution behavior. This static audit does not establish provider reliability, live Jev accuracy, Kuru economics, profitable fills or a deployed runtime configuration.
|
||||
|
||||
Reference in New Issue
Block a user