Skip to content
markpaper

src/session/session.test.ts

v0.1.0 · 6 KB

Download file
import { describe, expect, it, vi } from 'vitest';
import { createFakeExchange } from '../testing/fake.js';
import { FIXTURE_ACCOUNT, FIXTURE_KEYS, wireOrder } from '../testing/fixtures.js';
import { createAddSlot, createRateBudget } from '../throttle/index.js';
import { createTradeSession } from './index.js';

function setup(
  changes: { dropAddReplies?: boolean; readDelayMs?: number; rejectAfterAck?: string; readOnly?: boolean } = {},
) {
  const fake = createFakeExchange({
    ackDelayMs: 3,
    readDelayMs: changes.readDelayMs ?? 0,
    dropAddReplies: changes.dropAddReplies ?? false,
    rejectAfterAck: changes.rejectAfterAck,
    canonicalAccountId: FIXTURE_ACCOUNT,
  });
  const confirmed: unknown[] = [];
  const session = createTradeSession({
    url: 'ws://localhost',
    credentials: () => FIXTURE_KEYS,
    accountId: () => FIXTURE_ACCOUNT,
    dryRun: () => changes.readOnly ?? false,
    authTimeoutMs: 70,
    subscribeTimeoutMs: 70,
    handshakeTimeoutMs: 70,
    pingIntervalMs: 1000,
    deadAfterMs: 2000,
    backoffMinMs: 25,
    backoffMaxMs: 100,
    stableAfterMs: 500,
    authFailBackoffMs: 200,
    maxBufferedBytes: 4096,
    replyTimeoutMs: 45,
    fillGraceMs: 12,
    ordersPageLimit: 40,
    logWindowMs: 120,
    strayFillMs: 300,
    strayFillMax: 50,
    seenTradesMax: 70,
    sentCloidsMax: 90,
    closeWaitMs: 200,
    httpBodyMax: 2048,
    maxPayloadBytes: 2 ** 20,
    budget: createRateBudget({
      budgetGeneral: 200,
      budgetCancel: 200,
      fuseFraction: 0.9,
      defaultBackoffMs: 300,
      rateBackoff: { minMs: 10, maxMs: 8000 },
      emaTimeConstantMs: 240,
    }),
    addSlot: createAddSlot({ staleMs: 500 }),
    webSocketFactory: fake.webSocketFactory,
    onConfirmedFill: (fill) => confirmed.push(fill),
  });
  return {
    session,
    fake,
    confirmed,
    close: async () => {
      await session.close();
      fake.close();
    },
  };
}
const wire = () => ({
  symbol: 'BTC-USD',
  side: 'BUY' as const,
  tif: 'GTC' as const,
  quantity: '0.38',
  price: '120.25',
  reduceOnly: false,
  cloid: 'cd'.repeat(16),
});
describe('trade socket correlation and outcomes', () => {
  it('authenticates, subscribes, reads and never reuses a sent cloid', async () => {
    const test = setup();
    try {
      test.session.ensureStarted();
      expect(await test.session.whenReady(200)).toBe(true);
      const first = await test.session.submitOrder(wire(), false, 50, 90);
      expect(first.kind).toBe('acked');
      const rows = await test.session.getUserOrders(40, 0, 50);
      expect(rows.orders).toHaveLength(1);
      expect((await test.session.submitOrder(wire(), false, 50, 90)).kind).toBe('not_sent');
      expect(test.fake.sent.filter((frame) => frame.type === 'add_order')).toHaveLength(1);
    } finally {
      await test.close();
    }
  });
  it('holds an unknown sent order without automatic resubmission', async () => {
    const test = setup({ dropAddReplies: true });
    try {
      test.session.ensureStarted();
      expect(await test.session.whenReady(200)).toBe(true);
      const outcome = await test.session.submitOrder(wire(), false, 20, 40);
      expect(outcome.kind).toBe('unknown');
      expect(test.fake.orders.size).toBe(1);
      expect(test.fake.sent.filter((frame) => frame.type === 'add_order')).toHaveLength(1);
    } finally {
      await test.close();
    }
  });
  it('waits for a rejection after acknowledgment when requested', async () => {
    const test = setup({ rejectAfterAck: 'FAILED_MARGIN_CHECK' });
    try {
      test.session.ensureStarted();
      expect(await test.session.whenReady(200)).toBe(true);
      const outcome = await test.session.submitOrder(wire(), false, 50, 90, { settleAfterAckMs: 20 });
      expect(outcome.kind).toBe('rejected');
    } finally {
      await test.close();
    }
  });
  it('blocks writes in read-only mode at the session boundary', async () => {
    const test = setup({ readOnly: true });
    try {
      test.session.ensureStarted();
      expect(await test.session.whenReady(200)).toBe(true);
      expect((await test.session.submitOrder(wire(), false, 50, 90)).kind).toBe('not_sent');
      expect(test.fake.orders.size).toBe(0);
    } finally {
      await test.close();
    }
  });
  it('invalidates a socket epoch after an uncorrelated read timeout', async () => {
    const test = setup({ readDelayMs: 90 });
    try {
      test.session.ensureStarted();
      expect(await test.session.whenReady(200)).toBe(true);
      const epoch = test.session.health().epoch;
      await expect(test.session.getUserOrders(40, 0, 12)).rejects.toMatchObject({ kind: 'timeout' });
      expect(test.session.health().ready).toBe(false);
      expect(test.session.health().epoch).toBeGreaterThanOrEqual(epoch);
    } finally {
      await test.close();
    }
  });
  it.each(['symbol', 'side', 'orderId'] as const)(
    'quarantines a contradictory fill %s without confirming it',
    async (field) => {
      const test = setup({ dropAddReplies: true });
      try {
        test.session.ensureStarted();
        expect(await test.session.whenReady(200)).toBe(true);
        const result = test.session.submitOrder(wire(), true, 180, 250);
        await vi.waitFor(() => expect(test.fake.orders.size).toBe(1));
        const row = [...test.fake.orders.values()][0]!;
        test.fake.emit({ order_response: wireOrder(row) });
        await Promise.resolve();
        const fill = {
          trade_id: 'synthetic-trade',
          order_id: row.orderId,
          symbol: row.symbol,
          side: row.side,
          quantity: '0.18',
          price: row.price,
          client_order_id: row.cloid,
        };
        if (field === 'symbol') fill.symbol = 'ETH-USD';
        if (field === 'side') fill.side = 'SELL';
        if (field === 'orderId') fill.order_id = '20000000-0000-4000-8000-000000000002';
        test.fake.emit({ fill_response: fill });
        expect((await result).kind).toBe('unknown');
        expect(test.confirmed).toEqual([]);
      } finally {
        await test.close();
      }
    },
  );
});
All files