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