src/account/parse.ts
v0.3.0 · 12.2 KB
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,
};
}