Skip to content
markpaper

src/history/fills.ts

v0.3.0 · 8.1 KB

Download file
import type { InfoRequester } from '../transport/types.js';
import { paginateForward } from './paginate.js';
import type { FillLike, HlFill } from './types.js';

/** Server cap of `userFills` and of one `userFillsByTime` response. */
export const USER_FILLS_PAGE_LIMIT = 2000;

/** Default pause between `userFillsByTime` pages (heavy endpoint). */
export const DEFAULT_FILLS_PAGE_DELAY_MS = 250;

/**
 * Dedup key of a fill: `tid` when present, otherwise the composite
 * `hash_tid_time_coin_px_sz_side` for feeds where tid/hash can be absent.
 */
export function fillKey(f: FillLike): string {
  if (f.tid !== undefined && f.tid !== null) return `tid:${f.tid}`;
  return `${f.hash ?? ''}_${f.tid ?? ''}_${f.time}_${f.coin}_${f.px}_${f.sz}_${f.side}`;
}

/**
 * Removes duplicate fills (first occurrence wins).
 *
 * Why: merging `userFills` with a second `dex: 'xyz'` request (HL ignores `dex`
 * for fills), a WS snapshot with its stream, or REST with WS doubles size and
 * closedPnl. Keep the dedup even if HL ever starts honouring `dex` -
 * it stays correct.
 */
export function dedupFills<F extends FillLike>(fills: readonly F[]): F[] {
  const seen = new Set<string>();
  const out: F[] = [];
  for (const f of fills) {
    const k = fillKey(f);
    if (seen.has(k)) continue;
    seen.add(k);
    out.push(f);
  }
  return out;
}

/** Sorts fills chronologically (then by tid). The API does not guarantee order. */
export function sortFills<F extends FillLike>(fills: readonly F[]): F[] {
  return [...fills].sort((a, b) => a.time - b.time || (a.tid ?? 0) - (b.tid ?? 0));
}

export interface FillDeduperOptions {
  /** Evict when the set grows beyond this size. Default 20 000. */
  maxSize?: number;
  /** How many of the oldest keys to evict at once. Default 10 000. */
  evictCount?: number;
}

export interface FillDeduper {
  /** Returns true when the fill is new (and remembers it), false for a duplicate. */
  add(fill: FillLike): boolean;
  has(fill: FillLike): boolean;
  readonly size: number;
}

/**
 * Bounded "seen tid" set for long-running processes that merge WS and REST fills.
 * An unbounded `Set<tid>` grows forever; by default the oldest 10 000 keys are
 * dropped once the set exceeds 20 000.
 */
export function createFillDeduper(opts: FillDeduperOptions = {}): FillDeduper {
  const maxSize = Math.max(1, opts.maxSize ?? 20_000);
  const evictCount = Math.max(1, Math.min(opts.evictCount ?? 10_000, maxSize));
  const seen = new Set<string>();
  return {
    add(fill) {
      const k = fillKey(fill);
      if (seen.has(k)) return false;
      seen.add(k);
      if (seen.size > maxSize) {
        let n = 0;
        for (const key of seen) {
          if (n++ >= evictCount) break;
          seen.delete(key);
        }
      }
      return true;
    },
    has(fill) {
      return seen.has(fillKey(fill));
    },
    get size() {
      return seen.size;
    },
  };
}

export interface FetchFillsByTimeOptions {
  /** Inclusive start, unix ms. */
  startTime: number;
  /** Inclusive end, unix ms. Omit for "until now". */
  endTime?: number;
  /**
   * `false` (default) returns raw partial fills; `true` merges partials of one
   * crossing order within one time slice. How aggregation interacts with the
   * 2000 cap and the cursor is not verified.
   */
  aggregateByTime?: boolean;
  /**
   * Stop after collecting this many fills and report `aborted: true`: a very
   * active account otherwise costs hundreds of heavy calls.
   */
  maxFills?: number;
  /** Hard cap on requests. */
  maxPages?: number;
  /** Pause between pages. Default 250 ms. */
  pageDelayMs?: number;
  signal?: AbortSignal;
}

export interface FetchFillsResult {
  /** Unique fills sorted by time (then tid). */
  fills: HlFill[];
  pages: number;
  /** The window was read to the end. */
  complete: boolean;
  /** `maxFills` was reached. */
  aborted: boolean;
  /**
   * Milliseconds holding a full page (2000) of fills by themselves. Fills of
   * such a millisecond beyond 2000 are unreachable by time pagination.
   */
  denseMillis: number[];
}

/** @internal */
export function assertTimeMs(name: string, v: number): void {
  if (!Number.isSafeInteger(v) || v < 0) {
    throw new RangeError(`history: ${name} must be a non-negative integer ms`);
  }
}

/**
 * Reads a user's fills for a time window through `userFillsByTime`, paging forward.
 *
 * Rules encoded here:
 * - One response holds at most 2000 fills and they are the OLDEST from
 *   `startTime`, so the window is paged forward in time.
 * - The cursor re-reads the newest millisecond of a page instead of jumping to
 *   `newest + 1`, and duplicates are removed by `tid`. `newest + 1` loses the
 *   tail of a millisecond cut by the cap. Fills beyond 2000 inside ONE
 *   millisecond are unreachable either way; such milliseconds are reported in
 *   `denseMillis`.
 * - `dex` is never sent: HL ignores it for fills (one response already contains
 *   `xyz:*` fills), a second `dex:'xyz'` request doubles the weight and, merged,
 *   double-counts every fill. Tell HIP-3 fills apart by the `xyz:` coin prefix.
 * - Errors are propagated, never turned into an empty array.
 * - Per public docs (not verified) only the ~10 000 most recent fills of an
 *   address are reachable; pagination cannot return older history.
 * - TWAP slice fills are NOT in this feed - see `fetchTwapSliceFills`.
 *
 * Weight: 20 per page plus extra per returned items (per docs).
 */
export async function fetchFillsByTime(
  info: InfoRequester,
  user: string,
  opts: FetchFillsByTimeOptions,
): Promise<FetchFillsResult> {
  assertTimeMs('startTime', opts.startTime);
  if (opts.endTime !== undefined) {
    assertTimeMs('endTime', opts.endTime);
    if (opts.endTime < opts.startTime) throw new RangeError('history: endTime is before startTime');
  }
  const aggregateByTime = opts.aggregateByTime ?? false;
  const endTime = opts.endTime;
  const res = await paginateForward<HlFill>({
    label: 'userFillsByTime',
    startTime: opts.startTime,
    endTime,
    pageLimit: USER_FILLS_PAGE_LIMIT,
    maxPages: opts.maxPages,
    maxItems: opts.maxFills,
    pageDelayMs: opts.pageDelayMs ?? DEFAULT_FILLS_PAGE_DELAY_MS,
    signal: opts.signal,
    timeOf: (f) => f.time,
    keyOf: fillKey,
    fetchPage: (cursor) =>
      info(
        {
          type: 'userFillsByTime',
          user,
          startTime: cursor,
          ...(endTime !== undefined ? { endTime } : {}),
          aggregateByTime,
        },
        opts.signal ? { signal: opts.signal } : undefined,
      ),
  });
  return {
    fills: sortFills(res.items),
    pages: res.pages,
    complete: res.complete,
    aborted: res.aborted,
    denseMillis: res.denseMillis,
  };
}

export interface FetchRecentFillsOptions {
  aggregateByTime?: boolean;
  signal?: AbortSignal;
}

export interface RecentFillsResult {
  /** Sorted by time. */
  fills: HlFill[];
  /** The response hit the 2000 cap: the window is partial (it covers only the most recent 2000 fills). */
  capped: boolean;
}

/**
 * Reads the latest (at most 2000) fills through `userFills` - one heavy request.
 * Good for fresh trades; use `fetchFillsByTime` for a period or a backtest.
 * Never sends `dex` (ignored by HL for fills; a second request duplicates fills).
 * TWAP slices are not included.
 */
export async function fetchRecentFills(
  info: InfoRequester,
  user: string,
  opts: FetchRecentFillsOptions = {},
): Promise<RecentFillsResult> {
  const raw = await info(
    {
      type: 'userFills',
      user,
      ...(opts.aggregateByTime !== undefined ? { aggregateByTime: opts.aggregateByTime } : {}),
    },
    opts.signal ? { signal: opts.signal } : undefined,
  );
  if (!Array.isArray(raw)) throw new TypeError('history: userFills returned a non-array response');
  const fills = raw as HlFill[];
  return { fills: sortFills(dedupFills(fills)), capped: fills.length >= USER_FILLS_PAGE_LIMIT };
}

/** true for spot coins (`PURR/USDC`, `@107`). */
export function isSpotCoin(coin: string): boolean {
  return coin.includes('/') || coin.startsWith('@');
}

/** HIP-3 dex of a coin (`xyz` for `xyz:TSLA`), or `''` for the main perp dex and spot. */
export function coinDex(coin: string): string {
  const i = coin.indexOf(':');
  return i > 0 ? coin.slice(0, i) : '';
}
All files