Skip to content
markpaper

src/account/equity.test.ts

v0.3.0 · 9.9 KB

Download file
import { describe, expect, it } from 'vitest';
import { DEFAULT_STABLES, EQUITY_FORMULA_VERSION, computeEquity, equityBasis, isBasisComparable } from './equity.js';
import type { AccountSnapshot, SpotRead } from './types.js';

const USER = `0x${'0'.repeat(40)}` as const;

function ch(av: string, opts: { used?: string; cross?: string; withdrawable?: string | null; positions?: unknown[] } = {}) {
  return {
    marginSummary: { accountValue: av, totalNtlPos: '0.0', totalRawUsd: '0.0', totalMarginUsed: opts.used ?? '0.0' },
    crossMarginSummary: { accountValue: opts.cross ?? av, totalNtlPos: '0.0', totalRawUsd: '0.0', totalMarginUsed: '0.0' },
    ...(opts.withdrawable === null ? {} : { withdrawable: opts.withdrawable ?? '0.0' }),
    assetPositions: opts.positions ?? [],
    time: 1,
  };
}

const position = (coin: string, marginUsed: string) => ({
  position: {
    coin,
    szi: '1',
    leverage: { type: 'cross', value: 10 },
    entryPx: '100',
    positionValue: '100',
    unrealizedPnl: '0',
    returnOnEquity: '0',
    liquidationPx: null,
    marginUsed,
  },
});

function snap(perp: Array<[string, unknown]>, spot: SpotRead | unknown): AccountSnapshot {
  const spotRead: SpotRead =
    typeof spot === 'object' && spot !== null && 'ok' in spot ? (spot as SpotRead) : { ok: true, state: spot };
  return { user: USER, perp: perp.map(([dex, state]) => ({ dex, state })), spot: spotRead, fetchedAt: 0 };
}

const spotOf = (...balances: unknown[]) => ({ balances });

describe('computeEquity', () => {
  it('sums perp accountValue over ALL dexes (main + HIP-3) plus free stables', () => {
    const e = computeEquity(snap([['', ch('1000')], ['xyz', ch('500')]], spotOf({ coin: 'USDC', total: '200', hold: '0' })));
    expect(e.perpAccountValue).toEqual({ '': 1000, xyz: 500 });
    expect(e.perpEquity).toBe(1500);
    expect(e.spotFreeStables).toBe(200);
    expect(e.total).toBe(1700);
    expect(e.trusted).toBe(true);
  });

  it('uses marginSummary, not crossMarginSummary (isolated positions)', () => {
    const e = computeEquity(snap([['', ch('120', { cross: '100' })]], spotOf()));
    expect(e.perpEquity).toBe(120);
    expect(e.perDex[0]?.crossAccountValue).toBe(100);
  });

  it('Unified Account: hold mirrors perp margin, so "perp + whole spot" would double count', () => {
    const e = computeEquity(snap([['', ch('600')]], spotOf({ coin: 'USDC', total: '1000', hold: '600' })));
    expect(e.total).toBe(1000);
    expect(e.total).not.toBe(1600);
    expect(e.spotStableTotal).toBe(1000);
    expect(e.spotStableReserve).toBe(600);
  });

  it('capital is invariant when HL flips the unified convention between replicas', () => {
    const a = computeEquity(snap([['', ch('70')]], spotOf({ coin: 'USDC', total: '1000', hold: '70' })));
    const b = computeEquity(snap([['', ch('990')]], spotOf({ coin: 'USDC', total: '1000', hold: '990' })));
    expect(a.total).toBe(1000);
    expect(b.total).toBe(1000);
    expect(a.perpEquity).not.toBe(b.perpEquity);
  });

  it('classic account: USDC in the perp leg, empty spot → capital = perp', () => {
    const e = computeEquity(snap([['', ch('5000')]], spotOf({ coin: 'USDC', total: '0.0', hold: '0.0' })));
    expect(e.total).toBe(5000);
    expect(e.spotStableTotal).toBe(0);
  });

  it('portfolio margin: spotHold is the reserve, the negative-hold phantom adds nothing', () => {
    const e = computeEquity(
      snap(
        [['', ch('1000')]],
        {
          portfolioMarginEnabled: true,
          balances: [
            { coin: 'USDC', total: '1000.0', hold: '-80000.0', spotHold: '1000.0' },
            { coin: 'USDT0', total: '0.0', hold: '-999999.99999999', spotHold: '0.0' },
          ],
        },
      ),
    );
    expect(e.portfolioMarginEnabled).toBe(true);
    expect(e.spotFreeStables).toBe(0);
    expect(e.total).toBe(1000);
  });

  it('excludes spot alts', () => {
    const e = computeEquity(snap([['', ch('10')]], spotOf({ coin: 'HYPE', total: '99999', hold: '0' })));
    expect(e.total).toBe(10);
    expect(e.stableBalances).toEqual([]);
  });

  it('sums exactly (no float drift)', () => {
    const e = computeEquity(snap([['', ch('0.1')], ['xyz', ch('0.2')]], spotOf({ coin: 'USDC', total: '0.3', hold: '0.0' })));
    expect(e.perpEquity).toBe(0.3);
    expect(e.total).toBe(0.6);
    expect(e.exact).toMatchObject({ perpEquity: '0.3', total: '0.6', spotFreeStables: '0.3' });
  });

  it('margin ratio is computed against the single pool (all dexes + free stables)', () => {
    const e = computeEquity(
      snap(
        [
          ['', ch('1000', { used: '200', positions: [position('BTC', '200')] })],
          ['xyz', ch('0', { used: '0' })],
        ],
        spotOf({ coin: 'USDC', total: '1000', hold: '0' }),
      ),
    );
    expect(e.marginUsed).toBe(200);
    expect(e.marginRatio).toBe(0.1);
  });

  it('margin ratio is +Infinity when capital <= 0 but margin is used (fail-closed)', () => {
    const e = computeEquity(
      snap(
        [
          ['', ch('-100')],
          ['xyz', ch('50', { used: '50', positions: [position('xyz:TSLA', '50')] })],
        ],
        spotOf(),
      ),
    );
    expect(e.marginRatio).toBe(Number.POSITIVE_INFINITY);
  });

  it('margin ratio is 0 for an empty account', () => {
    expect(computeEquity(snap([['', ch('0')]], spotOf())).marginRatio).toBe(0);
  });

  it('unreadable spot: total is null (unknown, not zero), perp stays usable, ratio uses 0 free stables', () => {
    const e = computeEquity(
      snap([['', ch('1000', { used: '100', positions: [position('ETH', '100')] })]], { ok: false, error: 'timeout' }),
    );
    expect(e.total).toBeNull();
    expect(e.spotFreeStables).toBeNull();
    expect(e.spotOk).toBe(false);
    expect(e.trusted).toBe(false);
    expect(e.spotError).toBe('timeout');
    expect(e.perpEquity).toBe(1000);
    expect(e.marginRatio).toBe(0.1);
  });

  it('spot that fails validation is reported as not ok with the reason', () => {
    const e = computeEquity(snap([['', ch('1')]], spotOf({ coin: 'USDC', total: '10', hold: '-1' })));
    expect(e.spotOk).toBe(false);
    expect(e.spotError).toMatch(/negative/);
    expect(e.total).toBeNull();
  });

  it('last-known-good fallback fills capital but marks it stale and untrusted', () => {
    const e = computeEquity(snap([['', ch('1000')]], { ok: false, error: 'x' }), { fallbackSpotFreeStables: 250 });
    expect(e.total).toBe(1250);
    expect(e.spotFreeStables).toBe(250);
    expect(e.spotFresh).toBe(false);
    expect(e.trusted).toBe(false);
    const fromString = computeEquity(snap([['', ch('1')]], { ok: false, error: 'x' }), { fallbackSpotFreeStables: '0.5' });
    expect(fromString.exact.total).toBe('1.5');
  });

  it('fallback is ignored when spot is fresh', () => {
    const e = computeEquity(snap([['', ch('1')]], spotOf({ coin: 'USDC', total: '2', hold: '0' })), { fallbackSpotFreeStables: 99 });
    expect(e.total).toBe(3);
    expect(e.spotFresh).toBe(true);
  });

  it('rejects an invalid fallback', () => {
    expect(() => computeEquity(snap([['', ch('1')]], { ok: false, error: 'x' }), { fallbackSpotFreeStables: -1 })).toThrow(
      /fallbackSpotFreeStables/,
    );
    expect(() =>
      computeEquity(snap([['', ch('1')]], { ok: false, error: 'x' }), { fallbackSpotFreeStables: Number.NaN }),
    ).toThrow();
  });

  it('a snapshot without the main dex cannot produce equity', () => {
    expect(() => computeEquity(snap([['xyz', ch('1')]], spotOf()))).toThrow(expect.objectContaining({ code: 'MISSING_MAIN_DEX' }));
  });

  it('propagates degraded perp answers instead of reading them as zero', () => {
    expect(() => computeEquity(snap([['', { assetPositions: [] }]], spotOf()))).toThrow(
      expect.objectContaining({ code: 'DEGRADED_PERP' }),
    );
  });

  it('sums withdrawable across dexes; null when any dex lacks it', () => {
    expect(computeEquity(snap([['', ch('10', { withdrawable: '7.5' })], ['xyz', ch('1', { withdrawable: '0.5' })]], spotOf())).withdrawable).toBe(8);
    expect(computeEquity(snap([['', ch('10')], ['xyz', ch('1', { withdrawable: null })]], spotOf())).withdrawable).toBeNull();
  });

  it('honours a custom stable list and validates it', () => {
    const spot = spotOf({ coin: 'USDC', total: '1', hold: '0' }, { coin: 'USDT', total: '2', hold: '0' });
    expect(computeEquity(snap([['', ch('0')]], spot), { stables: ['USDC'] }).spotFreeStables).toBe(1);
    expect(computeEquity(snap([['', ch('0')]], spot)).spotFreeStables).toBe(3);
    expect(() => computeEquity(snap([['', ch('0')]], spot), { stables: [''] })).toThrow(/stables/);
  });

  it('flags borrowed stables', () => {
    const e = computeEquity(snap([['', ch('0')]], spotOf({ coin: 'USDC', total: '1', hold: '0', borrowed: '5' })));
    expect(e.hasBorrowedStables).toBe(true);
  });

  it('includes the default stable list from the knowledge base', () => {
    expect([...DEFAULT_STABLES]).toEqual(['USDC', 'USDT', 'USDT0', 'USDH', 'USDE']);
  });
});

describe('formula fingerprint', () => {
  it('encodes version, formula, dexes (main visible) and stables, order-independent', () => {
    const a = equityBasis(['xyz', ''], ['USDT', 'USDC']);
    const b = equityBasis(['', 'xyz'], ['USDC', 'USDT']);
    expect(a).toBe(b);
    expect(a).toBe(`v${EQUITY_FORMULA_VERSION}|perp+free|dexes=[main,xyz]|stables=[USDC,USDT]`);
  });

  it('a narrower dex set gives a different basis', () => {
    expect(equityBasis(['xyz'])).not.toBe(equityBasis(['', 'xyz']));
  });

  it('computeEquity reports the basis of the snapshot it used', () => {
    const e = computeEquity(snap([['', ch('1')], ['flx', ch('1')]], spotOf()));
    expect(e.basis).toBe(equityBasis(['', 'flx'], DEFAULT_STABLES));
  });

  it('only identical fingerprints are comparable; legacy (missing) is not', () => {
    const cur = equityBasis(['']);
    expect(isBasisComparable(cur, cur)).toBe(true);
    expect(isBasisComparable(equityBasis(['', 'xyz']), cur)).toBe(false);
    expect(isBasisComparable(undefined, cur)).toBe(false);
    expect(isBasisComparable(null, cur)).toBe(false);
    expect(isBasisComparable('', cur)).toBe(false);
  });
});
All files