// 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