src/history/fills.ts
v0.3.0 · 8.1 KB
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) : '';
}