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({