x402-smartperps / tree / src/engine.js · raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// SmartPerps engine: smart-money positioning on Hyperliquid perps, from public data only.
// "Smart money" = a transparent, rules-based cohort of Hyperliquid accounts from the public leaderboard (top 30-day PnL,
// positive all-time PnL, account value >= $100k, market-maker-like volume excluded). A cron (every 5 min) reads each
// cohort account's open perp positions (public clearinghouseState) and stores one snapshot + per-coin history in a
// SQLite Durable Object. Paid requests read that snapshot: no upstream call sits between the 402 and the answer.
// Read-only: nothing is traded, no keys, no custody. Fail-closed: low coverage or a stale snapshot is never reported
// as a confident read (bias "unknown" + sources_failed, or HTTP 503 Incomplete, never charged).
import { withDeadline } from './deadline.js';

export const ENGINE_VERSION = 'smartperps-0.1.1';
export const USER_AGENT = 'SmartPerps/0.1 (+https://api.youraitools.co/smartperps; read-only public market data; hello@youraitools.co)';
export const DISCLAIMER = 'Not financial advice. Past performance does not guarantee future results. Descriptive statistics of public Hyperliquid positions at snapshot time; past PnL of the cohort says nothing about future results and positions can change at any moment. SmartPerps never trades, holds funds or keys.';
export const HL_HOSTS = ['https://api.hyperliquid.xyz/info', 'https://api-ui.hyperliquid.xyz/info'];
export const LEADERBOARD_URL = 'https://stats-data.hyperliquid.xyz/Mainnet/leaderboard';
export const COHORT_RULES = { size: 120, minAccountValueUsd: 100000, minMonthPnlUsd: 0, minAllTimePnlUsd: 0, maxMonthVolumeToEquity: 1000, rank_by: '30-day PnL (USD)' };
export const PRIVACY = 'Privacy: cohort answers (lite, coin, scan, preview, MCP) never return full account addresses (top positions show 0x1234…abcd only) and liquidation data is grouped into price bands (position count + notional per band, notional withheld for bands with fewer than 3 positions), never tied to an individual account. /v1/wallet profiles only the address the caller supplies and shows distance to liquidation in whole percent, not liquidation prices. Public on-chain data only; do not use it to target or harass account holders.';
export const WALLET_NOTE = 'Public on-chain Hyperliquid data only. Liquidation is shown as distance from mark in whole percent, not as a price. Do not use this data to target, front-run liquidations of, or harass account holders.';
export const LIQ_MIN_POSITIONS = 3;
/** 0x5b5d51203a0f9079f8aeb098a6523a13f298c060 -> 0x5b5d…c060 (cohort answers never carry full addresses). */
export function maskAddress(a) { return typeof a === 'string' && /^0x[0-9a-fA-F]{40}$/.test(a) ? `${a.slice(0, 6)}…${a.slice(-4)}` : null; }
/** Distance from mark to liquidation in whole percent (wallet tier; never the price itself). */
export function liqDistancePct(liq, mark) { return liq && mark ? Math.round(Math.abs(liq - mark) / mark * 100) : null; }
const sig = (v, d = 4) => (v === null || !Number.isFinite(v) ? null : Number(v.toPrecision(d)));
export const LIMITS = [
  'Hyperliquid perps only (the largest onchain perp venue); centralized exchanges and spot holdings are not included.',
  `The cohort is rules-based (${COHORT_RULES.size} accounts: top 30-day PnL on the public Hyperliquid leaderboard, all-time PnL > 0, account value >= $100k, very high volume-to-equity accounts excluded as likely market makers). It is a statistical sample, not a list of "experts".`,
  'Positions come from a snapshot refreshed about every 5 minutes; snapshot_age_s is in every answer. A snapshot older than 30 minutes or covering under 60% of the cohort is refused (HTTP 503, never charged).',
  'Bias labels are descriptive statistics of current positions, not predictions or trade signals. Hedged books (e.g. a perp short against spot) look short here.',
  PRIVACY,
];
const TIMEOUT = 5000;
const STALE_REFUSE_S = 1800, STALE_LOW_S = 1200, STALE_MED_S = 600;

export class InputError extends Error { constructor(message, status = 400, code = 'bad_request') { super(message); 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) => { const n = typeof v === 'number' ? v : Number.parseFloat(v); return Number.isFinite(n) ? n : null; };
const round = (v, d = 2) => (v === null || v === undefined || !Number.isFinite(v) ? null : Number(v.toFixed(d)));
const usd = (v) => (v === null || v === undefined || !Number.isFinite(v) ? null : Math.round(v));

/** "btc", "BTC-PERP", "ETH-USD", "SOLUSDT", "kpepe" -> Hyperliquid coin name ("BTC", "ETH", "SOL", "kPEPE"). */
export function normalizeSymbol(raw) {
  if (typeof raw !== 'string') throw new InputError('"symbol" must be a string, e.g. BTC');
  let s = raw.trim().replace(/[\s_/]+/g, '-');
  const k = /^k[A-Z0-9]+$/.test(s); // Hyperliquid k-markets (kPEPE, kBONK)
  s = k ? s : s.toUpperCase();
  s = s.replace(/-?(PERP|PERPETUAL|SWAP)$/i, '').replace(/-?(USDT|USDC|USD)$/i, '').replace(/-+$/, '');
  if (!/^k?[A-Z0-9]{1,15}$/.test(s)) throw new InputError('"symbol" must be a Hyperliquid perp ticker like BTC, ETH, SOL or HYPE (letters and digits, max 15)');
  return s;
}
export function normalizeAddress(raw) {
  if (typeof raw !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(raw.trim())) throw new InputError('"address" must be a 0x-prefixed 40-hex EVM address (a Hyperliquid account)');
  return raw.trim().toLowerCase();
}

async function getJson(fetchImpl, url, init = {}, what = url, ms = TIMEOUT) {
  const ctl = new AbortController();
  const p = (async () => {
    const r = await fetchImpl(url, { ...init, signal: ctl.signal, headers: { accept: 'application/json', 'user-agent': USER_AGENT, ...(init.headers || {}) } });
    if (!r.ok) throw new Error(`${what}: HTTP ${r.status}`);
    return r.json();
  })();
  try { return await withDeadline(p, ms, what); } finally { ctl.abort(); }
}
/** POST to the Hyperliquid info API with host fallback (api -> api-ui). */
export async function hlInfo(fetchImpl, body, what = body.type, ms = TIMEOUT) {
  let last;
  for (const h of HL_HOSTS) {
    try { return await getJson(fetchImpl, h, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }, what, ms); } catch (e) { last = e; }
  }
  throw last;
}

// ---------------- cohort (leaderboard) ----------------
/** Turn one leaderboard row into a compact candidate, or null when it fails the cohort rules. */
export function candidate(r, rules = COHORT_RULES) {
  if (!r || typeof r.ethAddress !== 'string') return null;
  const w = Object.fromEntries(Array.isArray(r.windowPerformances) ? r.windowPerformances : []);
  const av = num(r.accountValue), mp = num(w.month?.pnl), ap = num(w.allTime?.pnl), mv = num(w.month?.vlm), mroi = num(w.month?.roi), wp = num(w.week?.pnl);
  if (av === null || mp === null || ap === null) return null;
  if (av < rules.minAccountValueUsd || mp <= rules.minMonthPnlUsd || ap <= rules.minAllTimePnlUsd) return null;
  if (mv !== null && mv / Math.max(av, 1) > rules.maxMonthVolumeToEquity) return null;
  return { a: r.ethAddress.toLowerCase(), av: usd(av), mp: usd(mp), ap: usd(ap), wp: usd(wp), mroi: round(mroi, 4), n: typeof r.displayName === 'string' ? r.displayName.slice(0, 40) : null };
}
export function selectCohort(cands, rules = COHORT_RULES) {
  return cands.filter(Boolean).sort((x, y) => y.mp - x.mp).slice(0, rules.size).map((c, i) => ({ ...c, rank: i + 1 }));
}
/** Stream-parse the (~40 MB) public leaderboard without holding it as one JSON value. */
export async function fetchLeaderboardCohort(fetchImpl, rules = COHORT_RULES, ms = 60000) {
  const ctl = new AbortController();
  const work = (async () => {
    const res = await fetchImpl(LEADERBOARD_URL, { signal: ctl.signal, headers: { accept: 'application/json', 'user-agent': USER_AGENT } });
    if (!res.ok) throw new Error(`leaderboard: HTTP ${res.status}`);
    const cands = []; let rows = 0; let buf = '';
    const take = (chunk) => { const c = candidate(JSON.parse(chunk), rules); rows++; if (c) cands.push(c); };
    // Rows start at '{' + optional whitespace + '"ethAddress"' (the file is pretty-printed); slice between row starts.
    const drain = (final) => {
      const re = /\{\s*"ethAddress"/g; const starts = []; let m;
      while ((m = re.exec(buf))) starts.push(m.index);
      if (!starts.length) { if (!final) buf = buf.slice(-64); return; }
      for (let k = 0; k + 1 < starts.length; k++) take(buf.slice(starts[k], starts[k + 1]).replace(/,\s*$/, ''));
      const last = starts[starts.length - 1];
      if (final) { const tail = buf.slice(last).replace(/\]\s*\}\s*$/, '').replace(/,\s*$/, ''); if (tail.trim()) take(tail); buf = ''; } else buf = buf.slice(last);
    };
    if (res.body?.getReader) {
      const reader = res.body.getReader(); const dec = new TextDecoder();
      for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); if (buf.length > 1 << 20) drain(false); }
      buf += dec.decode(); drain(true);
    } else { buf = await res.text(); drain(true); }
    if (rows < 100) throw new Error(`leaderboard: only ${rows} rows`);
    return { cohort: selectCohort(cands, rules), leaderboard_rows: rows, eligible: cands.length };
  })();
  try { return await withDeadline(work, ms, 'leaderboard'); } finally { ctl.abort(); }
}

// ---------------- snapshot (positions) ----------------
async function pool(items, n, fn) {
  const out = new Array(items.length); let k = 0;
  await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (k < items.length) { const i = k++; try { out[i] = await fn(items[i], i); } catch (e) { out[i] = { error: String(e?.message || e) }; } } }));
  return out;
}
/** Map coin -> mark price from metaAndAssetCtxs. */
export async function fetchMarks(fetchImpl) {
  const [meta, ctxs] = await hlInfo(fetchImpl, { type: 'metaAndAssetCtxs' }, 'hyperliquid_meta');
  const marks = {};
  meta.universe.forEach((u, i) => { if (!u.isDelisted && ctxs[i]) { const m = num(ctxs[i].markPx); if (m) marks[u.name] = m; } });
  return marks;
}
/** Compact position rows from a clearinghouseState answer. */
export function positionsOf(state, idx) {
  const out = [];
  for (const ap of state?.assetPositions || []) {
    const p = ap?.position; if (!p) continue;
    const szi = num(p.szi), ntl = num(p.positionValue); if (!szi || !ntl) continue;
    out.push({ i: idx, c: p.coin, s: szi > 0 ? 1 : -1, sz: Math.abs(szi), ntl: usd(Math.abs(ntl)), e: num(p.entryPx), lev: num(p.leverage?.value), lt: p.leverage?.type === 'isolated' ? 'iso' : 'cross', liq: num(p.liquidationPx), u: usd(num(p.unrealizedPnl)) });
  }
  return out;
}
/** Build the per-coin aggregate table from compact positions. */
export function aggregate(positions, marks = {}) {
  const coins = {};
  for (const p of positions) {
    const a = (coins[p.c] ||= { tl: 0, ts: 0, ln: 0, sn: 0, le: 0, se: 0, lev: 0, u_l: 0, u_s: 0, top: 0 });
    if (p.s > 0) { a.tl++; a.ln += p.ntl; a.le += (p.e || 0) * p.ntl; a.u_l += p.u || 0; } else { a.ts++; a.sn += p.ntl; a.se += (p.e || 0) * p.ntl; a.u_s += p.u || 0; }
    a.lev += (p.lev || 0) * p.ntl; a.top = Math.max(a.top, p.ntl);
  }
  for (const [c, a] of Object.entries(coins)) {
    const g = a.ln + a.sn;
    coins[c] = { tl: a.tl, ts: a.ts, ln: usd(a.ln), sn: usd(a.sn), net: usd(a.ln - a.sn), le: a.ln ? round(a.le / a.ln, 6) : null, se: a.sn ? round(a.se / a.sn, 6) : null, lev: g ? round(a.lev / g, 2) : null, ul: usd(a.u_l), us: usd(a.u_s), top: g ? round(a.top / g * 100, 1) : null, m: marks[c] ?? null };
  }
  return coins;
}
/**
 * One refresh: cohort (cached up to `cohortMaxAgeMs`, kept as last-good for 48 h if the leaderboard fails), marks, and
 * every cohort account's positions. Returns { snapshot, cohortRecord } for the store. Throws only if nothing usable.
 */
export async function buildSnapshot({ fetchImpl = globalThis.fetch, now = Date.now(), cohortRecord = null, cohortMaxAgeMs = 3600_000, concurrency = 6 } = {}) {
  const failed = [];
  let cr = cohortRecord;
  if (!cr || now - cr.at > cohortMaxAgeMs) {
    try { const lb = await fetchLeaderboardCohort(fetchImpl); cr = { at: now, ...lb }; } catch (e) {
      failed.push('hyperliquid_leaderboard');
      if (!cr || now - cr.at > 48 * 3600_000) throw new Incomplete('Hyperliquid leaderboard unavailable and no recent cohort', failed);
    }
  }
  let marks = {};
  try { marks = await fetchMarks(fetchImpl); } catch { failed.push('hyperliquid_meta'); }
  const states = await pool(cr.cohort, concurrency, (c) => hlInfo(fetchImpl, { type: 'clearinghouseState', user: c.a }, 'clearinghouseState'));
  const positions = []; let covered = 0; let equity = 0;
  states.forEach((s, i) => { if (!s || s.error) return; covered++; equity += num(s.marginSummary?.accountValue) || 0; positions.push(...positionsOf(s, i)); });
  if (covered < cr.cohort.length) failed.push(`hyperliquid_positions:${cr.cohort.length - covered}_of_${cr.cohort.length}_failed`);
  if (!covered) throw new Incomplete('No cohort positions could be read from Hyperliquid', failed);
  const snapshot = { at: now, cohort_at: cr.at, cohort_size: cr.cohort.length, covered, equity_usd: usd(equity), failed, marks_ok: !failed.includes('hyperliquid_meta'), listed: Object.keys(marks), coins: aggregate(positions, marks), positions };
  return { snapshot, cohortRecord: cr };
}

// ---------------- reads (pure: snapshot + history in, answer out) ----------------
export function biasLabel(a) {
  if (!a) return 'no_positions';
  const n = a.tl + a.ts, g = a.ln + a.sn;
  if (n < 3 || !g) return 'insufficient_data';
  const ns = a.ln / g, ts = a.tl / n;
  if (ns >= 0.7 && ts >= 0.6) return 'strong_long';
  if (ns <= 0.3 && ts <= 0.4) return 'strong_short';
  if (ns >= 0.57 && ts >= 0.5) return 'lean_long';
  if (ns <= 0.43 && ts <= 0.5) return 'lean_short';
  return 'mixed';
}
const NOTES = { strong_long: 'Most of the cohort\'s notional and most cohort traders in this coin are long.', strong_short: 'Most of the cohort\'s notional and most cohort traders in this coin are short.', lean_long: 'The cohort leans long by notional.', lean_short: 'The cohort leans short by notional.', mixed: 'Cohort longs and shorts roughly offset, or notional and trader counts disagree.', insufficient_data: 'Fewer than 3 cohort accounts hold this coin: no read.', no_positions: 'No cohort account holds this coin right now.', unknown: 'Snapshot coverage is too low for a read (see sources_failed).' };

/** Freshness / coverage gate shared by every snapshot read. Throws Incomplete (503, never charged) when unusable. */
export function quality(snap, now = Date.now()) {
  if (!snap) throw new Incomplete('No positions snapshot yet (the first refresh runs every 5 minutes); not charged, retry shortly', ['snapshot_missing']);
  const age = Math.max(0, Math.round((now - snap.at) / 1000));
  const cov = snap.cohort_size ? snap.covered / snap.cohort_size : 0;
  const failed = [...(snap.failed || [])];
  if (age > STALE_REFUSE_S) throw new Incomplete(`Positions snapshot is ${age} s old (refresh failing); not charged`, [...failed, 'snapshot_stale']);
  if (cov < 0.6) throw new Incomplete(`Only ${snap.covered} of ${snap.cohort_size} cohort accounts could be read; not charged`, failed);
  if (age > STALE_LOW_S) failed.push('snapshot_stale');
  const confidence = cov >= 0.9 && age <= STALE_MED_S && snap.marks_ok ? 'high' : cov >= 0.75 && age <= STALE_LOW_S ? 'medium' : 'low';
  return { snapshot_at: new Date(snap.at).toISOString(), snapshot_age_s: age, cohort: { size: snap.cohort_size, covered: snap.covered, coverage_pct: round(cov * 100, 1), rules: COHORT_RULES, cohort_refreshed_at: new Date(snap.cohort_at).toISOString() }, confidence, sources_failed: failed, reliable: cov >= 0.75 };
}
/** Net-notional change vs the history sample closest to `ago` ms before the snapshot (within 40% tolerance). */
export function flowsFor(coin, snap, hist = []) {
  const cur = snap.coins[coin];
  const out = {};
  for (const [k, ago] of [['1h', 3600_000], ['4h', 4 * 3600_000], ['24h', 24 * 3600_000]]) {
    const target = snap.at - ago; let best = null;
    for (const h of hist) { if (h.at >= snap.at) continue; if (!best || Math.abs(h.at - target) < Math.abs(best.at - target)) best = h; }
    if (!best || Math.abs(best.at - target) > ago * 0.4) { out[k] = null; continue; }
    const prev = best.coins?.[coin] || { net: 0, tl: 0, ts: 0, ln: 0, sn: 0 };
    const c = cur || { net: 0, tl: 0, ts: 0, ln: 0, sn: 0 };
    out[k] = { net_notional_change_usd: usd(c.net - prev.net), long_notional_change_usd: usd(c.ln - prev.ln), short_notional_change_usd: usd(c.sn - prev.sn), traders_long_change: c.tl - prev.tl, traders_short_change: c.ts - prev.ts, vs: new Date(best.at).toISOString() };
  }
  return out;
}
/** Map a normalized ticker to the Hyperliquid coin name (PEPE -> kPEPE); unknown coins = 404 (never charged). */
export function resolveCoin(coin, snap) {
  if (!snap) return coin;
  const listed = new Set([...(snap.listed || []), ...Object.keys(snap.coins || {})]);
  if (listed.has(coin)) return coin;
  if (listed.has(`k${coin}`)) return `k${coin}`;
  const ci = [...listed].find((x) => x.toUpperCase() === coin.toUpperCase());
  if (ci) return ci;
  if (snap.listed?.length) throw new InputError(`"${coin}" is not a listed Hyperliquid perp`, 404, 'symbol_not_listed');
  return coin;
}
function coinCore(coin, snap, q) {
  const a = snap.coins[coin];
  const label = q.reliable ? biasLabel(a) : 'unknown';
  const g = a ? a.ln + a.sn : 0;
  return {
    symbol: coin, bias: label, bias_note: NOTES[label],
    traders_long: a?.tl ?? 0, traders_short: a?.ts ?? 0,
    long_notional_usd: a?.ln ?? 0, short_notional_usd: a?.sn ?? 0, net_notional_usd: a?.net ?? 0,
    long_share_notional_pct: g ? round(a.ln / g * 100, 1) : null, long_share_traders_pct: a && a.tl + a.ts ? round(a.tl / (a.tl + a.ts) * 100, 1) : null,
    mark_price: a?.m ?? null,
  };
}
export function coinLite(coin, snap, hist, now = Date.now()) {
  const q = quality(snap, now); coin = resolveCoin(coin, snap);
  const f = flowsFor(coin, snap, hist);
  return { ...coinCore(coin, snap, q), flow_1h_net_usd: f['1h']?.net_notional_change_usd ?? null, flow_24h_net_usd: f['24h']?.net_notional_change_usd ?? null, ...q, checked_at: new Date(now).toISOString() };
}
const LIQ_BUCKETS = [[0, 2], [2, 5], [5, 10], [10, 25], [25, Infinity]];
export function coinFull(coin, snap, hist, cohort = [], now = Date.now(), topN = 10) {
  const q = quality(snap, now); coin = resolveCoin(coin, snap);
  const a = snap.coins[coin];
  const mine = snap.positions.filter((p) => p.c === coin);
  const mark = a?.m ?? null;
  // Liquidation data is aggregated into price bands only (count + notional per band); it is never tied to an account.
  const band = (side) => ([lo, hi]) => ({ within_pct: hi === Infinity ? `>${lo}` : `${lo}-${hi}`,
    price_from: mark ? sig(side > 0 ? (hi === Infinity ? 0 : mark * (1 - hi / 100)) : mark * (1 + lo / 100)) : null,
    price_to: mark ? (side > 0 ? sig(mark * (1 - lo / 100)) : hi === Infinity ? null : sig(mark * (1 + hi / 100))) : null,
    notional_usd: 0, positions: 0 });
  const liq = { longs: LIQ_BUCKETS.map(band(1)), shorts: LIQ_BUCKETS.map(band(-1)), no_liquidation_price_usd: 0 };
  for (const p of mine) {
    if (!p.liq || !mark) { liq.no_liquidation_price_usd += p.ntl; continue; }
    const d = Math.abs(p.liq - mark) / mark * 100; const b = LIQ_BUCKETS.findIndex(([lo, hi]) => d >= lo && d < hi);
    const side = p.s > 0 ? liq.longs : liq.shorts; side[b].notional_usd += p.ntl; side[b].positions++;
  }
  for (const b of [...liq.longs, ...liq.shorts]) if (b.positions > 0 && b.positions < LIQ_MIN_POSITIONS) { b.notional_usd = null; b.notional_withheld = `fewer than ${LIQ_MIN_POSITIONS} positions`; }
  liq.bands_note = `Bands by distance of the liquidation price from mark (longs below, shorts above). Notional is withheld for bands with fewer than ${LIQ_MIN_POSITIONS} positions so no band maps to one account.`;
  const top = mine.sort((x, y) => y.ntl - x.ntl).slice(0, topN).map((p) => {
    const c = cohort[p.i];
    return { address: maskAddress(c?.a), side: p.s > 0 ? 'long' : 'short', size: p.sz, notional_usd: p.ntl, entry_price: p.e, leverage: p.lev, margin: p.lt, unrealized_pnl_usd: p.u, cohort_rank_30d_pnl: c?.rank ?? null, trader_30d_pnl_usd: c?.mp ?? null };
  });
  const core = coinCore(coin, snap, q);
  return {
    ...core,
    avg_entry_long: a?.le ?? null, avg_entry_short: a?.se ?? null,
    entry_vs_mark_long_pct: a?.le && mark ? round((mark - a.le) / a.le * 100, 2) : null, entry_vs_mark_short_pct: a?.se && mark ? round((a.se - mark) / a.se * 100, 2) : null,
    avg_leverage: a?.lev ?? null, unrealized_pnl_long_usd: a?.ul ?? 0, unrealized_pnl_short_usd: a?.us ?? 0,
    largest_position_share_pct: a?.top ?? null, concentration_note: a?.top >= 50 ? 'One account holds at least half of the cohort notional in this coin: treat the bias as one trader\'s position.' : null,
    flows: flowsFor(coin, snap, hist), liquidation_clusters: liq, top_positions: top,
    ...q, checked_at: new Date(now).toISOString(),
  };
}
export function scan(snap, hist, { limit = 10, minTraders = 3 } = {}, now = Date.now()) {
  const q = quality(snap, now);
  const rows = Object.entries(snap.coins).map(([c, a]) => ({ c, a })).filter(({ a }) => a.tl + a.ts >= minTraders);
  const f24 = (c) => flowsFor(c, snap, hist)['24h']?.net_notional_change_usd ?? null;
  const f1 = (c) => flowsFor(c, snap, hist)['1h']?.net_notional_change_usd ?? null;
  const row = ({ c, a }) => ({ symbol: c, bias: q.reliable ? biasLabel(a) : 'unknown', traders_long: a.tl, traders_short: a.ts, net_notional_usd: a.net, long_share_notional_pct: a.ln + a.sn ? round(a.ln / (a.ln + a.sn) * 100, 1) : null, flow_1h_net_usd: f1(c), flow_24h_net_usd: f24(c) });
  let gl = 0, gs = 0; for (const a of Object.values(snap.coins)) { gl += a.ln; gs += a.sn; }
  const withFlow = rows.map((r) => ({ ...r, f: f24(r.c) })).filter((r) => r.f !== null);
  return {
    mode: 'cohort', coins_held: Object.keys(snap.coins).length, coins_ranked: rows.length, min_traders: minTraders,
    cohort_book: { gross_long_usd: usd(gl), gross_short_usd: usd(gs), net_usd: usd(gl - gs), long_share_pct: gl + gs ? round(gl / (gl + gs) * 100, 1) : null, accounts_with_positions: new Set(snap.positions.map((p) => p.i)).size, equity_usd: snap.equity_usd },
    most_net_long: rows.filter((r) => r.a.net > 0).sort((x, y) => y.a.net - x.a.net).slice(0, limit).map(row),
    most_net_short: rows.filter((r) => r.a.net < 0).sort((x, y) => x.a.net - y.a.net).slice(0, limit).map(row),
    most_one_sided: rows.filter((r) => r.a.tl + r.a.ts >= Math.max(minTraders, 5)).sort((x, y) => Math.abs(y.a.tl - y.a.ts) / (y.a.tl + y.a.ts) - Math.abs(x.a.tl - x.a.ts) / (x.a.tl + x.a.ts) || (y.a.tl + y.a.ts) - (x.a.tl + x.a.ts)).slice(0, limit).map(row),
    biggest_24h_buying: withFlow.filter((r) => r.f > 0).sort((x, y) => y.f - x.f).slice(0, limit).map(row),
    biggest_24h_selling: withFlow.filter((r) => r.f < 0).sort((x, y) => x.f - y.f).slice(0, limit).map(row),
    flows_note: withFlow.length ? null : 'Flow history builds up over the first 24 hours after launch; flow lists are empty until a 24h-old snapshot exists.',
    ...q, checked_at: new Date(now).toISOString(),
  };
}

// ---------------- wallet profile (live) ----------------
export async function walletProfile(address, { fetchImpl = globalThis.fetch, now = Date.now(), cohort = [] } = {}) {
  const failed = [];
  const [stateR, fillsR] = await Promise.allSettled([
    hlInfo(fetchImpl, { type: 'clearinghouseState', user: address }, 'clearinghouseState'),
    hlInfo(fetchImpl, { type: 'userFillsByTime', user: address, startTime: now - 30 * 86400_000, aggregateByTime: true }, 'userFills', 7000),
  ]);
  if (stateR.status !== 'fulfilled') throw new Incomplete('Hyperliquid account state unavailable; not charged', ['hyperliquid_clearinghouse']);
  const s = stateR.value;
  const av = num(s.marginSummary?.accountValue), ntl = num(s.marginSummary?.totalNtlPos), mu = num(s.marginSummary?.totalMarginUsed);
  const pos = positionsOf(s, 0).sort((x, y) => y.ntl - x.ntl).map((p) => ({ symbol: p.c, side: p.s > 0 ? 'long' : 'short', size: p.sz, notional_usd: p.ntl, entry_price: p.e, leverage: p.lev, margin: p.lt, liquidation_distance_pct: liqDistancePct(p.liq, p.sz ? p.ntl / p.sz : null), unrealized_pnl_usd: p.u }));
  let fills = null;
  if (fillsR.status === 'fulfilled' && Array.isArray(fillsR.value)) {
    const f = fillsR.value; let realized = 0, fees = 0, wins = 0, closes = 0, vol = 0; const by = {};
    for (const x of f) {
      const pnl = num(x.closedPnl) || 0, fee = num(x.fee) || 0, px = num(x.px) || 0, sz = num(x.sz) || 0;
      realized += pnl; fees += fee; vol += px * sz; by[x.coin] = (by[x.coin] || 0) + px * sz;
      if (pnl !== 0) { closes++; if (pnl > 0) wins++; }
    }
    fills = { window_days: 30, fills: f.length, truncated: f.length >= 2000, volume_usd: usd(vol), realized_pnl_usd: usd(realized), fees_usd: usd(fees), closing_fills: closes, winning_closes_pct: closes ? round(wins / closes * 100, 1) : null, most_traded: Object.entries(by).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([symbol, v]) => ({ symbol, volume_usd: usd(v) })) };
  } else failed.push('hyperliquid_fills');
  const c = cohort.find((x) => x.a === address) || null;
  const gross = pos.reduce((t, p) => t + p.notional_usd, 0), longs = pos.filter((p) => p.side === 'long').reduce((t, p) => t + p.notional_usd, 0);
  return {
    address, account_value_usd: usd(av), total_notional_usd: usd(ntl), margin_used_usd: usd(mu), margin_usage_pct: av ? round((mu || 0) / av * 100, 1) : null, effective_leverage: av ? round((ntl || 0) / av, 2) : null,
    open_positions: pos.length, book: { long_notional_usd: usd(longs), short_notional_usd: usd(gross - longs), net_usd: usd(2 * longs - gross) }, positions: pos.slice(0, 25),
    last_30d: fills, in_smart_money_cohort: !!c, cohort_entry: c ? { rank_30d_pnl: c.rank, pnl_30d_usd: c.mp, pnl_all_time_usd: c.ap, roi_30d: c.mroi, display_name: c.n } : null,
    confidence: failed.length ? 'medium' : 'high', sources_failed: failed, data_note: WALLET_NOTE, checked_at: new Date(now).toISOString(),
  };
}

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