src/format/price.test.ts
v0.3.0 · 11.4 KB
import { describe, expect, it } from 'vitest';
import { HlFormatError } from './decimal.js';
import { formatPrice as sdkFormatPrice } from '@nktkas/hyperliquid/utils';
import { formatPrice, isValidPrice, priceTick, pxDecimals, samePriceOnGrid, shiftPriceTicks } from './price.js';
describe('pxDecimals', () => {
it('is 6 - szDecimals for perps and 8 - szDecimals for spot, floored at 0', () => {
expect(pxDecimals(2)).toBe(4);
expect(pxDecimals(6)).toBe(0);
expect(pxDecimals(7)).toBe(0);
expect(pxDecimals(2, 'spot')).toBe(6);
expect(() => pxDecimals(2, 'margin' as 'perp')).toThrow(HlFormatError);
});
});
describe('formatPrice: reference cases from the knowledge base', () => {
it.each([
[83.20512, 2, '83.205'], // HYPE: 5 significant figures
[79555.55, 5, '79556'], // BTC: 5 sig figs remove the fraction
[1.4220512, 0, '1.4221'], // XRP: cap is 5 sig figs, not 6 decimals
[1.3521, 0, '1.3521'], // already on grid
[0.26523, 2, '0.2652'], // EIGEN: decimals cap binds below $1
])('formatPrice(%s, szDecimals=%s) === %s', (px, szDecimals, expected) => {
expect(formatPrice(px, { szDecimals })).toBe(expected);
});
it('applies both caps: significant figures and decimals', () => {
expect(formatPrice(0.26523, { szDecimals: 0 })).toBe('0.26523');
expect(formatPrice(0.26523, { szDecimals: 3 })).toBe('0.265');
});
});
describe('formatPrice: integers', () => {
it('rounds to integers from 1e4', () => {
expect(formatPrice(12345.6, { szDecimals: 0 })).toBe('12346');
});
it('allows integer prices above 1e5 by default, strict 5 sig figs on request', () => {
expect(formatPrice(123456.7, { szDecimals: 0 })).toBe('123457');
expect(formatPrice(123456.7, { szDecimals: 0, integerExemption: false })).toBe('123460');
});
});
describe('formatPrice: directions', () => {
it('floor / ceil / round', () => {
expect(formatPrice(83.2059, { szDecimals: 2, mode: 'floor' })).toBe('83.205');
expect(formatPrice(83.2051, { szDecimals: 2, mode: 'ceil' })).toBe('83.206');
expect(formatPrice(83.2055, { szDecimals: 2 })).toBe('83.206');
});
it('passive rounds away from the book, aggressive toward execution', () => {
const o = { szDecimals: 2 } as const;
expect(formatPrice(83.2055, { ...o, mode: 'passive', side: 'B' })).toBe('83.205');
expect(formatPrice(83.2055, { ...o, mode: 'passive', side: 'sell' })).toBe('83.206');
expect(formatPrice(83.2055, { ...o, mode: 'aggressive', side: 'buy' })).toBe('83.206');
expect(formatPrice(83.2055, { ...o, mode: 'aggressive', side: 'A' })).toBe('83.205');
});
it('requires side for passive / aggressive and a known mode', () => {
expect(() => formatPrice(1, { szDecimals: 2, mode: 'passive' })).toThrow(HlFormatError);
expect(() => formatPrice(1, { szDecimals: 2, mode: 'up' as 'ceil' })).toThrow(HlFormatError);
});
it('floor is not fooled by float artifacts (epsilon)', () => {
const px = 98.00999999999999;
expect(formatPrice(px, { szDecimals: 2, mode: 'floor' })).toBe('98.01');
});
});
describe('formatPrice: tick jump at powers of ten', () => {
it('rounds across 100 correctly', () => {
expect(formatPrice(99.9996, { szDecimals: 0 })).toBe('100');
expect(formatPrice(99.9996, { szDecimals: 0, mode: 'floor' })).toBe('99.999');
expect(formatPrice(100.004, { szDecimals: 0 })).toBe('100');
expect(formatPrice(100.005, { szDecimals: 0 })).toBe('100.01');
});
it('matches the xyz:MU ladder (tick 0.1 at >= 1000, 0.01 below)', () => {
expect(isValidPrice('1000.2', { szDecimals: 2 })).toBe(true);
expect(isValidPrice('950.02', { szDecimals: 2 })).toBe(true);
expect(isValidPrice('1000.25', { szDecimals: 2 })).toBe(false);
expect(formatPrice(1000.25, { szDecimals: 2, mode: 'floor' })).toBe('1000.2');
});
});
describe('formatPrice: wire form and errors', () => {
it('strips trailing zeros and absorbs float noise', () => {
expect(formatPrice('83.2000', { szDecimals: 2 })).toBe('83.2');
expect(formatPrice(0.1 + 0.2, { szDecimals: 0 })).toBe('0.3');
expect(formatPrice(40, { szDecimals: 2 })).toBe('40');
});
it('throws when the price rounds to zero', () => {
expect(() => formatPrice(0.4, { szDecimals: 6, mode: 'floor' })).toThrow(HlFormatError);
expect(() => formatPrice(0.4, { szDecimals: 6 })).toThrow(HlFormatError);
expect(formatPrice(0.6, { szDecimals: 6 })).toBe('1');
});
it('throws on non-positive or malformed prices', () => {
for (const bad of [0, -1, NaN, Infinity, 'abc']) {
expect(() => formatPrice(bad, { szDecimals: 2 })).toThrow(HlFormatError);
}
expect(() => formatPrice(1, { szDecimals: 9 })).toThrow(HlFormatError);
});
});
describe('formatPrice: spot (experimental)', () => {
it('uses 8 - szDecimals decimals', () => {
expect(formatPrice('0.0000123456789', { szDecimals: 0, market: 'spot' })).toBe('0.00001235');
expect(formatPrice('0.0000123456789', { szDecimals: 0, market: 'spot', mode: 'floor' })).toBe('0.00001234');
expect(formatPrice('0.0000123456789', { szDecimals: 0 })).toBe('0.000012');
});
});
describe('priceTick', () => {
it('derives the tick from the price, not a constant', () => {
expect(priceTick(82.716, { szDecimals: 2 })).toBe('0.001'); // HYPE
expect(priceTick(99.9, { szDecimals: 2 })).toBe('0.001'); // SOL below 100
expect(priceTick(100.1, { szDecimals: 2 })).toBe('0.01'); // SOL above 100
expect(priceTick(77500, { szDecimals: 5 })).toBe('1'); // BTC
expect(priceTick(0.26523, { szDecimals: 2 })).toBe('0.0001'); // decimals cap binds
expect(priceTick(123456, { szDecimals: 0 })).toBe('1');
expect(priceTick(123456, { szDecimals: 0, integerExemption: false })).toBe('10');
});
it('is independent of the string length of a round price', () => {
expect(priceTick('40', { szDecimals: 2 })).toBe('0.001');
expect(priceTick('2', { szDecimals: 2 })).toBe('0.0001');
});
});
describe('isValidPrice', () => {
it('accepts on-grid prices and rejects off-grid ones', () => {
expect(isValidPrice('83.205', { szDecimals: 2 })).toBe(true);
expect(isValidPrice('83.2051', { szDecimals: 2 })).toBe(false);
expect(isValidPrice('0.26523', { szDecimals: 2 })).toBe(false);
expect(isValidPrice('79556', { szDecimals: 5 })).toBe(true);
expect(isValidPrice('79555.5', { szDecimals: 5 })).toBe(false);
expect(isValidPrice('123456', { szDecimals: 0 })).toBe(true);
expect(isValidPrice('123456', { szDecimals: 0, integerExemption: false })).toBe(false);
expect(isValidPrice('123460', { szDecimals: 0, integerExemption: false })).toBe(true);
});
it('rejects non-positive and malformed input', () => {
for (const bad of ['0', '-1', 'abc', NaN]) expect(isValidPrice(bad, { szDecimals: 2 })).toBe(false);
});
});
describe('shiftPriceTicks', () => {
it('walks across a power of ten with the correct tick on each side', () => {
expect(shiftPriceTicks(100, -1, { szDecimals: 0 })).toBe('99.999');
expect(shiftPriceTicks(100, -2, { szDecimals: 0 })).toBe('99.998');
expect(shiftPriceTicks(99.999, 1, { szDecimals: 0 })).toBe('100');
expect(shiftPriceTicks(100, 1, { szDecimals: 0 })).toBe('100.01');
expect(shiftPriceTicks(1000.2, -3, { szDecimals: 2 })).toBe('999.99');
});
it('respects the decimals cap and the integer exemption', () => {
expect(shiftPriceTicks(100, -1, { szDecimals: 4 })).toBe('99.99');
expect(shiftPriceTicks(100000, -1, { szDecimals: 0 })).toBe('99999');
expect(shiftPriceTicks(100000, -1, { szDecimals: 0, integerExemption: false })).toBe('99999');
expect(shiftPriceTicks(100000, 1, { szDecimals: 0, integerExemption: false })).toBe('100010');
});
it('snaps the start price with the given mode when ticks = 0', () => {
expect(shiftPriceTicks(83.20512, 0, { szDecimals: 2 })).toBe('83.205');
expect(shiftPriceTicks(83.2059, 0, { szDecimals: 2, mode: 'floor' })).toBe('83.205');
});
it('throws below zero and on invalid tick counts', () => {
expect(() => shiftPriceTicks(0.0001, -1, { szDecimals: 2 })).toThrow(HlFormatError);
expect(() => shiftPriceTicks(1, 1.5, { szDecimals: 2 })).toThrow(HlFormatError);
expect(() => shiftPriceTicks(1, 1e6, { szDecimals: 2 })).toThrow(HlFormatError);
});
});
describe('samePriceOnGrid', () => {
it('compares quantized strings, not raw numbers', () => {
expect(samePriceOnGrid(41.236, '41.24', { szDecimals: 4 })).toBe(true);
expect(samePriceOnGrid(41.236, 41.234, { szDecimals: 2 })).toBe(false);
expect(samePriceOnGrid('83.2050', 83.205, { szDecimals: 2 })).toBe(true);
});
});
describe('grid cases observed on mainnet books (2026-09)', () => {
it('spot: 8 - szDecimals decimals, prices the perp grid would reject', () => {
// @27 (szDecimals 0) and @146 (szDecimals 2) resting levels
for (const px of ['0.00000558', '0.00002006', '0.0000203']) {
expect(isValidPrice(px, { szDecimals: 0, market: 'spot' })).toBe(true);
expect(isValidPrice(px, { szDecimals: 0, market: 'perp' })).toBe(false);
}
expect(isValidPrice('0.000009', { szDecimals: 2, market: 'spot' })).toBe(true);
expect(isValidPrice('0.000009', { szDecimals: 2, market: 'perp' })).toBe(false);
expect(isValidPrice('0.0000009', { szDecimals: 2, market: 'spot' })).toBe(false);
expect(formatPrice('0.00000011', { szDecimals: 0, market: 'spot' })).toBe('0.00000011');
expect(formatPrice(1.1e-7, { szDecimals: 0, market: 'spot' })).toBe('0.00000011');
expect(() => formatPrice('0.00000011', { szDecimals: 2, market: 'spot', mode: 'floor' })).toThrow(HlFormatError);
});
it('HIP-3 perps use the perp grid (xyz:TSLA szDecimals 3, xyz:SP500 szDecimals 3)', () => {
expect(priceTick('359.42', { szDecimals: 3 })).toBe('0.01');
expect(priceTick('7622.3', { szDecimals: 3 })).toBe('0.1');
expect(formatPrice('7622.349', { szDecimals: 3 })).toBe('7622.3');
expect(isValidPrice('359.425', { szDecimals: 3 })).toBe(false);
});
it('sub-cent perp (kPEPE szDecimals 0) keeps 6 decimals', () => {
expect(priceTick('0.003591', { szDecimals: 0 })).toBe('0.000001');
expect(formatPrice('0.0035915', { szDecimals: 0, mode: 'passive', side: 'B' })).toBe('0.003591');
expect(formatPrice('0.0035915', { szDecimals: 0, mode: 'passive', side: 'A' })).toBe('0.003592');
});
it('prices >= 1e5 (xyz:IBOV mid 175000.0) with and without the integer exemption', () => {
expect(formatPrice('175000.0', { szDecimals: 0 })).toBe('175000');
expect(formatPrice('175012.4', { szDecimals: 0 })).toBe('175012');
expect(formatPrice('175012.4', { szDecimals: 0, integerExemption: false })).toBe('175010');
expect(isValidPrice('175012', { szDecimals: 0, integerExemption: false })).toBe(false);
});
});
describe('parity with the SDK formatter (truncation) on the strict grid', () => {
const cases: Array<[string, number, 'perp' | 'spot']> = [
['83.20512', 2, 'perp'],
['79555.55', 5, 'perp'],
['1.4220512', 0, 'perp'],
['0.26523', 2, 'perp'],
['0.0035915', 0, 'perp'],
['99.99999', 0, 'perp'],
['1000.26', 1, 'perp'],
['12345.678', 3, 'perp'],
['0.000012789', 0, 'spot'],
['0.00001279', 2, 'spot'],
['123456', 0, 'perp'],
['2598.15', 4, 'perp'],
];
it.each(cases)('%s szDecimals=%i %s: floor equals SDK output and both are on our grid', (px, szDecimals, market) => {
const ours = formatPrice(px, { szDecimals, market, mode: 'floor' });
const sdk = sdkFormatPrice(px, szDecimals, market);
expect(ours).toBe(sdk);
expect(isValidPrice(sdk, { szDecimals, market })).toBe(true);
});
});