Skip to content
markpaper

src/account/snapshot.test.ts

v0.2.0 · 5.4 KB

Download file
import { describe, expect, it } from 'vitest';
import { decimalToX18 } from '../numbers/x18.js';
import type { Hex } from '../signing/types.js';
import type { QueryRequest, QueryRequester } from '../transport/types.js';
import { ordersData, rawOrder, subaccountInfoPayload, UNIVERSE } from './fixtures.js';
import { createSweepClock } from './orders.js';
import { readAccountSnapshot } from './snapshot.js';

const SENDER = `0x${'0'.repeat(64)}` as Hex;

function stubQuery(infos: Array<Record<string, unknown>>, orders: unknown[] = [rawOrder()]) {
  const bodies: QueryRequest[] = [];
  let infoCalls = 0;
  const query: QueryRequester = async <T>(body: QueryRequest) => {
    bodies.push(body);
    if (body.type === 'subaccount_info') return infos[Math.min(infoCalls++, infos.length - 1)] as T;
    if (body.type === 'orders') return ordersData(orders) as T;
    throw new Error(`unexpected ${body.type}`);
  };
  return { query, bodies };
}

describe('readAccountSnapshot', () => {
  it('reads positions -> orders -> positions, takes state from the second read, stable when amounts match', async () => {
    const { query, bodies } = stubQuery([subaccountInfoPayload(), subaccountInfoPayload()]);
    const snap = await readAccountSnapshot({
      query,
      sender: SENDER,
      byProductId: UNIVERSE.byProductId,
      chunkSize: 40,
      interestProductIds: [8],
    });
    expect(bodies.map((b) => b.type)).toEqual(['subaccount_info', 'orders', 'subaccount_info']);
    expect(bodies[0]).toMatchObject({ subaccount: SENDER });
    // working set = interest (8) + non-zero balances (2); no sweep
    expect(new Set(bodies[1]?.product_ids as number[])).toEqual(new Set([2, 8]));
    expect(snap.stable).toBe(true);
    expect(snap.positionsOk).toBe(true);
    expect(snap.ordersComplete).toBe(false);
    expect(snap.accountValue).toBe(1234.5);
    expect(snap.positions.map((p) => p.coin)).toEqual(['BTC']);
    expect(snap.ownOrders).toHaveLength(1);
    expect(snap.foreignOrders).toHaveLength(0);
    expect(snap.orders).toHaveLength(1);
  });

  it('is unstable when an amount changed between the reads', async () => {
    const after = subaccountInfoPayload({
      perp_balances: [{ product_id: 2, balance: { amount: decimalToX18('0.6').toString(), v_quote_balance: '0' } }],
    });
    const { query } = stubQuery([subaccountInfoPayload(), after]);
    const snap = await readAccountSnapshot({ query, sender: SENDER, byProductId: UNIVERSE.byProductId, chunkSize: 40 });
    expect(snap.stable).toBe(false);
    expect(snap.positions[0]?.size).toBe(0.6); // second read
  });

  it('is unstable (positionsOk=false) on a degraded position and drops unknown ids from the order read', async () => {
    const withUnknown = subaccountInfoPayload({
      perp_balances: [{ product_id: 777, balance: { amount: '1000000000000000000', v_quote_balance: '0' } }],
    });
    const { query, bodies } = stubQuery([withUnknown, withUnknown], []);
    const snap = await readAccountSnapshot({
      query,
      sender: SENDER,
      byProductId: UNIVERSE.byProductId,
      chunkSize: 40,
      interestProductIds: [2],
    });
    expect(bodies[1]?.product_ids).toEqual([2]);
    expect(snap.positionsOk).toBe(false);
    expect(snap.stable).toBe(false);
  });

  it('full sweep: covers the universe, ordersComplete=true, spends the window only on a usable snapshot', async () => {
    let t = 0;
    const clock = createSweepClock({ now: () => t });
    const unstable = stubQuery([
      subaccountInfoPayload(),
      subaccountInfoPayload({ perp_balances: [{ product_id: 2, balance: { amount: '1', v_quote_balance: '0' } }] }),
    ]);
    const s1 = await readAccountSnapshot({
      query: unstable.query,
      sender: SENDER,
      byProductId: UNIVERSE.byProductId,
      chunkSize: 40,
      sweepClock: clock,
    });
    expect(unstable.bodies[1]?.product_ids).toEqual([2, 8, 188]);
    expect(s1.ordersComplete).toBe(true);
    expect(s1.stable).toBe(false);
    expect(clock.due(SENDER)).toBe(true); // window kept for the next tick

    const stable = stubQuery([subaccountInfoPayload(), subaccountInfoPayload()]);
    const s2 = await readAccountSnapshot({
      query: stable.query,
      sender: SENDER,
      byProductId: UNIVERSE.byProductId,
      chunkSize: 40,
      sweepClock: clock,
    });
    expect(s2.ordersComplete).toBe(true);
    expect(s2.stable).toBe(true);
    expect(clock.due(SENDER)).toBe(false);

    const s3 = await readAccountSnapshot({
      query: stable.query,
      sender: SENDER,
      byProductId: UNIVERSE.byProductId,
      chunkSize: 40,
      sweepClock: clock,
    });
    expect(s3.ordersComplete).toBe(false);
    t = 300_000;
    const s4 = await readAccountSnapshot({
      query: stable.query,
      sender: SENDER,
      byProductId: UNIVERSE.byProductId,
      chunkSize: 40,
      sweepClock: clock,
    });
    expect(s4.ordersComplete).toBe(true);
    const forced = await readAccountSnapshot({
      query: stable.query,
      sender: SENDER,
      byProductId: UNIVERSE.byProductId,
      chunkSize: 40,
      fullSweep: true,
    });
    expect(forced.ordersComplete).toBe(true);
  });

  it('propagates read failures (never an empty snapshot)', async () => {
    const query: QueryRequester = async () => {
      throw new Error('503');
    };
    await expect(
      readAccountSnapshot({ query, sender: SENDER, byProductId: UNIVERSE.byProductId, chunkSize: 40 }),
    ).rejects.toThrow('503');
  });
});
All files