Skip to content
markpaper

src/account/parse.ts

v0.3.0 · 12.2 KB

Download file
import * as D from './decimal.js';
import type { Dec } from './decimal.js';
import { HlAccountError, dexLabel } from './errors.js';
import type { PerpDexState } from './types.js';

export const isObject = (x: unknown): x is Record<string, unknown> =>
  typeof x === 'object' && x !== null && !Array.isArray(x);

export interface ParsedPosition {
  coin: string;
  szi: Dec;
  entryPx: Dec | null;
  /** Absolute notional at mark price. */
  positionValue: Dec;
  unrealizedPnl: Dec;
  marginUsed: Dec;
  leverage: { type: 'cross' | 'isolated'; value: number; rawUsd: Dec | null };
  liquidationPx: Dec | null;
  returnOnEquity: Dec | null;
  maxLeverage: number | null;
}

export interface ParsedDex {
  dex: string;
  accountValue: Dec;
  totalMarginUsed: Dec;
  totalNtlPos: Dec;
  crossAccountValue: Dec | null;
  withdrawable: Dec | null;
  positions: ParsedPosition[];
  /**
   * Coins present in `assetPositions` with `szi == 0` or `positionValue == 0`. They are not open
   * positions, but a zero-size row is "no data", not a close: HL transiently returns such rows for live
   * positions, and treating them as closed gives a false close followed by a phantom new position.
   */
  zeroSizeCoins: string[];
}

function degraded(dex: string, message: string): HlAccountError {
  return new HlAccountError('DEGRADED_PERP', `${dexLabel(dex)}: ${message}`, { dex });
}

export function describe(value: unknown): string {
  if (value === undefined) return 'undefined';
  try {
    return JSON.stringify(value) ?? String(value);
  } catch {
    return String(value);
  }
}

function requireDec(value: unknown, field: string, dex: string): Dec {
  const d = D.parseDec(value);
  if (!d) throw degraded(dex, `${field} is not a decimal string (got ${describe(value)})`);
  return d;
}

function optionalDec(value: unknown, field: string, dex: string): Dec {
  return value === undefined || value === null ? D.ZERO : requireDec(value, field, dex);
}

/**
 * Validates one `clearinghouseState` answer (fail-closed checklist from the knowledge base).
 *
 * - `marginSummary` must exist with a decimal `accountValue`. HTTP 200 without it happens under
 *   429/5xx storms; read as $0, it breaks position sizing and can send a market IoC many times the
 *   account size. Missing field = degraded read, not an empty account.
 * - `assetPositions` must be an array (a healthy flat account returns `[]`).
 * - `accountValue >= totalMarginUsed`: otherwise the account would already be liquidated.
 * - Every open position needs decimal `szi`, `positionValue`, `unrealizedPnl` and `leverage.value > 0`.
 *   A non-numeric `szi` is a broken read, never "flat": `Math.abs(NaN) > 0` is false, so a NaN reads
 *   as flat and an engine can market-close a live position because of it.
 * - No duplicate coin inside the dex; positions with zero total margin = incomplete payload.
 *
 * A position is open when `szi != 0` AND `positionValue != 0`. Rows failing that are not dropped
 * silently: they are listed in `zeroSizeCoins` so callers can keep those coins "active" (no data).
 *
 * The per-dex `accountValue >= totalMarginUsed` check follows the knowledge-base checklist. The same
 * base also records that on a Unified Account a HIP-3 dex `accountValue` can read 0 while that dex holds
 * positions; that conflict is unverified. If it is real, such reads are rejected as `DEGRADED_PERP`
 * (fail-closed: the tick is skipped, never read as $0).
 *
 * On HIP-3 dexes whose `collateralToken` is not USDC (for example USDH, USDE or USDT0 dexes) the
 * amounts are denominated in that collateral token, not in USD.
 *
 * @throws {HlAccountError} `DEGRADED_PERP`.
 */
export function parseDexState(raw: unknown, dex: string): ParsedDex {
  if (!isObject(raw)) throw degraded(dex, 'clearinghouseState answer is not an object');
  const ms = raw.marginSummary;
  if (!isObject(ms)) throw degraded(dex, 'answer has no marginSummary (degraded read, not $0)');

  const accountValue = requireDec(ms.accountValue, 'marginSummary.accountValue', dex);
  const totalMarginUsed = optionalDec(ms.totalMarginUsed, 'marginSummary.totalMarginUsed', dex);
  const totalNtlPos = optionalDec(ms.totalNtlPos, 'marginSummary.totalNtlPos', dex);

  if (D.sign(totalMarginUsed) > 0 && D.cmp(accountValue, totalMarginUsed) < 0) {
    throw degraded(dex, 'accountValue < totalMarginUsed (the account would already be liquidated)');
  }
  if (!Array.isArray(raw.assetPositions)) {
    throw degraded(dex, 'assetPositions is not an array (a healthy answer always carries one)');
  }

  const positions: ParsedPosition[] = [];
  const zeroSizeCoins: string[] = [];
  const seen = new Set<string>();
  for (const item of raw.assetPositions) {
    const p = isObject(item) ? item.position : undefined;
    if (!isObject(p) || typeof p.coin !== 'string' || p.coin === '') continue;
    const coin = p.coin;
    const szi = requireDec(p.szi, `${coin}.szi`, dex);
    if (D.isZero(szi)) {
      if (!zeroSizeCoins.includes(coin)) zeroSizeCoins.push(coin);
      continue;
    }
    const positionValue = requireDec(p.positionValue, `${coin}.positionValue`, dex);
    if (D.isZero(positionValue)) {
      if (!zeroSizeCoins.includes(coin)) zeroSizeCoins.push(coin);
      continue;
    }
    if (seen.has(coin)) throw degraded(dex, `duplicate position for ${coin}`);
    seen.add(coin);

    const lev = p.leverage;
    if (!isObject(lev)) throw degraded(dex, `${coin}: open position without leverage`);
    const levValue =
      typeof lev.value === 'number' ? lev.value : typeof lev.value === 'string' ? Number(lev.value) : Number.NaN;
    if (!(Number.isFinite(levValue) && levValue > 0)) {
      throw degraded(dex, `${coin}: leverage.value must be > 0 (got ${describe(lev.value)})`);
    }
    if (lev.type !== 'cross' && lev.type !== 'isolated') {
      throw degraded(dex, `${coin}: unknown leverage.type ${describe(lev.type)}`);
    }

    const unrealizedPnl = requireDec(p.unrealizedPnl, `${coin}.unrealizedPnl`, dex);
    const marginUsed = optionalDec(p.marginUsed, `${coin}.marginUsed`, dex);
    const entryPx =
      p.entryPx === undefined || p.entryPx === null || p.entryPx === ''
        ? null
        : requireDec(p.entryPx, `${coin}.entryPx`, dex);

    positions.push({
      coin,
      szi,
      entryPx,
      positionValue: D.abs(positionValue),
      unrealizedPnl,
      marginUsed,
      leverage: { type: lev.type, value: levValue, rawUsd: D.parseDec(lev.rawUsd) },
      liquidationPx: D.parseDec(p.liquidationPx),
      returnOnEquity: D.parseDec(p.returnOnEquity),
      maxLeverage: typeof p.maxLeverage === 'number' && Number.isFinite(p.maxLeverage) ? p.maxLeverage : null,
    });
  }

  // A position always holds margin. Positions with zero total margin mean the payload is incomplete
  // (seen on WS snapshots); a risk cap computed from it would read 0%.
  if (positions.length > 0 && D.isZero(totalMarginUsed)) {
    throw degraded(dex, 'positions present but totalMarginUsed is 0 (incomplete payload)');
  }

  const cross = isObject(raw.crossMarginSummary) ? D.parseDec(raw.crossMarginSummary.accountValue) : null;
  return {
    dex,
    accountValue,
    totalMarginUsed,
    totalNtlPos,
    crossAccountValue: cross,
    withdrawable: D.parseDec(raw.withdrawable),
    positions,
    zeroSizeCoins,
  };
}

/**
 * Parses every dex of a snapshot and applies the cross-dex checks: no duplicate dex ids and no coin
 * present on two dexes (HIP-3 coins are prefixed, so an overlap means a corrupted read).
 *
 * @throws {HlAccountError} `DEGRADED_PERP` or `INVALID_ARGUMENT`.
 */
export function parsePerpStates(perp: readonly PerpDexState[]): ParsedDex[] {
  if (!Array.isArray(perp)) throw new HlAccountError('INVALID_ARGUMENT', 'snapshot.perp must be an array');
  const dexSeen = new Set<string>();
  const coinSeen = new Map<string, string>();
  const out: ParsedDex[] = [];
  for (const entry of perp) {
    if (!isObject(entry) || typeof entry.dex !== 'string') {
      throw new HlAccountError('INVALID_ARGUMENT', 'each snapshot.perp entry needs a string dex');
    }
    if (dexSeen.has(entry.dex)) throw new HlAccountError('INVALID_ARGUMENT', `${dexLabel(entry.dex)} listed twice`);
    dexSeen.add(entry.dex);
    const parsed = parseDexState(entry.state, entry.dex);
    for (const p of parsed.positions) {
      const other = coinSeen.get(p.coin);
      if (other !== undefined) throw degraded(entry.dex, `coin ${p.coin} also reported by ${dexLabel(other)}`);
      coinSeen.set(p.coin, entry.dex);
    }
    out.push(parsed);
  }
  return out;
}

export interface ParsedStableRow {
  coin: string;
  total: Dec;
  hold: Dec;
  spotHold: Dec | null;
  reserve: Dec;
  reserveSource: 'spotHold' | 'hold';
  free: Dec;
  borrowed: Dec | null;
}

export type ParsedSpotStables =
  | {
      ok: true;
      portfolioMarginEnabled: boolean;
      rows: ParsedStableRow[];
      free: Dec;
      total: Dec;
      reserve: Dec;
      hasBorrowed: boolean;
    }
  | { ok: false; portfolioMarginEnabled: boolean; reason: string };

/**
 * Free stablecoins from a `spotClearinghouseState` answer (knowledge base, balance §3.2).
 *
 * `free = Σ max(0, total − reserve)` over an explicit stable list, where
 * `reserve = spotHold` when it is a non-empty string, otherwise `hold`.
 *
 * Fail-closed (the whole spot leg is rejected): non-object answer, missing `balances`, duplicate stable
 * row, non-strict numbers, `total < 0`, `reserve < 0`, negative `hold` unless portfolio margin with
 * `spotHold`, and `reserve > total` on a non-portfolio-margin account. On portfolio margin a reserve
 * above total clamps that row to 0 instead. An empty `balances: []` is a valid zero.
 *
 * Why not `total − max(0, hold)`: on portfolio margin it equals the UI "Available Balance" but includes
 * borrow capacity, doubling the denominator. A row like `total "0.0" / hold "-999999.99"` would add a
 * phantom $1,000,000 under `max(0, total − hold)`.
 */
export function parseSpotStables(raw: unknown, stables: readonly string[]): ParsedSpotStables {
  const pm = isObject(raw) && raw.portfolioMarginEnabled === true;
  const bad = (reason: string): ParsedSpotStables => ({ ok: false, portfolioMarginEnabled: pm, reason });
  if (!isObject(raw)) return bad('spotClearinghouseState answer is not an object');
  if (!Array.isArray(raw.balances)) return bad('spotClearinghouseState has no balances array (degraded read, not zero)');

  const wanted = new Set(stables);
  const seen = new Set<string>();
  const rows: ParsedStableRow[] = [];
  let hasBorrowed = false;
  for (const b of raw.balances) {
    // Exact symbol match only: prefix matching (startsWith('USD')) lets exotic rows into equity.
    if (!isObject(b) || typeof b.coin !== 'string' || !wanted.has(b.coin)) continue;
    const coin = b.coin;
    // One aggregate row per token is expected; a duplicate makes the spot contribution untrustworthy.
    if (seen.has(coin)) return bad(`duplicate ${coin} balance row`);
    seen.add(coin);

    const total = D.parseDec(b.total);
    const hold = b.hold === undefined || b.hold === null ? D.ZERO : D.parseDec(b.hold);
    const hasSpotHold = typeof b.spotHold === 'string' && b.spotHold !== '';
    const spotHold = hasSpotHold ? D.parseDec(b.spotHold) : null;
    if (!total) return bad(`${coin}.total is not a decimal string`);
    if (!hold) return bad(`${coin}.hold is not a decimal string`);
    if (hasSpotHold && !spotHold) return bad(`${coin}.spotHold is not a decimal string`);

    const reserve = spotHold ?? hold;
    if (D.sign(total) < 0) return bad(`${coin}.total is negative`);
    if (D.sign(reserve) < 0) return bad(`${coin} reserve is negative`);
    if (D.sign(hold) < 0 && !(pm && hasSpotHold)) {
      return bad(`${coin}.hold is negative outside portfolio margin with spotHold`);
    }
    if (!pm && D.cmp(reserve, total) > 0) {
      return bad(`${coin} reserve exceeds total on a non-portfolio-margin account`);
    }

    const borrowed = D.parseDec(b.borrowed);
    if (borrowed && D.sign(borrowed) > 0) hasBorrowed = true;
    rows.push({
      coin,
      total,
      hold,
      spotHold,
      reserve,
      reserveSource: spotHold ? 'spotHold' : 'hold',
      free: D.max(D.ZERO, D.sub(total, reserve)),
      borrowed,
    });
  }
  return {
    ok: true,
    portfolioMarginEnabled: pm,
    rows,
    free: D.sum(rows.map((r) => r.free)),
    total: D.sum(rows.map((r) => r.total)),
    reserve: D.sum(rows.map((r) => r.reserve)),
    hasBorrowed,
  };
}
All files