Skip to content
markpaper

src/account/positions.ts

v0.2.0 · 5 KB

Download file
// Positions, PnL and equity from a decoded `subaccount_info` plus the product universe.
//
// Formulas (knowledge base `account-and-fees.md` §2):
//   size          = perp_balances[].balance.amount (x18, sign = side)
//   entryPx       ~ |v_quote / amount|  (estimate: includes accrued funding / realized quote on the leg;
//                                        not verified against the UI as an average entry price)
//   positionValue = |size| * oracle
//   unrealizedPnl = size * oracle + v_quote   (quote of the position not yet settled into USDT0)
//   leverage      = round(1 / (1 - long_weight_initial)), weight from perp_products[].risk when valid
// A position on a product the universe cannot NAME, or without an oracle, makes the read DEGRADED:
// acting on the rest as if that coin were flat is the partial-read hazard.

import { effectiveLongWeight, maxLeverageFromWeights } from '../markets/leverage.js';
import type { PerpProduct } from '../markets/types.js';
import { absX18, x18ToNumber } from '../numbers/x18.js';
import type { SubaccountInfo } from './parse.js';

export type PositionSide = 'long' | 'short';

/** One open position. */
export interface NadoPosition {
  readonly coin: string;
  readonly productId: number;
  readonly side: PositionSide;
  /** Signed size, x18. */
  readonly sizeX18: bigint;
  /** Signed size as a double (positive = long). */
  readonly szi: number;
  /** Absolute size. */
  readonly size: number;
  /** Quote leg (`v_quote_balance`) as a double. */
  readonly vQuote: number;
  /** Estimated average entry price (`|vQuote / szi|`). @experimental not verified against the UI. */
  readonly entryPx: number;
  readonly oraclePx: number;
  readonly positionValue: number;
  readonly unrealizedPnl: number;
  readonly longWeightInitial: number;
  /** Effective leverage implied by the weight (unified cross margin has no per-position setting). */
  readonly leverage: number;
}

/** Why a position could not be decoded. */
export interface DegradedPosition {
  readonly productId: number;
  readonly reason: 'unknown-product' | 'no-oracle';
}

/** Result of {@link positionsFromInfo}. */
export interface PositionsResult {
  readonly positions: readonly NadoPosition[];
  /** False when at least one non-zero balance could not be decoded: do not treat that coin as flat. */
  readonly ok: boolean;
  readonly degraded: readonly DegradedPosition[];
}

/** Decodes the non-zero balances of `info` into positions using `byProductId` for names and weights. */
export function positionsFromInfo(
  info: SubaccountInfo,
  byProductId: ReadonlyMap<number, PerpProduct>,
): PositionsResult {
  const positions: NadoPosition[] = [];
  const degraded: DegradedPosition[] = [];
  for (const [productId, bal] of info.balances) {
    if (bal.amountX18 === 0n) continue;
    const product = byProductId.get(productId);
    if (!product) {
      degraded.push({ productId, reason: 'unknown-product' });
      continue;
    }
    const risk = info.products.get(productId);
    const oraclePx = risk ? x18ToNumber(risk.oraclePriceX18) : 0;
    if (!(oraclePx > 0)) {
      degraded.push({ productId, reason: 'no-oracle' });
      continue;
    }
    const szi = x18ToNumber(bal.amountX18);
    const vQuote = x18ToNumber(bal.vQuoteX18);
    const weightFromInfo =
      risk?.longWeightInitialX18 !== null && risk?.longWeightInitialX18 !== undefined
        ? x18ToNumber(risk.longWeightInitialX18)
        : null;
    const longWeightInitial = effectiveLongWeight(weightFromInfo, product.longWeightInitial);
    positions.push({
      coin: product.coin,
      productId,
      side: szi > 0 ? 'long' : 'short',
      sizeX18: bal.amountX18,
      szi,
      size: x18ToNumber(absX18(bal.amountX18)),
      vQuote,
      entryPx: Math.abs(vQuote / szi),
      oraclePx,
      positionValue: Math.abs(szi) * oraclePx,
      unrealizedPnl: szi * oraclePx + vQuote,
      longWeightInitial,
      leverage: maxLeverageFromWeights(longWeightInitial),
    });
  }
  return { positions, ok: degraded.length === 0, degraded };
}

/** Equity view of a subaccount. */
export interface NadoEquity {
  /**
   * Unweighted health in USDT0 (0 when the subaccount does not exist). Unified margin keeps the
   * collateral inside the health: this is the whole equity, nothing is added on top.
   */
  readonly accountValue: number;
  readonly exists: boolean;
  /** Sum of `unrealizedPnl` over decodable positions. */
  readonly unrealizedPnl: number;
  /** Sum of `positionValue` over decodable positions. */
  readonly grossPositionValue: number;
}

/** Equity from one `subaccount_info` read (same request as positions, so both are from one moment). */
export function equityFromInfo(info: SubaccountInfo, byProductId: ReadonlyMap<number, PerpProduct>): NadoEquity {
  const { positions } = positionsFromInfo(info, byProductId);
  let unrealizedPnl = 0;
  let grossPositionValue = 0;
  for (const p of positions) {
    unrealizedPnl += p.unrealizedPnl;
    grossPositionValue += p.positionValue;
  }
  return {
    accountValue: info.accountValue,
    exists: info.exists,
    unrealizedPnl,
    grossPositionValue,
  };
}
All files