Skip to content
markpaper

src/history/funding.ts

v0.3.0 · 5.5 KB

Download file
import type { InfoRequester } from '../transport/types.js';
import { ZERO, add, mul, neg, parseDec, requireDec, toStr, type Dec } from './decimal.js';
import { assertTimeMs } from './fills.js';
import { paginateForward } from './paginate.js';

/** One funding payment of an account (`userFunding`). */
export interface UserFundingEvent {
  time: number;
  hash: string;
  delta: {
    type: 'funding';
    coin: string;
    /** Signed USDC amount; negative = paid. */
    usdc: string;
    /** Signed position size at the payment. */
    szi: string;
    fundingRate: string;
    nSamples?: number | null;
  };
}

/** One hourly funding rate of a coin (`fundingHistory`). */
export interface FundingRateRecord {
  coin: string;
  fundingRate: string;
  premium: string;
  time: number;
}

export interface FundingPaginationOptions {
  startTime: number;
  endTime?: number;
  /** Maximum requests. Default 50. */
  maxPages?: number;
  /**
   * Server cap per response, if you know it. When set, a shorter page ends the
   * loop without an extra request; when unknown, the loop ends on a page with
   * nothing new.
   */
  pageLimit?: number;
  /** Pause between pages. Default 250 ms. */
  pageDelayMs?: number;
  signal?: AbortSignal;
}

export interface FundingPageResult<T> {
  /** Unique records sorted by time (then coin). */
  items: T[];
  pages: number;
  complete: boolean;
}

function checkWindow(o: FundingPaginationOptions): void {
  assertTimeMs('startTime', o.startTime);
  if (o.endTime !== undefined) {
    assertTimeMs('endTime', o.endTime);
    if (o.endTime < o.startTime) throw new RangeError('history: endTime is before startTime');
  }
}

const byTimeCoin = <T extends { time: number }>(coinOf: (x: T) => string) => (a: T, b: T): number =>
  a.time - b.time || (coinOf(a) < coinOf(b) ? -1 : coinOf(a) > coinOf(b) ? 1 : 0);

/**
 * Funding payments of an account for a window (`userFunding`), paged forward.
 * Records are deduplicated by `(time, coin)`; all coins are paid at the same
 * hourly timestamp, so the cursor re-reads the newest millisecond instead of
 * skipping to `newest + 1`.
 *
 * Fill `closedPnl` never contains funding; add these payments for honest PnL
 * of held positions (`summarizePnl`). The sum of `delta.usdc` is the check for
 * a funding simulator.
 *
 * @experimental The response shape is taken from public HL docs / SDK types;
 * the per-response cap, weight and ordering are not verified live.
 * Without a known cap the loop spends one extra request to detect the end.
 */
export async function fetchUserFunding(
  info: InfoRequester,
  user: string,
  opts: FundingPaginationOptions,
): Promise<FundingPageResult<UserFundingEvent>> {
  checkWindow(opts);
  const endTime = opts.endTime;
  const res = await paginateForward<UserFundingEvent>({
    label: 'userFunding',
    startTime: opts.startTime,
    endTime,
    pageLimit: opts.pageLimit,
    maxPages: opts.maxPages ?? 50,
    pageDelayMs: opts.pageDelayMs ?? 250,
    signal: opts.signal,
    timeOf: (e) => e.time,
    keyOf: (e) => `${e.time}|${e.delta?.coin ?? ''}`,
    fetchPage: (cursor) =>
      info(
        { type: 'userFunding', user, startTime: cursor, ...(endTime !== undefined ? { endTime } : {}) },
        opts.signal ? { signal: opts.signal } : undefined,
      ),
  });
  return {
    items: res.items.sort(byTimeCoin((e) => e.delta?.coin ?? '')),
    pages: res.pages,
    complete: res.complete,
  };
}

/**
 * Historical funding rates of a coin (`fundingHistory`, public), paged forward
 * and deduplicated by `(time, coin)`. Use it to simulate hourly funding in a
 * backtest.
 *
 * @experimental Shape from public HL docs / SDK types, not verified live
 * (cap, weight, HIP-3 coin naming).
 */
export async function fetchFundingHistory(
  info: InfoRequester,
  params: FundingPaginationOptions & { coin: string },
): Promise<FundingPageResult<FundingRateRecord>> {
  checkWindow(params);
  const endTime = params.endTime;
  const res = await paginateForward<FundingRateRecord>({
    label: 'fundingHistory',
    startTime: params.startTime,
    endTime,
    pageLimit: params.pageLimit,
    maxPages: params.maxPages ?? 50,
    pageDelayMs: params.pageDelayMs ?? 250,
    signal: params.signal,
    timeOf: (r) => r.time,
    keyOf: (r) => `${r.time}|${r.coin}`,
    fetchPage: (cursor) =>
      info(
        {
          type: 'fundingHistory',
          coin: params.coin,
          startTime: cursor,
          ...(endTime !== undefined ? { endTime } : {}),
        },
        params.signal ? { signal: params.signal } : undefined,
      ),
  });
  return { items: res.items.sort(byTimeCoin((r) => r.coin)), pages: res.pages, complete: res.complete };
}

/**
 * Funding payment for one hour: `-szi * oraclePx * fundingRate` (exact decimal
 * string; negative = the account pays). Positive rate: longs pay shorts.
 *
 * @experimental Formula from public HL docs, not reconciled with live `userFunding`
 * data. Reconcile a simulator against real `delta.usdc` sums.
 */
export function fundingPayment(szi: string, oraclePx: string, fundingRate: string): string {
  const v = mul(mul(requireDec(szi, 'szi'), requireDec(oraclePx, 'oraclePx')), requireDec(fundingRate, 'fundingRate'));
  return toStr(neg(v));
}

/** Sum of `delta.usdc`, optionally for one coin (exact decimal string). */
export function sumFundingUsdc(events: readonly UserFundingEvent[], coin?: string): string {
  let sum: Dec = ZERO;
  for (const e of events) {
    if (coin !== undefined && e.delta.coin !== coin) continue;
    const u = parseDec(e.delta.usdc);
    if (u) sum = add(sum, u);
  }
  return toStr(sum);
}
All files