export const STUDY_COINS = ["BTC", "ETH", "SOL", "HYPE"] as const; export const STUDY_NOTIONALS = [1_000, 10_000, 100_000] as const; export const MAX_BOOK_AGE_MS = 30_000; export const MAX_FUTURE_CLOCK_SKEW_MS = 1_000; export const BOOK_LEVEL_CAP = 20; export const WALK_PRICE_BAND = 0.02; export const FILL_RELATIVE_TOLERANCE = 1e-9; export type StudyCoin = typeof STUDY_COINS[number]; export type OrderSide = "buy" | "sell"; export type BookLevel = { price: number; size: number; orders: number }; export type ParsedOrderBook = { coin: StudyCoin; serverTimeMs: number; retrievedAtMs: number; ageMs: number; bids: BookLevel[]; asks: BookLevel[]; mid: number; spreadBps: number; }; export type BookQuote = { coin: StudyCoin; side: OrderSide; notionalUsd: number; targetBase: number; filledBase: number; coverage: number; status: "complete" | "insufficient_visible_depth"; mid: number; spreadBps: number; vwap: number | null; adverseCostUsd: number | null; adverseCostBps: number | null; levelsUsed: number; cutoffReached: boolean; visibleLevelCapReached: boolean; }; export type StudyRequest = { endpoint: string; method: "POST"; body: { type: "l2Book"; coin: StudyCoin; nSigFigs: null }; }; export type StudySnapshot = { round: number; coin: StudyCoin; request: StudyRequest; requestStartedAt: string; retrievedAt: string; serverTime: string | null; httpStatus: number | null; serverDateHeader: string | null; rawResponse: unknown; error: string | null; quotes: BookQuote[]; }; export type MetricSummary = { median: number; min: number; max: number }; export type StudySummaryRow = { coin: StudyCoin; side: OrderSide; notionalUsd: number; completeCount: number; totalCount: number; spreadBps: MetricSummary | null; adverseCostBps: MetricSummary | null; adverseCostUsd: MetricSummary | null; }; export type OrderBookStudy = { schemaVersion: 1; studyDate: string; venue: "Hyperliquid"; marketType: "standard_perpetuals"; captureStartedAt: string; captureCompletedAt: string; methodology: { engineVersion: string; sourceUrl: string; endpoint: string; rounds: number; intervalMs: number; coins: readonly StudyCoin[]; notionalsUsd: readonly number[]; maxLevelsPerSide: number; aggregation: "none; nSigFigs=null"; maxBookAgeMs: number; maxFutureClockSkewMs: number; maxDistanceFromBestPrice: number; fillRelativeTolerance: number; targetBaseFormula: string; costFormula: string; aggregationRule: string; publicationGate: string; limitations: string[]; }; marketVerification: { request: { endpoint: string; method: "POST"; body: { type: "meta"; dex: "" } }; requestStartedAt: string; retrievedAt: string; httpStatus: number; rawResponse: unknown; verifiedCoins: StudyCoin[]; }; snapshots: StudySnapshot[]; summary: StudySummaryRow[]; errors: { round: number; coin: StudyCoin; message: string }[]; }; function requireCondition(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } function record(value: unknown, label: string): Record { requireCondition(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); return value as Record; } function positiveDecimal(value: unknown, label: string): number { requireCondition(typeof value === "number" || (typeof value === "string" && /^(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?$/iu.test(value)), `${label} must be a decimal number`); const parsed = Number(value); requireCondition(Number.isFinite(parsed) && parsed > 0, `${label} must be finite and positive`); return parsed; } export function parseOrderBook(raw: unknown, expectedCoin: StudyCoin, retrievedAtMs: number): ParsedOrderBook { requireCondition(STUDY_COINS.includes(expectedCoin), "Unsupported study coin"); requireCondition(Number.isSafeInteger(retrievedAtMs) && retrievedAtMs > 0, "Invalid retrieval timestamp"); const data = record(raw, "Order book"); requireCondition(data.coin === expectedCoin, `Wrong coin: expected ${expectedCoin}, received ${data.coin}`); requireCondition(typeof data.time === "number" && Number.isSafeInteger(data.time) && data.time > 0, "Invalid server timestamp"); const ageMs = retrievedAtMs - data.time; requireCondition(ageMs <= MAX_BOOK_AGE_MS, `Stale book: ${ageMs} ms old`); requireCondition(ageMs >= -MAX_FUTURE_CLOCK_SKEW_MS, "Book timestamp is implausibly in the future"); requireCondition(Array.isArray(data.levels) && data.levels.length === 2, "Expected exactly two book sides"); const sides = data.levels.map((side, sideIndex) => { requireCondition(Array.isArray(side) && side.length > 0 && side.length <= BOOK_LEVEL_CAP, "Book side must contain 1–20 visible levels"); const parsed = side.map((value, index) => { const level = record(value, `Level ${sideIndex}:${index}`); requireCondition(typeof level.n === "number" && Number.isSafeInteger(level.n) && level.n > 0, "Invalid level order count"); return { price: positiveDecimal(level.px, "Price"), size: positiveDecimal(level.sz, "Size"), orders: level.n }; }); for (let index = 1; index < parsed.length; index += 1) { const ordered = sideIndex === 0 ? parsed[index].price < parsed[index - 1].price : parsed[index].price > parsed[index - 1].price; requireCondition(ordered, "Unsorted or duplicate price levels"); } return parsed; }); const [bids, asks] = sides; requireCondition(bids[0].price < asks[0].price, "Crossed or locked book"); const mid = bids[0].price + (asks[0].price - bids[0].price) / 2; const spreadBps = (asks[0].price - bids[0].price) / mid * 10_000; requireCondition(Number.isFinite(mid) && mid > 0 && Number.isFinite(spreadBps), "Nonfinite book metrics"); return { coin: expectedCoin, serverTimeMs: data.time, retrievedAtMs, ageMs, bids, asks, mid, spreadBps }; } /** Midpoint target size; cost includes crossing the spread once and excludes fees. */ export function quoteOrderBook(book: ParsedOrderBook, side: OrderSide, notionalUsd: number): BookQuote { requireCondition(side === "buy" || side === "sell", "Invalid order side"); requireCondition(Number.isFinite(notionalUsd) && notionalUsd > 0, "Notional must be finite and positive"); const targetBase = notionalUsd / book.mid; requireCondition(Number.isFinite(targetBase) && targetBase > 0, "Target base size is outside numerical range"); const levels = side === "buy" ? book.asks : book.bids; const direction = side === "buy" ? 1 : -1; const cutoff = levels[0].price * (1 + direction * WALK_PRICE_BAND); // A few ULPs only reconcile decimal endpoints such as 99 * 0.98 == 97.02. const boundaryTolerance = Number.EPSILON * levels[0].price * 4; let filledBase = 0; let adverseValue = 0; let levelsUsed = 0; let cutoffReached = false; for (const level of levels) { const remaining = Math.max(0, targetBase - filledBase); if (remaining === 0) break; if (direction * (level.price - cutoff) > boundaryTolerance) { cutoffReached = true; break; } const take = Math.min(remaining, level.size); filledBase += take; adverseValue += take * direction * (level.price - book.mid); levelsUsed += 1; } requireCondition(Number.isFinite(filledBase) && Number.isFinite(adverseValue) && adverseValue >= 0, "Nonfinite or negative walk result"); const complete = Math.max(0, targetBase - filledBase) <= targetBase * FILL_RELATIVE_TOLERANCE; const coverage = Math.min(1, filledBase / targetBase); const adversePerBase = complete ? adverseValue / filledBase : null; const vwap = adversePerBase === null ? null : book.mid + direction * adversePerBase; const adverseCostUsd = adversePerBase === null ? null : adversePerBase * targetBase; const adverseCostBps = adverseCostUsd === null ? null : adverseCostUsd / notionalUsd * 10_000; requireCondition([vwap, adverseCostUsd, adverseCostBps].every((value) => value === null || Number.isFinite(value)), "Nonfinite quote result"); return { coin: book.coin, side, notionalUsd, targetBase, filledBase, coverage, status: complete ? "complete" : "insufficient_visible_depth", mid: book.mid, spreadBps: book.spreadBps, vwap, adverseCostUsd, adverseCostBps, levelsUsed, cutoffReached, visibleLevelCapReached: !complete && !cutoffReached && levelsUsed === BOOK_LEVEL_CAP, }; } function summarize(values: number[]): MetricSummary | null { if (values.length === 0) return null; requireCondition(values.every(Number.isFinite), "Nonfinite summary input"); const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); const median = sorted.length % 2 ? sorted[middle] : sorted[middle - 1] + (sorted[middle] - sorted[middle - 1]) / 2; return { median, min: sorted[0], max: sorted[sorted.length - 1] }; } export function aggregateOrderBookStudy(snapshots: readonly StudySnapshot[]): StudySummaryRow[] { requireCondition(snapshots.length > 0, "No snapshots to aggregate"); const seen = new Set(); for (const snapshot of snapshots) { const key = `${snapshot.coin}:${snapshot.round}`; requireCondition(STUDY_COINS.includes(snapshot.coin) && Number.isInteger(snapshot.round) && snapshot.round > 0, "Invalid snapshot identity"); requireCondition(!seen.has(key), `duplicate snapshot: ${key}`); seen.add(key); if (snapshot.error !== null) { requireCondition(snapshot.quotes.length === 0, "Failed snapshots cannot provide quotes"); continue; } const quoteKeys = new Set(snapshot.quotes.map((quote) => `${quote.side}:${quote.notionalUsd}`)); requireCondition(snapshot.quotes.length === 6 && quoteKeys.size === 6, "Each valid snapshot must contain six unique quotes"); for (const quote of snapshot.quotes) { requireCondition(quote.coin === snapshot.coin && (quote.side === "buy" || quote.side === "sell") && STUDY_NOTIONALS.some((size) => size === quote.notionalUsd), "Quote does not match its snapshot or study sizes"); } } return STUDY_COINS.flatMap((coin) => { const coinSnapshots = snapshots.filter((snapshot) => snapshot.coin === coin); return (["buy", "sell"] as const).flatMap((side) => STUDY_NOTIONALS.map((notionalUsd) => { const complete = coinSnapshots.flatMap((snapshot) => snapshot.quotes) .filter((quote) => quote.side === side && quote.notionalUsd === notionalUsd && quote.status === "complete"); const values = (key: "spreadBps" | "adverseCostBps" | "adverseCostUsd") => complete.map((quote) => { const value = quote[key]; requireCondition(value !== null && Number.isFinite(value) && value >= 0, "Complete quote has missing or invalid costs"); return value; }); return { coin, side, notionalUsd, completeCount: complete.length, totalCount: coinSnapshots.length, spreadBps: summarize(values("spreadBps")), adverseCostBps: summarize(values("adverseCostBps")), adverseCostUsd: summarize(values("adverseCostUsd")) }; })); }); }