Skip to content
markpaper

src/markets/parse.test.ts

v0.2.0 · 7.5 KB

Download file
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'] });
  });
});
All files