Skip to content
markpaper

src/history/twap.ts

v0.3.0 · 6.5 KB

Download file
import type { InfoRequester } from '../transport/types.js';
import { assertTimeMs, fillKey, sortFills } from './fills.js';
import type { FillLike, HlFill } from './types.js';

/** Cap of `userTwapSliceFills`: at most 2000 records, the MOST RECENT ones. */
export const TWAP_SLICE_FILLS_LIMIT = 2000;

/** A TWAP slice fill with the id of its TWAP. */
export interface TwapSliceFill extends HlFill {
  twapId: number | null;
}

/**
 * Normalises a `userTwapSliceFills` response. An element is either the
 * documented wrapper `{ fill, twapId }` or a bare fill; a parser that assumes
 * one shape crashes or loses slices, so both are accepted (`x.fill || x`).
 */
export function parseTwapSliceFills(raw: unknown): TwapSliceFill[] {
  if (!Array.isArray(raw)) throw new TypeError('history: userTwapSliceFills returned a non-array response');
  const out: TwapSliceFill[] = [];
  for (const x of raw as unknown[]) {
    if (x === null || typeof x !== 'object') continue;
    const wrapper = x as { fill?: unknown; twapId?: unknown };
    const inner = (wrapper.fill && typeof wrapper.fill === 'object' ? wrapper.fill : x) as HlFill & {
      twapId?: unknown;
    };
    if (typeof inner.time !== 'number' || typeof inner.coin !== 'string') continue;
    const idRaw = wrapper.fill ? wrapper.twapId ?? inner.twapId : inner.twapId;
    const twapId = typeof idRaw === 'number' ? idRaw : null;
    out.push({ ...inner, twapId });
  }
  return out;
}

export interface TwapSliceFillsResult {
  /** Slices sorted by time (oldest first). */
  fills: TwapSliceFill[];
  /**
   * The feed returned its 2000 cap. Only the most recent 2000 slices are
   * visible, so any total computed from them is likely understated.
   */
  capped: boolean;
  /** Time of the oldest returned slice, or null. */
  firstTime: number | null;
  /**
   * Time of the newest slice, or null. Judge "TWAP active now" by THIS, not by
   * the count: 2000 slices can span a few hours or many months.
   */
  lastTime: number | null;
}

export interface FetchTwapSliceFillsOptions {
  /**
   * Sent to the API AND applied on the client, because whether the server
   * honours it is not verified.
   */
  startTime?: number;
  signal?: AbortSignal;
}

function toResult(fills: TwapSliceFill[], rawCount: number): TwapSliceFillsResult {
  const sorted = sortFills(fills);
  return {
    fills: sorted,
    capped: rawCount >= TWAP_SLICE_FILLS_LIMIT,
    firstTime: sorted.length > 0 ? (sorted[0] as TwapSliceFill).time : null,
    lastTime: sorted.length > 0 ? (sorted[sorted.length - 1] as TwapSliceFill).time : null,
  };
}

/**
 * Reads TWAP slice fills of a user (`userTwapSliceFills`).
 *
 * Why this exists: TWAP executions never appear in `userFills` /
 * `userFillsByTime` (with either `aggregateByTime`). A position closed through
 * a losing TWAP can look profitable when judged by `userFills` alone. Always
 * compute PnL and turnover on BOTH feeds - see
 * `mergeFillsWithTwap`.
 *
 * The feed returns the most recent 2000 slices, unlike `userFillsByTime` which
 * returns the oldest 2000 from `startTime`; over a long window the two feeds
 * cover different periods.
 */
export async function fetchTwapSliceFills(
  info: InfoRequester,
  user: string,
  opts: FetchTwapSliceFillsOptions = {},
): Promise<TwapSliceFillsResult> {
  if (opts.startTime !== undefined) assertTimeMs('startTime', opts.startTime);
  const raw = await info(
    { type: 'userTwapSliceFills', user, ...(opts.startTime !== undefined ? { startTime: opts.startTime } : {}) },
    opts.signal ? { signal: opts.signal } : undefined,
  );
  const parsed = parseTwapSliceFills(raw);
  const rawCount = (raw as unknown[]).length;
  const start = opts.startTime;
  const filtered = start === undefined ? parsed : parsed.filter((f) => f.time >= start);
  return toResult(filtered, rawCount);
}

export interface FetchTwapSliceFillsByTimeOptions {
  startTime: number;
  endTime?: number;
  signal?: AbortSignal;
}

/**
 * Reads TWAP slice fills for a window through `userTwapSliceFillsByTime`.
 *
 * @experimental Described only by public HL docs, not verified live:
 * the cap, which end of the window is returned when capped, and whether the
 * time filter is honoured are unknown. The window is therefore also applied on
 * the client, and no pagination is attempted; check `capped`.
 */
export async function fetchTwapSliceFillsByTime(
  info: InfoRequester,
  user: string,
  opts: FetchTwapSliceFillsByTimeOptions,
): Promise<TwapSliceFillsResult> {
  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 endTime = opts.endTime;
  const raw = await info(
    {
      type: 'userTwapSliceFillsByTime',
      user,
      startTime: opts.startTime,
      ...(endTime !== undefined ? { endTime } : {}),
    },
    opts.signal ? { signal: opts.signal } : undefined,
  );
  const parsed = parseTwapSliceFills(raw);
  const filtered = parsed.filter((f) => f.time >= opts.startTime && (endTime === undefined || f.time <= endTime));
  return toResult(filtered, (raw as unknown[]).length);
}

/** A fill tagged with the feed it came from. */
export type MergedFill<F extends FillLike = HlFill> = F & {
  source: 'fills' | 'twap';
  twapId?: number | null;
};

export interface MergeFillsWithTwapOptions {
  /** Keep only fills with `time >= from`. */
  from?: number;
  /** Keep only fills with `time <= to`. */
  to?: number;
}

/**
 * Union of regular fills and TWAP slice fills: deduplicated by `tid`, sorted by
 * time, each tagged with `source`.
 *
 * Pass `from`/`to` so both feeds are cut to the same period: when capped,
 * `userFillsByTime` holds the OLDEST 2000 of a window and `userTwapSliceFills`
 * the NEWEST 2000, so an uncut merge mixes different periods.
 */
export function mergeFillsWithTwap<F extends FillLike>(
  fills: readonly F[],
  twapSlices: readonly FillLike[],
  opts: MergeFillsWithTwapOptions = {},
): MergedFill<F>[] {
  const inWindow = (t: number): boolean =>
    (opts.from === undefined || t >= opts.from) && (opts.to === undefined || t <= opts.to);
  const seen = new Set<string>();
  const out: MergedFill<F>[] = [];
  for (const f of fills) {
    if (!inWindow(f.time)) continue;
    const k = fillKey(f);
    if (seen.has(k)) continue;
    seen.add(k);
    out.push({ ...f, source: 'fills' });
  }
  for (const f of twapSlices) {
    if (!inWindow(f.time)) continue;
    const k = fillKey(f);
    if (seen.has(k)) continue;
    seen.add(k);
    out.push({ ...(f as F), source: 'twap', twapId: f.twapId ?? null });
  }
  return sortFills(out);
}
All files