src/account/positions.test.ts
v0.3.0 · 5.2 KB
import { describe, expect, it } from 'vitest';
import { aggregateRoe, normalizePositions, roeFromMark } from './positions.js';
import type { PerpDexState } from './types.js';
function position(coin: string, szi: string, extra: Record<string, unknown> = {}) {
return {
type: 'oneWay',
position: {
coin,
szi,
leverage: { type: 'cross', value: 4 },
entryPx: '100.0',
positionValue: '110.0',
unrealizedPnl: '10.0',
returnOnEquity: '0.4',
liquidationPx: '80.5',
marginUsed: '27.5',
maxLeverage: 50,
...extra,
},
};
}
function dex(dexId: string, positions: unknown[], used = '1000'): PerpDexState {
return {
dex: dexId,
state: {
marginSummary: { accountValue: '5000', totalNtlPos: '0', totalRawUsd: '0', totalMarginUsed: used },
crossMarginSummary: { accountValue: '5000', totalNtlPos: '0', totalRawUsd: '0', totalMarginUsed: used },
withdrawable: '0',
assetPositions: positions,
time: 1,
},
};
}
describe('normalizePositions', () => {
it('maps a long: signed size, side, entry, mark, liquidation, leverage, margin, uPnL', () => {
const [p] = normalizePositions({ perp: [dex('', [position('BTC', '1.0')])] });
expect(p).toMatchObject({
dex: '',
coin: 'BTC',
size: 1,
side: 'long',
entryPx: 100,
markPx: 110,
positionValue: 110,
signedNotional: 110,
liquidationPx: 80.5,
leverage: { type: 'cross', value: 4, rawUsd: null },
marginUsed: 27.5,
unrealizedPnl: 10,
maxLeverage: 50,
});
});
it('ROE uses marginUsed (mark denominator): 4x and +10% is 36.4%, not the entry-based 40%', () => {
const [p] = normalizePositions({ perp: [dex('', [position('BTC', '1.0')])] });
expect(p?.roe).toBeCloseTo(10 / 27.5, 12);
expect(p?.roe).toBeCloseTo(0.3636, 4);
expect(p?.returnOnEquityHl).toBe(0.4);
expect(p?.roe).not.toBeCloseTo(0.4, 3);
});
it('identity: uPnL / marginUsed equals dir × (mark − entry) / mark × leverage (long and short)', () => {
const [long] = normalizePositions({ perp: [dex('', [position('BTC', '1.0')])] });
expect(roeFromMark({ side: 'long', entryPx: 100, markPx: 110, leverage: 4 })).toBeCloseTo(long?.roe as number, 12);
// short 2 @ entry 100, mark 90: pv 180, margin 180/3 = 60, uPnL = +20
const [short] = normalizePositions({
perp: [
dex('', [
position('ETH', '-2', {
leverage: { type: 'cross', value: 3 },
positionValue: '180',
marginUsed: '60',
unrealizedPnl: '20',
}),
]),
],
});
expect(short?.side).toBe('short');
expect(short?.size).toBe(-2);
expect(short?.signedNotional).toBe(-180);
expect(short?.markPx).toBe(90);
expect(roeFromMark({ side: 'short', entryPx: 100, markPx: 90, leverage: 3 })).toBeCloseTo(short?.roe as number, 12);
});
it('ROE is null when marginUsed <= 0 (unknown, not a loss)', () => {
const [p] = normalizePositions({ perp: [dex('', [position('BTC', '1', { marginUsed: '0' })])] });
expect(p?.roe).toBeNull();
});
it('keeps HIP-3 dex and prefixed coin; isolated rawUsd; null entry and liquidation', () => {
const [p] = normalizePositions({
perp: [
dex('', []),
dex('xyz', [
position('xyz:NVDA', '3', {
leverage: { type: 'isolated', value: 2, rawUsd: '-150.25' },
entryPx: null,
liquidationPx: null,
}),
]),
],
});
expect(p).toMatchObject({ dex: 'xyz', coin: 'xyz:NVDA', entryPx: null, liquidationPx: null });
expect(p?.leverage).toEqual({ type: 'isolated', value: 2, rawUsd: -150.25 });
});
it('skips szi = 0 and positionValue = 0 rows; a flat validated account yields []', () => {
expect(normalizePositions({ perp: [dex('', [position('A', '0'), position('B', '1', { positionValue: '0' })])] })).toEqual([]);
});
it('a non-numeric szi throws instead of reading as flat', () => {
expect(() => normalizePositions({ perp: [dex('', [position('BTC', 'abc')])] })).toThrow(
expect.objectContaining({ code: 'DEGRADED_PERP' }),
);
});
it('keeps exact decimal strings', () => {
const [p] = normalizePositions({ perp: [dex('', [position('BTC', '0.00012300')])] });
expect(p?.exact).toEqual({ szi: '0.000123', entryPx: '100', positionValue: '110', unrealizedPnl: '10', marginUsed: '27.5' });
});
});
describe('aggregateRoe', () => {
it('is Σ uPnL / Σ marginUsed over positions', () => {
const ps = normalizePositions({
perp: [
dex('', [
position('BTC', '1', { unrealizedPnl: '10', marginUsed: '40' }),
position('ETH', '-1', { unrealizedPnl: '-5', marginUsed: '10' }),
]),
],
});
expect(aggregateRoe(ps)).toBe(0.1);
});
it('is null without margin', () => {
expect(aggregateRoe([])).toBeNull();
});
});
describe('roeFromMark', () => {
it('returns null for non-positive inputs', () => {
expect(roeFromMark({ side: 'long', entryPx: 0, markPx: 1, leverage: 1 })).toBeNull();
expect(roeFromMark({ side: 'long', entryPx: 1, markPx: 0, leverage: 1 })).toBeNull();
expect(roeFromMark({ side: 'long', entryPx: 1, markPx: 1, leverage: 0 })).toBeNull();
});
});