Skip to content
markpaper

src/history/funding.test.ts

v0.3.0 · 3.9 KB

Download file
import { describe, expect, it } from 'vitest';
import type { InfoRequest, InfoRequester } from '../transport/types.js';
import {
  fetchFundingHistory,
  fetchUserFunding,
  fundingPayment,
  sumFundingUsdc,
  type FundingRateRecord,
  type UserFundingEvent,
} from './funding.js';

const USER = '0x0000000000000000000000000000000000000000'; // privacy-allow: placeholder address
const H = 3_600_000;

function ev(time: number, coin: string, usdc: string): UserFundingEvent {
  return { time, hash: '0x00', delta: { type: 'funding', coin, usdc, szi: '1', fundingRate: '0.0000125', nSamples: null } };
}

function server<T extends { time: number }>(rows: T[], cap: number) {
  const calls: InfoRequest[] = [];
  const info = (async (b: InfoRequest) => {
    calls.push(b);
    const end = b.endTime as number | undefined;
    return rows.filter((r) => r.time >= (b.startTime as number) && (end === undefined || r.time <= end)).slice(0, cap);
  }) as InfoRequester;
  return { info, calls };
}

describe('fetchUserFunding', () => {
  it('pages forward and keeps all coins paid at the same hourly timestamp', async () => {
    // 30 hours x 40 coins, cap 500: pages cut inside one hourly timestamp.
    const rows: UserFundingEvent[] = [];
    for (let h = 0; h < 30; h += 1) for (let c = 0; c < 40; c += 1) rows.push(ev(h * H, `C${c}`, '-0.01'));
    const { info, calls } = server(rows, 500);
    const res = await fetchUserFunding(info, USER, { startTime: 0, endTime: 30 * H, pageDelayMs: 0 });
    expect(res.items).toHaveLength(1200);
    expect(res.complete).toBe(true);
    expect(calls[0]).toEqual({ type: 'userFunding', user: USER, startTime: 0, endTime: 30 * H });
    expect(sumFundingUsdc(res.items)).toBe('-12');
    expect(sumFundingUsdc(res.items, 'C0')).toBe('-0.3');
  });

  it('with a known pageLimit a short page ends the loop without an extra request', async () => {
    const rows = [ev(0, 'BTC', '1'), ev(H, 'BTC', '1')];
    const { info, calls } = server(rows, 500);
    await fetchUserFunding(info, USER, { startTime: 0, pageLimit: 500, pageDelayMs: 0 });
    expect(calls).toHaveLength(1);
  });

  it('validates the window and propagates errors', async () => {
    const { info } = server<UserFundingEvent>([], 10);
    await expect(fetchUserFunding(info, USER, { startTime: 5, endTime: 1 })).rejects.toThrow(RangeError);
    const failing = (async () => {
      throw new Error('HTTP 429');
    }) as InfoRequester;
    await expect(fetchUserFunding(failing, USER, { startTime: 0 })).rejects.toThrow('HTTP 429');
  });
});

describe('fetchFundingHistory', () => {
  it('pages a coin rate history and sorts it', async () => {
    const rows: FundingRateRecord[] = Array.from({ length: 1200 }, (_, i) => ({
      coin: 'ETH',
      fundingRate: '0.00001',
      premium: '0',
      time: i * H,
    }));
    const { info, calls } = server(rows, 500);
    const res = await fetchFundingHistory(info, { coin: 'ETH', startTime: 0, pageDelayMs: 0, maxPages: 10 });
    expect(res.items).toHaveLength(1200);
    expect(res.items[1199]!.time).toBe(1199 * H);
    expect(calls[0]).toEqual({ type: 'fundingHistory', coin: 'ETH', startTime: 0 });
    expect(res.complete).toBe(true);
  });

  it('stops at maxPages as incomplete', async () => {
    const rows: FundingRateRecord[] = Array.from({ length: 100 }, (_, i) => ({ coin: 'ETH', fundingRate: '0', premium: '0', time: i }));
    const { info } = server(rows, 10);
    const res = await fetchFundingHistory(info, { coin: 'ETH', startTime: 0, pageDelayMs: 0, maxPages: 2 });
    expect(res.complete).toBe(false);
    expect(res.pages).toBe(2);
  });
});

describe('fundingPayment', () => {
  it('positive rate: a long pays, a short receives', () => {
    expect(fundingPayment('2', '100', '0.0001')).toBe('-0.02');
    expect(fundingPayment('-2', '100', '0.0001')).toBe('0.02');
    expect(fundingPayment('0', '100', '0.0001')).toBe('0');
    expect(() => fundingPayment('x', '1', '1')).toThrow(/szi/);
  });
});
All files