Skip to content
markpaper

src/format/price.ts

v0.3.0 · 8.4 KB

Download file
import {
  HlFormatError,
  add,
  decimalPlaces,
  isInteger,
  isPositive,
  magnitude,
  parseDec,
  powTen,
  roundToExp,
  significantDigits,
  sub,
  toPlain,
  type Dec,
  type Rounding,
} from './decimal.js';
import { assertSzDecimals, parsePositive } from './size.js';
import type { FormatPriceOptions, MarketKind, Numeric, OrderSide, PriceGridOptions, PriceRounding } from './types.js';

/** Maximum significant figures of a non-integer price. */
export const MAX_PRICE_SIG_FIGS = 5;

/** Decimal budget before subtracting `szDecimals`: 6 for perps, 8 for spot. */
const PRICE_DECIMAL_BASE: Record<MarketKind, number> = { perp: 6, spot: 8 };

/** Safety cap for {@link shiftPriceTicks}; the walk is linear in the tick count. */
const MAX_TICK_SHIFT = 100_000;

/**
 * Maximum decimals of a price: `max(0, 6 - szDecimals)` for perps (main and
 * HIP-3), `max(0, 8 - szDecimals)` for spot.
 *
 * @experimental for `market: 'spot'`: the `8 - szDecimals` rule comes from a
 * single source (HL docs) and the knowledge base has not placed spot orders with
 * it. Supporting evidence: resting mainnet spot books (2026-09) contain prices such
 * as `0.00000558` at `szDecimals=0` and `0.000009` at `szDecimals=2`, which the
 * perp grid would reject and this grid accepts. Risk: a spot price on the wrong
 * grid is rejected with `Order has invalid price` (no fill, no position impact).
 */
export function pxDecimals(szDecimals: number, market: MarketKind = 'perp'): number {
  assertSzDecimals(szDecimals);
  const base = PRICE_DECIMAL_BASE[market];
  if (base === undefined) throw new HlFormatError(`Invalid market: ${String(market)}`);
  return Math.max(0, base - szDecimals);
}

/** @internal True for `'B'` / `'buy'`. */
export function isBuySide(side: OrderSide | undefined): boolean {
  if (side === 'B' || side === 'buy') return true;
  if (side === 'A' || side === 'sell') return false;
  throw new HlFormatError(`Invalid side: ${String(side)} (expected 'B' | 'A' | 'buy' | 'sell')`);
}

/** @internal Maps a price rounding mode (+ side) to a plain direction. */
export function resolvePriceRounding(mode: PriceRounding, side: OrderSide | undefined): Rounding {
  switch (mode) {
    case 'round':
    case 'floor':
    case 'ceil':
      return mode;
    case 'passive':
      return isBuySide(side) ? 'floor' : 'ceil';
    case 'aggressive':
      return isBuySide(side) ? 'ceil' : 'floor';
    default:
      throw new HlFormatError(`Invalid price rounding mode: ${String(mode)}`);
  }
}

/**
 * @internal Tick exponent at a positive price: the tick is `10^tickExp`.
 * `tick = max(10^-pxDecimals, 10^(floor(log10(px)) - 4))`, and with the integer
 * exemption the significant-figure term is capped at 1 (integers are always valid).
 */
export function tickExpAt(px: Dec, grid: PriceGridOptions): number {
  const decExp = -pxDecimals(grid.szDecimals, grid.market ?? 'perp');
  let sigExp = magnitude(px) - (MAX_PRICE_SIG_FIGS - 1);
  if (grid.integerExemption !== false) sigExp = Math.min(sigExp, 0);
  return Math.max(decExp, sigExp);
}

/** @internal Rounds an exact positive price onto the grid; throws if it rounds to zero. */
export function roundPriceDec(px: Dec, grid: PriceGridOptions, rounding: Rounding): Dec {
  const r = roundToExp(px, tickExpAt(px, grid), rounding);
  if (!isPositive(r)) {
    throw new HlFormatError(
      `Price ${toPlain(px)} rounds to 0 on the grid (szDecimals=${grid.szDecimals}, market=${grid.market ?? 'perp'})`,
    );
  }
  return r;
}

/**
 * Rounds a price onto the Hyperliquid price grid and returns the canonical wire string.
 *
 * Rules (orders.md §5, market-data.md §3):
 * - At most 5 significant figures AND at most `6 - szDecimals` decimals (perps) /
 *   `8 - szDecimals` (spot). Both apply: checking only significant figures gives
 *   `Order has invalid price` rejects on coins below $1 (0.26523 at
 *   `szDecimals >= 2` must be `0.2652`).
 * - Integer prices are valid regardless of significant figures. Between 1e4 and
 *   1e5 the step is 1 either way (BTC 79555.55 -> `79556`).
 * - The tick floats with the price and jumps 10x when the price crosses a power of
 *   ten (SOL: 0.001 below 100, 0.01 above), so it is always derived from the value,
 *   never stored or read off the string length.
 * - Default rounding is to nearest. For post-only quotes use `mode: 'passive'`
 *   (never crosses by rounding), for IoC caps `mode: 'aggressive'`.
 *
 * Integer exemption risk (`integerExemption`, default `true`): for prices >= 100000
 * the exemption allows step 1 instead of step 10. It is documented by HL and
 * implemented by the SDK, but the knowledge base has not observed such prices
 * live. Pass `integerExemption: false` to stay on the strict 5-significant-figure
 * grid, which is valid under both readings. Spot (`market: 'spot'`) is
 * `@experimental`, see {@link pxDecimals}.
 *
 * Throws `HlFormatError` for non-positive, non-finite or malformed prices, or if
 * the price rounds to 0 (e.g. `0.4` with 0 allowed decimals, rounded down).
 */
export function formatPrice(px: Numeric, opts: FormatPriceOptions): string {
  const rounding = resolvePriceRounding(opts.mode ?? 'round', opts.side);
  return toPlain(roundPriceDec(parsePositive(px, 'price'), opts, rounding));
}

/**
 * Tick size at a price, as a canonical string: `priceTick(82.716, { szDecimals: 2 }) === '0.001'`.
 * Why: any "N ticks" parameter is not scale invariant (10 ticks of SOL are 0.01%
 * of price at 99.9 but 0.1% at 100.1); compute the tick at the current price and
 * clamp tick-based offsets by a fraction of price.
 */
export function priceTick(px: Numeric, grid: PriceGridOptions): string {
  return toPlain(powTen(tickExpAt(parsePositive(px, 'price'), grid)));
}

/**
 * True if the price is already on the grid (no rounding applied): positive,
 * at most `pxDecimals` decimals, and at most 5 significant figures unless it is
 * an integer (with the integer exemption).
 */
export function isValidPrice(px: Numeric, grid: PriceGridOptions): boolean {
  const maxDec = pxDecimals(grid.szDecimals, grid.market ?? 'perp');
  let d: Dec;
  try {
    d = parseDec(px, 'price');
  } catch {
    return false;
  }
  if (!isPositive(d)) return false;
  if (decimalPlaces(d) > maxDec) return false;
  if (significantDigits(d) <= MAX_PRICE_SIG_FIGS) return true;
  return grid.integerExemption !== false && isInteger(d);
}

/**
 * Moves a price by `ticks` grid steps (positive = up, negative = down), honouring
 * the 10x tick jump at powers of ten: one tick below 100.00 (perp, `szDecimals=0`)
 * is 99.999, one tick above 99.999 is 100. The start price is first snapped to the
 * grid with `opts.mode` (default `'round'`).
 *
 * Why: shifting by a fixed tick breaks at 100/1000 boundaries, where the offset
 * silently changes tenfold. Throws if the walk would reach a price <= 0.
 */
export function shiftPriceTicks(px: Numeric, ticks: number, opts: FormatPriceOptions): string {
  if (!Number.isSafeInteger(ticks) || Math.abs(ticks) > MAX_TICK_SHIFT) {
    throw new HlFormatError(`Invalid ticks: ${String(ticks)} (expected integer with |ticks| <= ${MAX_TICK_SHIFT})`);
  }
  const rounding = resolvePriceRounding(opts.mode ?? 'round', opts.side);
  let cur = roundPriceDec(parsePositive(px, 'price'), opts, rounding);
  for (let i = 0; i < Math.abs(ticks); i++) {
    cur = ticks > 0 ? tickUp(cur, opts) : tickDown(cur, opts);
  }
  return toPlain(cur);
}

/** @internal One grid step up from an on-grid price. */
export function tickUp(cur: Dec, grid: PriceGridOptions): Dec {
  return add(cur, powTen(tickExpAt(cur, grid)));
}

/** @internal One grid step down from an on-grid price (uses the finer tick below a power of ten). */
export function tickDown(cur: Dec, grid: PriceGridOptions): Dec {
  let next = sub(cur, powTen(tickExpAt(cur, grid)));
  if (isPositive(next) && magnitude(next) < magnitude(cur)) {
    // Crossed a power of ten: the grid below is finer.
    next = sub(cur, powTen(tickExpAt(next, grid)));
  }
  if (!isPositive(next)) {
    throw new HlFormatError(`Cannot move one tick below ${toPlain(cur)}: price would be <= 0`);
  }
  return next;
}

/**
 * Compares two prices as quantized wire strings (orders.md §5.5). Why: a raw
 * planner number never equals the exchange's string (41.236 goes out as 41.24
 * and comes back as 41.24), and comparing raw numbers gives an endless
 * cancel/re-place loop.
 * Both sides go through `formatPrice` with the same options.
 */
export function samePriceOnGrid(a: Numeric, b: Numeric, opts: FormatPriceOptions): boolean {
  return formatPrice(a, opts) === formatPrice(b, opts);
}
All files