src/account/equity.test.ts
v0.3.0 · 9.9 KB
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);
});
});