Skip to content
markpaper

src/account/positions.test.ts

v0.3.0 · 5.2 KB

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