x402-perpcheck / tree / src/engine.js ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// PerpCheck engine: pre-trade order check for Hyperliquid perps. Read-only analytics: never places, signs or holds anything.
// Input: { coin, side: 'long'|'short', size? (coins) | notional? (USD), price? (limit; omit = market), leverage, margin: 'cross'|'isolated', reduceOnly? }
// Output: verdict REJECT_LIKELY | CAUTION | OK, with exchange-rule checks (would Hyperliquid reject it?), estimated isolated
// liquidation price + distance, taker impact at size from the live L2 book, funding carry, OI-cap / delisted status.
// Pure functions take already-fetched data so they are unit-testable offline; fetchMarket() does the 4 public info calls.

import { VERSION_STR } from './version.js';
export const MIN_NOTIONAL = 10;          // USD, Hyperliquid minimum order value
export const MAX_DECIMALS_PERP = 6;      // price decimals limit = 6 - szDecimals
export const ENGINE_VERSION = `perpcheck-${VERSION_STR}`;
export const USER_AGENT = `PerpCheck/${VERSION_STR} (+https://api.youraitools.co/perpcheck; read-only pre-trade check; hello@youraitools.co)`;
export const DISCLAIMER = 'Not financial advice; the exchange decides whether an order is accepted. PerpCheck is a read-only pre-trade check: it never places, signs or holds orders, has no custody and never asks for keys.';
export const LIMITS = [
  'Hyperliquid main-dex perps only (HIP-3 builder dexes such as xyz:META are not supported yet).',
  'Rules checked: size decimals (szDecimals), price tick (5 significant figures and 6 - szDecimals decimals; integers always allowed), $10 minimum order value, max leverage by margin tier, isolated-only coins, open-interest cap, delisted coins and limits far from mark.',
  'Liquidation is the isolated-margin estimate before fees and funding; for cross margin it is the isolated-equivalent worst case.',
  'Taker impact walks the live public L2 book (20 levels per side) for market orders only.',
  'Fail-closed: if a needed public source fails or times out the answer is never OK; it lists sources_failed and lowers confidence. If market metadata is unavailable the request fails with 503 and is not charged.',
];
export class InputError extends Error { constructor(m, status = 400, code = 'bad_request') { super(m); this.status = status; this.code = code; } }
export class Incomplete extends Error { constructor(message, sources_failed = []) { super(message); this.code = 'upstream_unavailable'; this.sources_failed = sources_failed; } }

const num = (v) => (v === undefined || v === null || v === '' ? undefined : Number(v));

export function parseOrder(q) {
  const coin = String(q.coin ?? q.symbol ?? '').trim();
  if (!coin) throw new InputError('"coin" is required, e.g. BTC');
  if (typeof (q.coin ?? q.symbol) !== 'string') throw new InputError('"coin" must be a string, e.g. "BTC"');
  if (coin.includes(':')) throw new InputError('HIP-3 builder-dex perps (dex:COIN) are not supported yet; use a main-dex perp such as BTC, ETH or kPEPE');
  if (!/^[A-Za-z0-9]{1,20}$/.test(coin)) throw new InputError('"coin" must be a Hyperliquid perp name, e.g. BTC, ETH, kPEPE');
  const side = String(q.side ?? '').toLowerCase();
  const s = side === 'long' || side === 'buy' ? 1 : side === 'short' || side === 'sell' ? -1 : 0;
  if (!s) throw new InputError('"side" must be long or short');
  const size = num(q.size), notional = num(q.notional), price = num(q.price), leverage = num(q.leverage ?? 1);
  if (size === undefined && notional === undefined) throw new InputError('give "size" (in coins) or "notional" (USD)');
  for (const [k, v] of Object.entries({ size, notional, price, leverage })) if (v !== undefined && !(Number.isFinite(v) && v > 0 && v < 1e15)) throw new InputError(`"${k}" must be a positive number`);
  for (const k of ['size', 'notional', 'price', 'leverage']) if (q[k] !== undefined && q[k] !== '' && !['string', 'number'].includes(typeof q[k])) throw new InputError(`"${k}" must be a number`);
  if (leverage > 1000) throw new InputError('"leverage" must be 1000 or less');
  const ro = q.reduceOnly ?? q.reduce_only;
  if (ro !== undefined && ro !== '' && !['true', 'false', true, false].includes(ro)) throw new InputError('"reduceOnly" must be true or false');
  const margin = String(q.margin || 'cross').toLowerCase();
  if (!['cross', 'isolated'].includes(margin)) throw new InputError('"margin" must be cross or isolated');
  return { coin, side: s, size, notional, price, leverage, margin, reduceOnly: String(q.reduceOnly ?? q.reduce_only ?? '') === 'true', rawPrice: q.price === undefined ? undefined : String(q.price), rawSize: q.size === undefined ? undefined : String(q.size) };
}

const decimals = (s) => { const t = String(s); if (/e/i.test(t)) return 99; const i = t.indexOf('.'); return i < 0 ? 0 : t.length - i - 1 - (t.match(/0+$/)?.[0].length ?? 0) * (t.includes('.') ? 1 : 0); };
const sigFigs = (s) => { const t = String(s).replace('-', '').replace('.', '').replace(/^0+/, '').replace(/0+$/, ''); return t.length; };

/** Hyperliquid price rule: integer prices always OK; otherwise <= 5 significant figures AND <= (6 - szDecimals) decimals. */
export function priceValid(raw, szDecimals) {
  const t = String(raw);
  if (/^\d+$/.test(t)) return { ok: true };
  const d = decimals(t), sf = sigFigs(t), maxD = MAX_DECIMALS_PERP - szDecimals;
  if (d > maxD) return { ok: false, reason: `price has ${d} decimals; max for this coin is ${maxD}` };
  if (sf > 5) return { ok: false, reason: `price has ${sf} significant figures; max is 5 (unless it is an integer)` };
  return { ok: true };
}
export function roundPrice(p, szDecimals) {
  if (Number.isInteger(p)) return p;
  const maxD = MAX_DECIMALS_PERP - szDecimals;
  return Number(Number(p.toPrecision(5)).toFixed(maxD));
}
export function sizeValid(raw, szDecimals) {
  const d = decimals(raw);
  return d > szDecimals ? { ok: false, reason: `size has ${d} decimals; this coin allows ${szDecimals} (szDecimals)` } : { ok: true };
}

export function tierMaxLeverage(table, maxLeverage, notional) {
  const tiers = table?.marginTiers?.length ? table.marginTiers : [{ lowerBound: '0', maxLeverage }];
  let lev = maxLeverage;
  for (const t of tiers) if (notional >= Number(t.lowerBound)) lev = Math.min(maxLeverage, Number(t.maxLeverage));
  return lev;
}

/** Isolated liq price per Hyperliquid docs: liq = price - side * margin_available / size / (1 - l * side), l = 1 / maintenance leverage (= 2 x max leverage). */
export function isolatedLiq(entry, side, size, leverage, maxLev) {
  const notional = entry * size, l = 1 / (2 * maxLev);
  const marginAvail = notional / leverage - notional * l;
  if (marginAvail <= 0) return { price: entry, immediate: true };
  return { price: entry - side * marginAvail / size / (1 - l * side), immediate: false };
}

/** Walk the L2 book: average fill and impact (bps vs mid) for a taker order of `size` coins. */
export function bookImpact(book, side, size) {
  const levels = side > 0 ? book.levels[1] : book.levels[0]; // buy lifts asks, sell hits bids
  const bid = Number(book.levels[0][0]?.px), ask = Number(book.levels[1][0]?.px), mid = (bid + ask) / 2;
  let left = size, cost = 0;
  for (const l of levels) { const take = Math.min(left, Number(l.sz)); cost += take * Number(l.px); left -= take; if (left <= 1e-12) break; }
  const filled = size - Math.max(left, 0);
  const avg = filled > 0 ? cost / filled : NaN;
  return { mid, spread_bps: ((ask - bid) / mid) * 1e4, avg_fill: avg, impact_bps: filled > 0 ? (side * (avg - mid) / mid) * 1e4 : null, filled_fraction: filled / size, book_levels: levels.length };
}

export function check(order, m) {
  // m: { meta, ctx, book, oiCap:Set, now }
  const issues = [], warns = [], info = {};
  const u = m.meta.universe.findIndex((x) => x.name.toLowerCase() === order.coin.toLowerCase());
  if (u < 0) {
    const near = m.meta.universe.filter((x) => !x.isDelisted && (x.name.toLowerCase().includes(order.coin.toLowerCase()) || order.coin.toLowerCase().includes(x.name.toLowerCase()))).slice(0, 5).map((x) => x.name);
    throw new InputError(`no Hyperliquid main-dex perp named ${order.coin}; an order for it would be rejected${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`, 404, 'unknown_coin');
  }
  const a = m.meta.universe[u], ctx = m.ctx?.[u], coin = a.name;
  const mark = Number(ctx?.markPx), oracle = Number(ctx?.oraclePx);
  if (!(mark > 0) || !Number.isFinite(oracle)) throw new Incomplete(`Hyperliquid returned no usable mark price for ${coin}; not charged, retry shortly`, ['metaAndAssetCtxs']);
  const failed = [...(m.failed || [])];
  const px = order.price ?? mark;
  const size = order.size ?? order.notional / px;
  const notional = size * px;
  if (a.isDelisted) issues.push({ code: 'delisted', msg: `${coin} is delisted on Hyperliquid; new orders are rejected` });
  if (!m.oiCap) warns.push({ code: 'oi_cap_unknown', msg: 'the open-interest cap list could not be read, so an OI-cap reject cannot be ruled out' });
  else if (m.oiCap.has(coin) && !order.reduceOnly) issues.push({ code: 'oi_cap', msg: `${coin} is at its open-interest cap; orders that add exposure are rejected` });
  if (notional < MIN_NOTIONAL && !order.reduceOnly) issues.push({ code: 'min_notional', msg: `order value $${notional.toFixed(2)} is below Hyperliquid's $${MIN_NOTIONAL} minimum` });
  if (order.rawSize !== undefined) { const v = sizeValid(order.rawSize, a.szDecimals); if (!v.ok) issues.push({ code: 'size_decimals', msg: v.reason, fix: Number(size.toFixed(a.szDecimals)) }); }
  if (order.rawPrice !== undefined) { const v = priceValid(order.rawPrice, a.szDecimals); if (!v.ok) issues.push({ code: 'price_tick', msg: v.reason, fix: roundPrice(order.price, a.szDecimals) }); }
  if (a.onlyIsolated && order.margin === 'cross') issues.push({ code: 'isolated_only', msg: `${coin} only supports isolated margin` });
  const table = (m.meta.marginTables || []).find(([id]) => id === a.marginTableId)?.[1];
  const maxLev = tierMaxLeverage(table, a.maxLeverage, notional);
  if (order.leverage > maxLev) issues.push({ code: 'leverage', msg: `leverage ${order.leverage}x is above the ${maxLev}x maximum for this coin at $${Math.round(notional)} notional`, fix: maxLev });
  if (order.price !== undefined) {
    const dev = (order.price - mark) / mark;
    if (Math.abs(dev) > 0.8) issues.push({ code: 'price_band', msg: `limit price is ${(dev * 100).toFixed(1)}% from mark; Hyperliquid rejects orders far from the oracle/mark` });
    else if (order.side * dev > 0.01) warns.push({ code: 'marketable_limit', msg: `limit ${order.side > 0 ? 'buy above' : 'sell below'} mark by ${(Math.abs(dev) * 100).toFixed(2)}%: fills immediately as taker` });
  }
  const lev = Math.min(order.leverage, maxLev);
  const liq = isolatedLiq(px, order.side, size, lev, a.maxLeverage);
  info.liquidation = { estimate_isolated: Number(liq.price.toPrecision(6)), distance_pct: Number(((Math.abs(px - liq.price) / px) * 100).toFixed(2)), note: order.margin === 'cross' ? 'cross margin: the real liquidation price depends on your whole account; this is the isolated-equivalent worst case' : 'isolated margin, before fees and funding' };
  if (liq.immediate) issues.push({ code: 'immediate_liq', msg: 'leverage leaves no margin above maintenance' });
  else if (info.liquidation.distance_pct < 2) warns.push({ code: 'tight_liq', msg: `liquidation only ${info.liquidation.distance_pct}% away` });
  if (order.price === undefined && !m.book) warns.push({ code: 'impact_unknown', msg: 'the L2 order book could not be read, so taker impact and book depth are unknown' });
  if (m.book && order.price === undefined) {
    const im = bookImpact(m.book, order.side, size);
    info.execution = { mid: im.mid, spread_bps: Number(im.spread_bps.toFixed(2)), est_avg_fill: Number(im.avg_fill.toPrecision(6)), est_impact_bps: im.impact_bps === null ? null : Number(im.impact_bps.toFixed(2)), book_depth_covered: Number(im.filled_fraction.toFixed(4)), book_time: m.book.time };
    if (im.filled_fraction < 1) warns.push({ code: 'thin_book', msg: `the visible book (${im.book_levels} levels) covers only ${(im.filled_fraction * 100).toFixed(1)}% of this size` });
    else if (im.impact_bps > 50) warns.push({ code: 'high_impact', msg: `estimated market impact ${im.impact_bps.toFixed(0)} bps at this size` });
  }
  const f = Number(ctx.funding); // hourly rate
  info.funding = { hourly_rate: f, apr_pct: Number((f * 24 * 365 * 100).toFixed(2)), est_cost_per_day_usd: Number((order.side * f * 24 * notional).toFixed(4)), direction: order.side * f > 0 ? 'you pay' : order.side * f < 0 ? 'you receive' : 'zero' };
  if (order.side * f * 24 * 365 > 0.5) warns.push({ code: 'expensive_carry', msg: `funding costs about ${(f * 24 * 365 * 100).toFixed(0)}% APR on this side` });
  info.market = { mark, oracle, premium_bps: Number((((mark - oracle) / oracle) * 1e4).toFixed(2)), open_interest_usd: Math.round(Number(ctx.openInterest) * mark), day_volume_usd: Math.round(Number(ctx.dayNtlVlm)), size_vs_day_volume_pct: Number(((notional / Math.max(Number(ctx.dayNtlVlm), 1)) * 100).toFixed(4)) };
  if (notional > 0.01 * Number(ctx.dayNtlVlm)) warns.push({ code: 'large_vs_volume', msg: 'order is over 1% of 24 h volume' });
  const rules = { szDecimals: a.szDecimals, price_max_decimals: MAX_DECIMALS_PERP - a.szDecimals, price_max_sig_figs: 5, min_notional_usd: MIN_NOTIONAL, max_leverage: a.maxLeverage, max_leverage_at_size: maxLev, only_isolated: !!a.onlyIsolated };
  // Fail-closed: a missing source can never yield OK (verdict at least CAUTION); confidence drops with what is missing.
  const verdict = issues.length ? 'REJECT_LIKELY' : (warns.length || failed.length) ? 'CAUTION' : 'OK';
  const confidence = failed.includes('perpsAtOpenInterestCap') ? 'low' : failed.length ? 'medium' : 'high';
  return { coin, side: order.side > 0 ? 'long' : 'short', order_type: order.price === undefined ? 'market' : 'limit', size: Number(size.toFixed(a.szDecimals)), notional_usd: Number(notional.toFixed(2)), leverage: order.leverage, margin: order.margin, reduce_only: order.reduceOnly, verdict, issues, warnings: warns, rules, ...info, confidence, sources_failed: failed, checked_at: new Date(m.now ?? Date.now()).toISOString(), disclaimer: DISCLAIMER };
}

// ---- public Hyperliquid info API (read-only), with fallbacks and tight timeouts ----
// Attempts per call: primary api.hyperliquid.xyz, then api-ui.hyperliquid.xyz, then the primary once more (shorter).
export const HL_HOSTS = ['https://api.hyperliquid.xyz/info', 'https://api-ui.hyperliquid.xyz/info'];
const ATTEMPTS = [[HL_HOSTS[0], 3500], [HL_HOSTS[1], 3500], [HL_HOSTS[0], 2500]];
async function info(body, { fetchImpl = globalThis.fetch, validate = () => true } = {}) {
  let last;
  for (const [url, ms] of ATTEMPTS) {
    const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), ms);
    try {
      const r = await Promise.race([
        fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT }, body: JSON.stringify(body), signal: ctl.signal }),
        new Promise((_, rej) => ctl.signal.addEventListener('abort', () => rej(new Error(`timeout after ${ms} ms`)))),
      ]);
      if (r.ok) { const j = await r.json(); if (validate(j)) return j; last = new Error('unexpected response shape'); } else { last = new Error(`HTTP ${r.status}`); try { await r.body?.cancel(); } catch { /* ignore */ } }
    } catch (e) { last = e; } finally { clearTimeout(t); }
  }
  throw Object.assign(new Error(`hyperliquid info ${body.type} unavailable: ${last?.message}`), { source: body.type });
}
// Shared 10 s per-isolate cache for the market-wide calls (one upstream read serves every coin).
const CACHE = new Map();
export function _clearCache() { CACHE.clear(); }
function cached(key, ms, now, fn) {
  const hit = CACHE.get(key);
  if (hit && now - hit.at < ms) return hit.p;
  const p = fn(); CACHE.set(key, { at: now, p });
  p.catch(() => { if (CACHE.get(key)?.p === p) CACHE.delete(key); });
  return p;
}
const isMac = (j) => Array.isArray(j) && j.length >= 2 && Array.isArray(j[0]?.universe) && Array.isArray(j[1]) && j[0].universe.length === j[1].length;
const isBook = (j) => j && Array.isArray(j.levels) && j.levels.length === 2 && Array.isArray(j.levels[0]) && Array.isArray(j.levels[1]);

/** Fetch what one order check needs. Throws Incomplete (503, never charged) if market metadata is unavailable;
 *  the OI-cap list and the L2 book are optional sources: a failure is listed in `failed` (never treated as OK). */
export async function fetchMarket(coin, { fetchImpl = globalThis.fetch, now = Date.now(), needBook = true } = {}) {
  const o = { fetchImpl };
  const [mac, oi, book] = await Promise.allSettled([
    cached('mac', 10_000, now, () => info({ type: 'metaAndAssetCtxs' }, { ...o, validate: isMac })),
    cached('oi', 10_000, now, () => info({ type: 'perpsAtOpenInterestCap' }, { ...o, validate: Array.isArray })),
    needBook ? info({ type: 'l2Book', coin }, { ...o, validate: isBook }) : Promise.resolve(null),
  ]);
  if (mac.status !== 'fulfilled') throw new Incomplete('Hyperliquid market metadata (metaAndAssetCtxs) is unavailable from every endpoint; not charged, retry shortly', ['metaAndAssetCtxs', ...(oi.status !== 'fulfilled' ? ['perpsAtOpenInterestCap'] : [])]);
  const failed = [];
  if (oi.status !== 'fulfilled') failed.push('perpsAtOpenInterestCap');
  let b = book.status === 'fulfilled' ? book.value : null;
  if (needBook && book.status !== 'fulfilled') failed.push('l2Book');
  else if (needBook && b && (!b.levels[0].length || !b.levels[1].length || (Number(b.time) && Math.abs(now - Number(b.time)) > 120_000))) { failed.push('l2Book_stale_or_empty'); b = null; }
  return { meta: mac.value[0], ctx: mac.value[1], oiCap: oi.status === 'fulfilled' ? new Set(oi.value) : null, book: b, failed, now };
}

/** One full order check (the paid tiers and the preview are views of this). */
export async function checkOrder(order, { fetchImpl, now = Date.now() } = {}) {
  const m = await fetchMarket(order.coin, { fetchImpl, now, needBook: order.price === undefined });
  return check(order, m);
}

/** Lite view: verdict, issues with fixes, warnings, rules, confidence (no liquidation/impact/funding/market). */
export function liteView(r) {
  const { liquidation, execution, funding, market, ...rest } = r;
  return rest;
}
/** Free preview: verdict and issue/warning codes only (no messages, fixes or market facts). */
export function previewView(r) {
  return { coin: r.coin, side: r.side, order_type: r.order_type, verdict: r.verdict, issue_codes: r.issues.map((i) => i.code), warning_codes: r.warnings.map((w) => w.code), confidence: r.confidence, sources_failed: r.sources_failed, checked_at: r.checked_at, disclaimer: DISCLAIMER };
}

agentgate-source: code mirror on Cloudflare Pages. All repos