src/history/twap.ts
v0.3.0 · 6.5 KB
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);
}