src/format/price.ts
v0.3.0 · 8.4 KB
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);
}