From c0802fe5d8d8ce770d5dc01420d7ec0138e35cb4 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 6 Sep 2026 09:18:26 -0400 Subject: [PATCH] fix(oracle): price from the live book, and expire stale prints The feed read $0.008546 while cross-venue consensus was $0.009280, low by 8.6%, and wowdesk's fair guard is anchored to it: the desk was refusing to bid at prices the market was actually trading, because the feed did not believe those prices existed. Two causes, both last-trade artefacts. Nonlogs WOW-BTC last traded at 10 sat while its book stood 10 bid, 13 ask, so the live midpoint was 11.5 and we were quoting a print instead. And CexSwap's WOW-XMR sample, which carries $635 of seven-day volume against Nonlogs' $169 and therefore dominates the average, was the 24-hour low and 11.5 hours old. Nonlogs now prefers the midpoint of the best bid and ask, guarded: both sides present, ask above bid, spread at or under 60%, else fall back to the last trade. CexSwap publishes no book, so the only guard available there is age, and a print older than 12 hours no longer anchors the feed. Measured against live venues: Nonlogs moves $0.007989 to $0.009187, three dead CexSwap pairs expire, and the feed as a whole moves $0.008546 to $0.008796. GET /v1/oracle now returns the method, the guard values and the known limits next to the rates, served by the code that implements them so the description cannot drift from the behaviour. README carries the same in prose. --- README.md | 58 +++++++++++++++++++++++ src/index.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fed7062..5ae17f6 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,61 @@ curl 'localhost:8787/v2/rates?base=BTC"e=USD' ## License MIT. + +## The oracle + +`GET /v1/oracle` returns the current rates alongside the method that produced +them, the guard values, and the limits we know about. It is served by the code +that implements it, so it cannot drift from the truth the way a separate +document would. + +### What it prices, and from what + +| Source | Covers | Price basis | +| --- | --- | --- | +| Kraken | majors | spot ticker | +| Nonlogs | niche tickers, BTC and USDT routes | live book midpoint, falling back to last trade | +| CexSwap | niche tickers, all quote routes | last trade in USD, weighted by 7-day USD volume, expiring after 12 hours | + +Niche tickers are volume-weighted across the venues that produce a usable +number. + +### Why the book midpoint, and not the last trade + +A last print says where somebody traded once. On a market doing a few hundred +dollars a day that can be many hours old, and it can sit at the day's extreme. +The midpoint of the current best bid and ask says where you could trade now, +which is what a price feed is being asked. + +The case that prompted the change, on 2026-09-06: Nonlogs WOW-BTC last traded +at 10 sat while its book stood 10 bid, 13 ask. The feed said $0.007989; the +market said $0.009187. Meanwhile CexSwap's WOW-XMR print, carrying $635 of +seven-day volume against Nonlogs' $169, was the 24-hour low and 11.5 hours old. +The feed as a whole read $0.008546 against a cross-venue consensus of $0.009280, +low by 8.6%, and a desk quoting against it refused to bid where the market +actually traded. + +### Guards + +- **Spread.** A midpoint is only meaningful across a real two-sided book, so + both sides must exist, the ask must be above the bid, and the spread must be + at or under 60%. Otherwise the last trade is used. 60% is deliberately loose: + on these venues a 30% spread is ordinary, while dead pairs show books wider + than 1000% that must never set a price. +- **Staleness.** A CexSwap print older than 12 hours is dropped. Its summary + carries no book, so staleness is the only guard available there, and seven + days of volume can be a single old fill. +- **Dead pairs.** Any pair with no volume is ignored entirely. Without this, a + stale print on an untraded pair drags the average; WOW-ETH sat at $0.000087 + and WOW-USDT at $0.070 on the same day the coin was worth about $0.009. + +### What it does not do + +- A midpoint is not depth-weighted. It says where the touch is, not what size + can trade there. +- CexSwap pool prices are not read directly. Only its last trade is available + without an authenticated quote, so between trades that source lags the pool + it describes. +- No source is excluded for being one we quote on ourselves. This is a public + price index and it prices the market as it finds it; a consumer that needs to + exclude its own influence has to do that itself. diff --git a/src/index.ts b/src/index.ts index 7358db9..900ea72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,21 @@ async function fetchEcbFiat(): Promise> { return out; } +/** + * A book wider than this has no meaningful midpoint, so we use the last trade + * instead. 60% is deliberately loose: these are thin markets and a 30% spread + * is a normal Tuesday, but the 1000%+ books that appear on dead pairs must + * never set a price. + */ +const MAX_BOOK_SPREAD_PCT = 60; + +/** + * A cexswap `last` print older than this is not evidence of the current price. + * Its summary carries no book, only the last trade, so staleness is the only + * guard available on that source. + */ +const MAX_LAST_TRADE_AGE_HOURS = 12; + async function fetchKraken(): Promise> { const pairs = Object.values(KRAKEN_PAIRS).join(","); const url = `https://api.kraken.com/0/public/Ticker?pair=${pairs}`; @@ -136,7 +151,12 @@ async function fetchNonlogs(btcUsd: number): Promise> { cf: { cacheTtl: 30, cacheEverything: true }, }); if (!res.ok) throw new Error(`nonlogs http ${res.status}`); - type NonlogsRow = { last_price?: string | null; quote_volume?: string | null }; + type NonlogsRow = { + last_price?: string | null; + quote_volume?: string | null; + highest_bid?: string | null; + lowest_ask?: string | null; + }; const body = (await res.json()) as { markets?: Record }; const markets = body.markets ?? {}; @@ -145,19 +165,45 @@ async function fetchNonlogs(btcUsd: number): Promise> { return Number.isFinite(n) ? n : 0; }; + /** + * Prefer the live book midpoint over the last trade. + * + * A last print says where somebody traded once; on a market doing a few + * hundred dollars a day that can be hours old and sitting at the day's low. + * The midpoint of the current best bid and ask says where you could trade + * now, which is what a price feed is asked for. WOW-BTC on 2026-09-06 is the + * case in point: last 10 sat, book 10 bid / 13 ask, mid 11.5. + * + * Guards, because a midpoint is only meaningful across a real two-sided + * book: both sides must exist, the ask must be above the bid, and the spread + * must be under MAX_BOOK_SPREAD_PCT. A 1000%-wide book has a midpoint that + * means nothing, and on these venues that is common. When any guard fails we + * fall back to the last trade rather than dropping the market. + */ + const liveOrLast = (row: NonlogsRow | undefined): number => { + const bid = num(row?.highest_bid); + const ask = num(row?.lowest_ask); + const last = num(row?.last_price); + if (bid > 0 && ask > bid) { + const mid = (bid + ask) / 2; + if (((ask - bid) / mid) * 100 <= MAX_BOOK_SPREAD_PCT) return mid; + } + return last; + }; + for (const ticker of NICHE_TICKERS) { // [usd price, weight in USD] per route. const samples: Array<[number, number]> = []; const btcRow = markets[`${ticker}-BTC`]; - const btcPrice = num(btcRow?.last_price); + const btcPrice = liveOrLast(btcRow); const btcVol = num(btcRow?.quote_volume); // volume in BTC if (btcPrice > 0 && btcVol > 0) { samples.push([btcPrice * btcUsd, btcVol * btcUsd]); } const usdtRow = markets[`${ticker}-USDT`]; - const usdtPrice = num(usdtRow?.last_price); + const usdtPrice = liveOrLast(usdtRow); const usdtVol = num(usdtRow?.quote_volume); // volume in USDT ≈ USD if (usdtPrice > 0 && usdtVol > 0) { samples.push([usdtPrice, usdtVol]); @@ -186,7 +232,12 @@ async function fetchCexswap(): Promise> { cf: { cacheTtl: 30, cacheEverything: true }, }); if (!res.ok) throw new Error(`cexswap http ${res.status}`); - type CexswapRow = { base?: string; last_usd?: number | string; volume7d_usd?: number | string }; + type CexswapRow = { + base?: string; + last_usd?: number | string; + volume7d_usd?: number | string; + last_trade_at_unix?: number | string; + }; const body = (await res.json()) as { items?: CexswapRow[] } | CexswapRow[]; const items = Array.isArray(body) ? body : (body.items ?? []); @@ -199,7 +250,14 @@ async function fetchCexswap(): Promise> { if (!NICHE_TICKERS.includes(base as (typeof NICHE_TICKERS)[number])) continue; const usd = num(m.last_usd); const weight = num(m.volume7d_usd); - if (usd > 0 && weight > 0) { + // cexswap publishes no book, so the last trade is all we get and staleness + // is the only guard we can apply. Seven days of volume can be a single old + // fill, and on 2026-09-06 WOW-XMR's last print was the 24h LOW while the + // pool itself had moved 13% above it. An old print is not evidence of the + // current price, and this source outweighs every other, so it must expire. + const tradedAt = num(m.last_trade_at_unix as string | undefined); + const ageHours = tradedAt > 0 ? (Date.now() / 1000 - tradedAt) / 3600 : Infinity; + if (usd > 0 && weight > 0 && ageHours <= MAX_LAST_TRADE_AGE_HOURS) { (buckets[base] ??= []).push({ usd, weight }); } } @@ -390,6 +448,66 @@ export default { return jsonResponse({ error: "not found" }, 404); } + // The oracle, described by the thing that implements it. A price feed that + // cannot say how it arrived at a number is asking to be trusted rather than + // checked, and this one gates real quoting decisions. + if (url.pathname === "/v1/oracle") { + const payload = await loadRates(env); + return jsonResponse({ + schema: "wow-oracle/v1", + fetched_at: payload.fetched_at, + method: + "Majors are Kraken spot. Niche tickers are volume-weighted across " + + "Nonlogs and CexSwap. Nonlogs prefers the live book midpoint over " + + "the last trade; CexSwap publishes no book, so its last trade is " + + "used and expires.", + sources: [ + { + venue: "kraken", + covers: "majors", + price_basis: "spot ticker", + }, + { + venue: "nonlogs", + covers: "niche tickers, BTC and USDT routes", + price_basis: + "midpoint of best bid and best ask when both sides exist, the " + + "ask is above the bid, and the spread is at or under " + + `${MAX_BOOK_SPREAD_PCT}%; otherwise the last trade`, + why: + "A last print says where somebody traded once. On a market doing " + + "a few hundred dollars a day that can be hours old and sitting at " + + "the day's low. The midpoint says where you could trade now.", + }, + { + venue: "cexswap", + covers: "niche tickers, all quote routes", + price_basis: "last trade in USD, weighted by 7-day USD volume", + expiry_hours: MAX_LAST_TRADE_AGE_HOURS, + why: + "This summary carries no order book, so staleness is the only " + + "guard available. Seven days of volume can be one old fill, and " + + "this source can outweigh every other, so an old print expires " + + "rather than anchoring the feed.", + }, + ], + guards: { + max_book_spread_pct: MAX_BOOK_SPREAD_PCT, + max_last_trade_age_hours: MAX_LAST_TRADE_AGE_HOURS, + zero_volume_pairs: "ignored entirely", + }, + known_limits: [ + "A venue midpoint is not a depth-weighted price: it says where the " + + "touch is, not what size can trade there.", + "CexSwap pool prices are not read directly; only its last trade is " + + "available without an authenticated quote, so between trades this " + + "source lags the pool it describes.", + "No source is excluded for being one we ourselves quote on.", + ], + rates: payload.rates, + }); + } + if (url.pathname === "/" || url.pathname === "/healthz") { const payload = await loadRates(env); return jsonResponse({