src/markets/parse.test.ts
v0.2.0 · 7.5 KB
import { describe, expect, it } from 'vitest';
import { X18 } from '../numbers/x18.js';
import { BTC_RAW, PONS_RAW, symbolsPayload as payload, XRP_RAW } from './fixtures.js';
import { checkProductsStable, coinOfSymbol, parseSymbols, SymbolsParseError } from './parse.js';
import type { RawSymbol } from './types.js';
describe('parseSymbols', () => {
it('decodes live BTC / PONS entries and skips spot products', () => {
const u = parseSymbols(
payload({
'BTC-PERP': BTC_RAW,
'PONS-PERP': PONS_RAW,
wAAPLx: { type: 'spot', product_id: 999, symbol: 'wAAPLx' },
}),
);
expect([...u.byCoin.keys()]).toEqual(['BTC', 'PONS']);
expect(u.skippedSpot).toEqual(['wAAPLx']);
const btc = u.byCoin.get('BTC');
expect(btc).toBeDefined();
expect(btc?.productId).toBe(2);
expect(btc?.symbol).toBe('BTC-PERP');
expect(btc?.tickX18).toBe(X18);
expect(btc?.lotX18).toBe(50000000000000n);
expect(btc?.tick).toBe(1);
expect(btc?.lot).toBe(0.00005);
expect(btc?.minSizeX18).toBe(100n * X18);
expect(btc?.minNotionalUsd).toBe(100);
expect(btc?.longWeightInitial).toBe(0.98);
expect(btc?.maxLeverage).toBe(50);
expect(btc?.makerFeeRateX18).toBe(100000000000000n);
expect(btc?.takerFeeRateX18).toBe(350000000000000n);
expect(btc?.longWeightMaintenanceX18).toBe(990000000000000000n);
expect(btc?.maxOpenInterestX18).toBe(165000000n * X18);
expect(btc?.tradingStatus).toBe('live');
expect(btc?.isolatedOnly).toBe(false);
expect(u.byProductId.get(2)).toBe(btc);
const pons = u.byCoin.get('PONS');
expect(pons?.tick).toBe(0.00001);
expect(pons?.lot).toBe(2);
expect(pons?.maxLeverage).toBe(5);
expect(pons?.tradingStatus).toBe('post_only');
expect(pons?.makerFeeRateX18).toBe(0n);
});
it('tolerates optional fields being absent or null', () => {
const u = parseSymbols(payload({ 'XRP-PERP': { ...XRP_RAW, max_open_interest_x18: null } }));
const xrp = u.byCoin.get('XRP');
expect(xrp?.makerFeeRateX18).toBeNull();
expect(xrp?.maxOpenInterestX18).toBeNull();
expect(xrp?.longWeightMaintenanceX18).toBeNull();
expect(xrp?.isolatedOnly).toBe(false);
expect(xrp?.maxLeverage).toBe(10);
});
it('keeps an unknown trading_status as a string instead of failing the universe', () => {
const u = parseSymbols(payload({ 'BTC-PERP': { ...BTC_RAW, trading_status: 'halted_new' } }));
expect(u.byCoin.get('BTC')?.tradingStatus).toBe('halted_new');
});
it('fails closed: one malformed entry invalidates the whole response', () => {
const bad = (patch: Partial<RawSymbol> | Record<string, unknown>, key = 'BTC-PERP'): unknown =>
payload({ [key]: { ...BTC_RAW, ...patch }, 'PONS-PERP': PONS_RAW });
expect(() => parseSymbols(bad({ trading_status: undefined }))).toThrow(SymbolsParseError);
expect(() => parseSymbols(bad({ trading_status: '' }))).toThrow(/trading_status/);
expect(() => parseSymbols(bad({ product_id: '2' as unknown as number }))).toThrow(/product_id/);
expect(() => parseSymbols(bad({ product_id: 0 }))).toThrow(/product_id/);
expect(() => parseSymbols(bad({ product_id: 2.5 }))).toThrow(/product_id/);
expect(() => parseSymbols(bad({ symbol: 'ETH-PERP' }))).toThrow(/key\/symbol mismatch/);
expect(() => parseSymbols(bad({ price_increment_x18: '0' }))).toThrow(/non-positive tick\/lot/);
expect(() => parseSymbols(bad({ size_increment: '-1' }))).toThrow(/non-positive tick\/lot/);
expect(() => parseSymbols(bad({ size_increment: '1e13' }))).toThrow(/size_increment/);
expect(() => parseSymbols(bad({ min_size: '-5' }))).toThrow(/negative min_size/);
expect(() => parseSymbols(bad({ long_weight_initial_x18: '1000000000000000000' }))).toThrow(/long_weight_initial/);
expect(() => parseSymbols(bad({ isolated_only: 'no' as unknown as boolean }))).toThrow(/isolated_only/);
expect(() => parseSymbols(bad({ type: 'future' as 'perp' }))).toThrow(/unknown type/);
expect(() =>
parseSymbols(payload({ 'BTC-PERP': BTC_RAW, 'ETH-PERP': { ...BTC_RAW, symbol: 'ETH-PERP' } })),
).toThrow(/duplicate product_id/);
expect(() =>
parseSymbols(payload({ 'BTC-PERP': BTC_RAW, BTC: { ...BTC_RAW, symbol: 'BTC', product_id: 3 } })),
).toThrow(/duplicate coin/);
expect(() => parseSymbols(payload({ 'BTC-PERP': null }))).toThrow(/not an object/);
expect(() => parseSymbols(payload({ ' BTC-PERP': { ...BTC_RAW, symbol: ' BTC-PERP' } }))).toThrow(/usable symbol/);
});
it('treats an empty perp map, an array and a missing map as errors, not as "no markets"', () => {
expect(() => parseSymbols(payload({}))).toThrow(/no perp products/);
expect(() => parseSymbols(payload({ wAAPLx: { type: 'spot' } }))).toThrow(/no perp products/);
expect(() => parseSymbols({ symbols: [] })).toThrow(/missing symbols map/);
expect(() => parseSymbols(null)).toThrow(/not an object/);
expect(() => parseSymbols('oops')).toThrow(SymbolsParseError);
});
it('carries the failing symbol on the error', () => {
try {
parseSymbols(payload({ 'BTC-PERP': { ...BTC_RAW, size_increment: '0' } }));
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(SymbolsParseError);
expect((e as SymbolsParseError).symbol).toBe('BTC-PERP');
}
});
});
describe('coinOfSymbol', () => {
it('strips -PERP only', () => {
expect(coinOfSymbol('BTC-PERP')).toBe('BTC');
expect(coinOfSymbol('INTC-PERP')).toBe('INTC');
expect(coinOfSymbol('wAAPLx')).toBe('wAAPLx');
});
});
describe('checkProductsStable', () => {
const base = parseSymbols(payload({ 'BTC-PERP': BTC_RAW, 'XRP-PERP': XRP_RAW }));
it('accepts a first load and additions, reports added/dropped', () => {
expect(checkProductsStable(null, base)).toEqual({ ok: true, dropped: [], added: ['BTC', 'XRP'] });
const more = parseSymbols(payload({ 'BTC-PERP': BTC_RAW, 'XRP-PERP': XRP_RAW, 'PONS-PERP': PONS_RAW }));
expect(checkProductsStable(base, more)).toEqual({ ok: true, dropped: [], added: ['PONS'] });
});
it('accepts a trading_status change (a market opening must pass a refresh)', () => {
const opened = parseSymbols(
payload({ 'BTC-PERP': { ...BTC_RAW, trading_status: 'post_only' }, 'XRP-PERP': XRP_RAW }),
);
expect(checkProductsStable(base, opened).ok).toBe(true);
});
it('rejects a moved product id or a changed grid', () => {
const moved = parseSymbols(payload({ 'BTC-PERP': { ...BTC_RAW, product_id: 3 }, 'XRP-PERP': XRP_RAW }));
expect(checkProductsStable(base, moved)).toMatchObject({ ok: false, reason: 'product_id', coin: 'BTC' });
const regrid = parseSymbols(
payload({ 'BTC-PERP': BTC_RAW, 'XRP-PERP': { ...XRP_RAW, size_increment: '1000000000000000000' } }),
);
expect(checkProductsStable(base, regrid)).toMatchObject({ ok: false, reason: 'grid', coin: 'XRP' });
});
it('strict mode rejects any dropped coin; pinned mode only a pinned one (a market rename)', () => {
const renamed = parseSymbols(payload({ 'BTC-PERP': BTC_RAW, 'XRPX-PERP': { ...XRP_RAW, symbol: 'XRPX-PERP' } }));
expect(checkProductsStable(base, renamed)).toMatchObject({ ok: false, reason: 'dropped', coin: 'XRP' });
expect(checkProductsStable(base, renamed, { pinnedCoins: ['BTC'] })).toEqual({
ok: true,
dropped: ['XRP'],
added: ['XRPX'],
});
expect(checkProductsStable(base, renamed, { pinnedCoins: ['XRP'] })).toMatchObject({
ok: false,
reason: 'dropped',
});
expect(checkProductsStable(base, renamed, { pinnedCoins: [] })).toMatchObject({ ok: true, dropped: ['XRP'] });
});
});