Skip to content
markpaper

src/account/parse.test.ts

v0.2.0 · 7.7 KB

Download file
import { describe, expect, it } from 'vitest';
import { decimalToX18, X18 } from '../numbers/x18.js';
import { subaccountInfoPayload, UNIVERSE } from './fixtures.js';
import {
  amountsUnchanged,
  nonZeroAmounts,
  parseSubaccountInfo,
  positionAmountX18,
  SubaccountInfoParseError,
} from './parse.js';
import { equityFromInfo, positionsFromInfo } from './positions.js';

describe('parseSubaccountInfo', () => {
  it('decodes exists, unweighted health, balances and products', () => {
    const info = parseSubaccountInfo(subaccountInfoPayload());
    expect(info.exists).toBe(true);
    expect(info.accountValueX18).toBe(1234n * X18 + X18 / 2n);
    expect(info.accountValue).toBe(1234.5);
    expect(info.initialHealthX18).toBe(1000n * X18);
    expect(info.maintenanceHealthX18).toBe(1100n * X18);
    expect(info.balances.get(2)).toEqual({ productId: 2, amountX18: decimalToX18('0.5'), vQuoteX18: -33000n * X18 });
    expect(info.balances.get(8)?.amountX18).toBe(0n);
    expect(info.products.get(2)).toEqual({
      productId: 2,
      oraclePriceX18: 66120n * X18,
      longWeightInitialX18: 950000000000000000n,
    });
    expect(positionAmountX18(info, 2)).toBe(decimalToX18('0.5'));
    expect(positionAmountX18(info, 999)).toBe(0n);
    expect([...nonZeroAmounts(info).keys()]).toEqual([2]);
  });

  it('a non-existent subaccount has account value 0 even if healths carry numbers', () => {
    const info = parseSubaccountInfo(subaccountInfoPayload({ exists: false }));
    expect(info.accountValueX18).toBe(0n);
    expect(info.accountValue).toBe(0);
  });

  it('tolerates missing risk / balances / products arrays', () => {
    const info = parseSubaccountInfo({
      exists: true,
      healths: [{ health: '0' }, { health: '0' }, { health: '5000000000000000000' }],
    });
    expect(info.accountValue).toBe(5);
    expect(info.balances.size).toBe(0);
    const noRisk = parseSubaccountInfo(
      subaccountInfoPayload({ perp_products: [{ product_id: 2, oracle_price_x18: '1000000000000000000' }] }),
    );
    expect(noRisk.products.get(2)?.longWeightInitialX18).toBeNull();
  });

  it('fails closed on every malformed field', () => {
    const bad = (o: Record<string, unknown>) => () => parseSubaccountInfo(subaccountInfoPayload(o));
    expect(bad({ exists: 'yes' })).toThrow(SubaccountInfoParseError);
    expect(bad({ healths: [{ health: '1' }, { health: '1' }] })).toThrow(/healths/);
    expect(bad({ healths: [{ health: '1' }, { health: '1' }, { health: '1e18' }] })).toThrow(/health/);
    expect(bad({ healths: [{ health: '1' }, { health: '1' }, null] })).toThrow(/healths\[2\]/);
    expect(bad({ perp_balances: 'x' })).toThrow(/perp_balances/);
    expect(bad({ perp_balances: [{ product_id: '2', balance: { amount: '1', v_quote_balance: '1' } }] })).toThrow(
      /product_id/,
    );
    expect(bad({ perp_balances: [{ product_id: 2 }] })).toThrow(/no balance/);
    expect(bad({ perp_balances: [{ product_id: 2, balance: { amount: '1.5', v_quote_balance: '1' } }] })).toThrow(
      /amount/,
    );
    expect(
      bad({
        perp_balances: [
          { product_id: 2, balance: { amount: '1', v_quote_balance: '1' } },
          { product_id: 2, balance: { amount: '2', v_quote_balance: '1' } },
        ],
      }),
    ).toThrow(/duplicate perp balance/);
    expect(bad({ perp_products: [{ product_id: 2, oracle_price_x18: 'x' }] })).toThrow(/oracle_price_x18/);
    expect(bad({ perp_products: [{ product_id: -1, oracle_price_x18: '1' }] })).toThrow(/product_id/);
    expect(
      bad({
        perp_products: [
          { product_id: 2, oracle_price_x18: '1' },
          { product_id: 2, oracle_price_x18: '1' },
        ],
      }),
    ).toThrow(/duplicate/);
    expect(() => parseSubaccountInfo(null)).toThrow(SubaccountInfoParseError);
    expect(() => parseSubaccountInfo([])).toThrow(SubaccountInfoParseError);
  });
});

describe('amountsUnchanged (stability fence)', () => {
  it('compares non-zero amounts only', () => {
    const a = parseSubaccountInfo(subaccountInfoPayload());
    const same = parseSubaccountInfo(subaccountInfoPayload());
    expect(amountsUnchanged(a, same)).toBe(true);
    const filled = parseSubaccountInfo(
      subaccountInfoPayload({
        perp_balances: [{ product_id: 2, balance: { amount: decimalToX18('0.6').toString(), v_quote_balance: '0' } }],
      }),
    );
    expect(amountsUnchanged(a, filled)).toBe(false);
    const flat = parseSubaccountInfo(subaccountInfoPayload({ perp_balances: [] }));
    expect(amountsUnchanged(a, flat)).toBe(false);
    const zeroEntryAdded = parseSubaccountInfo(
      subaccountInfoPayload({
        perp_balances: [
          { product_id: 2, balance: { amount: decimalToX18('0.5').toString(), v_quote_balance: '1' } },
          { product_id: 188, balance: { amount: '0', v_quote_balance: '0' } },
        ],
      }),
    );
    expect(amountsUnchanged(a, zeroEntryAdded)).toBe(true);
  });
});

describe('positionsFromInfo / equityFromInfo', () => {
  it('computes side, entry, value and uPnL with the knowledge-base formulas', () => {
    const info = parseSubaccountInfo(subaccountInfoPayload());
    const { positions, ok, degraded } = positionsFromInfo(info, UNIVERSE.byProductId);
    expect(ok).toBe(true);
    expect(degraded).toEqual([]);
    expect(positions).toHaveLength(1);
    const p = positions[0];
    expect(p).toMatchObject({
      coin: 'BTC',
      productId: 2,
      side: 'long',
      szi: 0.5,
      size: 0.5,
      vQuote: -33000,
      oraclePx: 66120,
    });
    expect(p?.entryPx).toBe(66000);
    expect(p?.positionValue).toBe(33060);
    expect(p?.unrealizedPnl).toBe(60); // 0.5 * 66120 - 33000
    expect(p?.longWeightInitial).toBe(0.95); // from perp_products.risk, not the 0.98 in symbols
    expect(p?.leverage).toBe(20);
    expect(p?.sizeX18).toBe(decimalToX18('0.5'));
  });

  it('falls back to the symbols weight and handles shorts', () => {
    const info = parseSubaccountInfo(
      subaccountInfoPayload({
        perp_balances: [
          {
            product_id: 2,
            balance: { amount: (-decimalToX18('0.25')).toString(), v_quote_balance: (16600n * X18).toString() },
          },
        ],
        perp_products: [{ product_id: 2, oracle_price_x18: (66120n * X18).toString() }],
      }),
    );
    const p = positionsFromInfo(info, UNIVERSE.byProductId).positions[0];
    expect(p).toMatchObject({
      side: 'short',
      szi: -0.25,
      size: 0.25,
      entryPx: 66400,
      longWeightInitial: 0.98,
      leverage: 50,
    });
    expect(p?.unrealizedPnl).toBeCloseTo(-0.25 * 66120 + 16600, 6);
  });

  it('degrades on an unknown product or a missing oracle instead of treating the coin as flat', () => {
    const unknown = parseSubaccountInfo(
      subaccountInfoPayload({
        perp_balances: [
          { product_id: 2, balance: { amount: decimalToX18('0.5').toString(), v_quote_balance: '0' } },
          { product_id: 777, balance: { amount: '1000000000000000000', v_quote_balance: '0' } },
        ],
      }),
    );
    const r1 = positionsFromInfo(unknown, UNIVERSE.byProductId);
    expect(r1.ok).toBe(false);
    expect(r1.degraded).toEqual([{ productId: 777, reason: 'unknown-product' }]);
    expect(r1.positions.map((p) => p.coin)).toEqual(['BTC']);
    const noOracle = parseSubaccountInfo(subaccountInfoPayload({ perp_products: [] }));
    const r2 = positionsFromInfo(noOracle, UNIVERSE.byProductId);
    expect(r2.ok).toBe(false);
    expect(r2.degraded).toEqual([{ productId: 2, reason: 'no-oracle' }]);
  });

  it('equity: account value and position totals', () => {
    const info = parseSubaccountInfo(subaccountInfoPayload());
    const eq = equityFromInfo(info, UNIVERSE.byProductId);
    expect(eq).toEqual({
      accountValue: 1234.5,
      exists: true,
      unrealizedPnl: 60,
      grossPositionValue: 33060,
    });
  });
});
All files