Skip to content
markpaper

src/account/positions.ts

v0.3.0 · 6 KB

Download file
import * as D from './decimal.js';
import { parsePerpStates } from './parse.js';
import type { AccountSnapshot } from './types.js';

export interface NormalizedPosition {
  /** `""` = main perp dex. */
  dex: string;
  /** HIP-3 coins keep their prefix (`xyz:NVDA`). Never split composite keys on `:`. */
  coin: string;
  /** Signed size (`szi`): > 0 long, < 0 short. */
  size: number;
  side: 'long' | 'short';
  /** Average entry price, `null` when HL sends none. */
  entryPx: number | null;
  /**
   * Mark price derived as `positionValue / |szi|` (HL reports `positionValue` at mark).
   * Derived, informational; `null` never happens for an open position but is kept for safety.
   */
  markPx: number | null;
  /** Absolute notional at mark. Use this for exposure, not `|szi| × entryPx` (stale against the market). */
  positionValue: number;
  /** `sign(szi) × positionValue`. */
  signedNotional: number;
  liquidationPx: number | null;
  /** Leverage is only visible while a position is open. */
  leverage: { type: 'cross' | 'isolated'; value: number; rawUsd: number | null };
  marginUsed: number;
  unrealizedPnl: number;
  /**
   * `unrealizedPnl / marginUsed` as a fraction (0.364 = 36.4%), `null` when `marginUsed <= 0`
   * (ROE cannot be computed reliably; treat as unknown, not as a loss).
   * Equivalent to `dir × (mark − entry) / mark × leverage`: the denominator is MARK, not entry. At 4x and
   * +10% the entry formula says 40%, the correct value is 36.4%; an ROE-based exit computed with the
   * entry formula is off by 3.6 points.
   */
  roe: number | null;
  /**
   * HL's own `returnOnEquity` (denominator = entry margin). Diverges from {@link roe} on cross after the
   * price moves. Informational only: stops, UI and peaks must all use `roe`.
   */
  returnOnEquityHl: number | null;
  maxLeverage: number | null;
  /** Exact decimal strings of the size and money fields. */
  exact: { szi: string; entryPx: string | null; positionValue: string; unrealizedPnl: string; marginUsed: string };
}

/**
 * Flattens open positions of every dex in a snapshot.
 *
 * A position is open when `szi != 0` and `positionValue != 0`. Validation is the same fail-closed parse
 * used by the snapshot (non-numeric `szi` throws instead of reading as flat, duplicate coins across dexes
 * throw). An empty result from a validated snapshot is a genuinely flat account.
 *
 * @throws {HlAccountError} `DEGRADED_PERP`, `INVALID_ARGUMENT`.
 */
export function normalizePositions(snapshot: Pick<AccountSnapshot, 'perp'>): NormalizedPosition[] {
  const out: NormalizedPosition[] = [];
  for (const dex of parsePerpStates(snapshot.perp)) {
    for (const p of dex.positions) {
      const sizeAbs = D.abs(p.szi);
      const pv = D.toNumber(p.positionValue);
      const marginUsed = D.toNumber(p.marginUsed);
      const upnl = D.toNumber(p.unrealizedPnl);
      const long = D.sign(p.szi) > 0;
      out.push({
        dex: dex.dex,
        coin: p.coin,
        size: D.toNumber(p.szi),
        side: long ? 'long' : 'short',
        entryPx: p.entryPx === null ? null : D.toNumber(p.entryPx),
        markPx: D.isZero(sizeAbs) ? null : pv / D.toNumber(sizeAbs),
        positionValue: pv,
        signedNotional: long ? pv : -pv,
        liquidationPx: p.liquidationPx === null ? null : D.toNumber(p.liquidationPx),
        leverage: {
          type: p.leverage.type,
          value: p.leverage.value,
          rawUsd: p.leverage.rawUsd === null ? null : D.toNumber(p.leverage.rawUsd),
        },
        marginUsed,
        unrealizedPnl: upnl,
        roe: D.sign(p.marginUsed) > 0 ? upnl / marginUsed : null,
        returnOnEquityHl: p.returnOnEquity === null ? null : D.toNumber(p.returnOnEquity),
        maxLeverage: p.maxLeverage,
        exact: {
          szi: D.toDecString(p.szi),
          entryPx: p.entryPx === null ? null : D.toDecString(p.entryPx),
          positionValue: D.toDecString(p.positionValue),
          unrealizedPnl: D.toDecString(p.unrealizedPnl),
          marginUsed: D.toDecString(p.marginUsed),
        },
      });
    }
  }
  return out;
}

/**
 * Coins that appear in `assetPositions` with `szi == 0` or `positionValue == 0`, per dex.
 *
 * {@link normalizePositions} does not return them (they are not open positions), but a zero-size row is
 * "no data", not a close: HL transiently returns such rows for live positions. Keep these coins in your
 * set of active keys (and log a warning) instead of treating them as closed; otherwise the next healthy
 * read produces a phantom new position after a false close.
 *
 * @throws {HlAccountError} `DEGRADED_PERP`, `INVALID_ARGUMENT` (same validation as the snapshot).
 */
export function listZeroSizePositions(snapshot: Pick<AccountSnapshot, 'perp'>): Array<{ dex: string; coin: string }> {
  return parsePerpStates(snapshot.perp).flatMap((d) => d.zeroSizeCoins.map((coin) => ({ dex: d.dex, coin })));
}

/**
 * Account-level PnL% of open positions: `Σ unrealizedPnl / Σ marginUsed` (fraction), summed exactly.
 * `null` when total margin is <= 0.
 */
export function aggregateRoe(positions: readonly Pick<NormalizedPosition, 'exact'>[]): number | null {
  let pnl = D.ZERO;
  let margin = D.ZERO;
  for (const p of positions) {
    const u = D.parseDec(p.exact.unrealizedPnl);
    const m = D.parseDec(p.exact.marginUsed);
    if (!u || !m) return null;
    pnl = D.add(pnl, u);
    margin = D.add(margin, m);
  }
  return D.sign(margin) > 0 ? D.toNumber(pnl) / D.toNumber(margin) : null;
}

/**
 * ROE from prices, for a fast price tick between account reads (fraction):
 * `dir × (mark − entry) / mark × leverage`, which equals `unrealizedPnl / marginUsed` when
 * `marginUsed = positionValue / leverage` (cross, margin at mark).
 * Returns `null` for non-positive prices or leverage.
 */
export function roeFromMark(args: { side: 'long' | 'short'; entryPx: number; markPx: number; leverage: number }): number | null {
  const { side, entryPx, markPx, leverage } = args;
  if (!(entryPx > 0) || !(markPx > 0) || !(leverage > 0)) return null;
  const dir = side === 'long' ? 1 : -1;
  return (dir * (markPx - entryPx) / markPx) * leverage;
}
All files