src/history/candles.ts
v0.3.0 · 15.1 KB
import type { InfoRequester } from '../transport/types.js';
import { sleep } from './paginate.js';
import type { HlCandle } from './types.js';
/**
* Intervals accepted by `candleSnapshot` (per the SDK schema). Only
* {@link VERIFIED_CANDLE_INTERVALS} are verified live; there are no
* sub-minute candles.
*/
export const CANDLE_INTERVALS = [
'1m',
'3m',
'5m',
'15m',
'30m',
'1h',
'2h',
'4h',
'8h',
'12h',
'1d',
'3d',
'1w',
'1M',
] as const;
export type CandleInterval = (typeof CANDLE_INTERVALS)[number];
/** Intervals verified against live HL. */
export const VERIFIED_CANDLE_INTERVALS: readonly CandleInterval[] = ['1m', '5m', '15m', '1h', '4h', '1d'];
/** Approximate cap of one `candleSnapshot` response (~5000; a 5m/90d request returned 5029). */
export const CANDLE_RESPONSE_LIMIT = 5000;
const MINUTE = 60_000;
const INTERVAL_MS: Record<CandleInterval, number> = {
'1m': MINUTE,
'3m': 3 * MINUTE,
'5m': 5 * MINUTE,
'15m': 15 * MINUTE,
'30m': 30 * MINUTE,
'1h': 60 * MINUTE,
'2h': 120 * MINUTE,
'4h': 240 * MINUTE,
'8h': 480 * MINUTE,
'12h': 720 * MINUTE,
'1d': 1440 * MINUTE,
'3d': 3 * 1440 * MINUTE,
'1w': 7 * 1440 * MINUTE,
// Not a calendar month: HL's `1M` bar is a fixed 30-day bucket aligned to the
// unix epoch (live check 2026-09-14: bars open 2026-07-06, 08-05, 09-04 and
// `T - t + 1` is exactly 30 days; `1w` and `3d` are epoch-aligned too). A
// calendar-month model reports false gaps and treats a bar as closed days
// before its real close (look-ahead).
'1M': 30 * 1440 * MINUTE,
};
export function isCandleInterval(x: unknown): x is CandleInterval {
return typeof x === 'string' && (CANDLE_INTERVALS as readonly string[]).includes(x);
}
/**
* Interval length in ms. Every HL interval has a fixed length, including `1M`
* (30 days, epoch-aligned - not a calendar month).
*/
export function candleIntervalMs(interval: CandleInterval): number {
return INTERVAL_MS[interval];
}
function assertInterval(interval: string): asserts interval is CandleInterval {
if (!isCandleInterval(interval)) throw new RangeError(`history: unknown candle interval: ${interval}`);
}
/** Open time of the bar following the bar opened at `t`. */
export function nextCandleOpen(t: number, interval: CandleInterval): number {
return t + candleIntervalMs(interval);
}
/** Time at which the bar is final: `t + interval` (the server sends `T = t + interval - 1`). */
export function candleCloseTime(c: Pick<HlCandle, 't'>, interval: CandleInterval): number {
return nextCandleOpen(c.t, interval);
}
/**
* Expected number of bars in a window (upper bound; markets with sessions have
* fewer). Use it to predict whether a request fits one ~5000-bar response.
*/
export function estimateCandleCount(interval: CandleInterval, startTime: number, endTime: number): number {
if (endTime < startTime) return 0;
return Math.floor((endTime - startTime) / candleIntervalMs(interval)) + 1;
}
/**
* Coin name for `candleSnapshot`. HIP-3 markets must be requested as
* `xyz:SP500` WITHOUT a `dex` field: bare `SP500` plus `dex:'xyz'` returns
* HTTP 500 (verified 2026-07-29), and a loader that swallowed that error made
* every xyz coin silently look like NO_DATA. Idempotent: never produces `xyz:xyz:`.
*/
export function toCandleCoin(coin: string, dex?: string): string {
if (!dex) return coin;
const prefix = `${dex}:`;
if (coin.startsWith(prefix)) return coin;
if (coin.includes(':')) throw new RangeError(`history: coin ${coin} does not belong to dex ${dex}`);
return prefix + coin;
}
/** `Number('')` and `Number(null)` are 0; a missing price must stay invalid. */
function strictNum(v: unknown): number {
if (typeof v === 'number') return v;
if (typeof v !== 'string' || v.trim() === '') return Number.NaN;
return Number(v);
}
/**
* Keeps well-formed candles: numeric `t`, finite `h`/`l`, `c > 0`. Dedups by `t`
* (first occurrence wins) and sorts by `t`.
*/
export function normalizeCandles(raw: readonly unknown[]): HlCandle[] {
const byT = new Map<number, HlCandle>();
for (const x of raw) {
if (x === null || typeof x !== 'object') continue;
const c = x as HlCandle;
if (typeof c.t !== 'number' || !Number.isFinite(c.t)) continue;
const h = strictNum(c.h);
const l = strictNum(c.l);
const close = strictNum(c.c);
if (!Number.isFinite(h) || !Number.isFinite(l) || !Number.isFinite(close) || close <= 0) continue;
if (!byT.has(c.t)) byT.set(c.t, c);
}
return [...byT.values()].sort((a, b) => a.t - b.t);
}
/** true when the bar is final at server time `serverNowMs`. */
export function isCandleClosed(c: Pick<HlCandle, 't' | 'T'>, interval: CandleInterval, serverNowMs: number): boolean {
return serverNowMs >= candleCloseTime(c, interval) && serverNowMs > c.T;
}
/** A candle with its closure status. */
export type CandleWithStatus = HlCandle & {
/**
* `true` - final; `false` - still forming at server time; `null` - the newest
* bar and no server time was given, so closure is unknown.
*/
closed: boolean | null;
};
/**
* Marks closure of a sorted candle series. Every bar except the newest is
* closed (a newer bar already exists - evidence from the server, not the local
* clock). The newest bar is judged by `serverNowMs`; without it the status is `null`.
*
* Why: the last bar of a snapshot is the current interval "so far"; deciding
* on it means using a price that did not exist at decision time.
*/
export function markCandleClosure(
candles: readonly HlCandle[],
interval: CandleInterval,
serverNowMs?: number,
): CandleWithStatus[] {
const last = candles.length - 1;
return candles.map((c, i) => ({
...c,
closed: i < last ? true : serverNowMs === undefined ? null : isCandleClosed(c, interval, serverNowMs),
}));
}
/**
* Server time estimate from `l2Book.time` (weight 2), for closure checks that
* must not depend on the local clock.
*
* Why: the local clock can be off. Live check 2026-09-14: `l2Book.time` matched
* the HTTP `Date` header within ~50 ms while the local clock ran ~35 s ahead -
* enough to treat a still-forming 1m/5m bar as closed. `l2Book.time` is the
* book snapshot timestamp; if it ever lags, a just-closed bar is treated as
* still open (the safe direction).
*/
export async function fetchServerTimeMs(
info: InfoRequester,
coin = 'BTC',
opts: { signal?: AbortSignal } = {},
): Promise<number> {
const raw = await info<{ time?: unknown } | null>(
{ type: 'l2Book', coin },
opts.signal ? { signal: opts.signal } : undefined,
);
const t = raw && typeof raw === 'object' ? raw.time : undefined;
if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0) {
throw new TypeError('history: l2Book response has no numeric time');
}
return t;
}
export interface FetchCandlesParams {
/** `BTC`, or a HIP-3 name `xyz:SP500` (or bare name plus `dex`). */
coin: string;
/** Only used to build the `xyz:` prefix; never sent to the API. */
dex?: string;
interval: CandleInterval;
startTime: number;
/** Omit for "until now". */
endTime?: number;
/**
* Server time used to decide whether the newest bar is closed. Without it
* the newest bar has unknown status and is dropped unless `includeUnclosed`.
* Prefer a server-derived value (e.g. `fetchServerTimeMs`) over `Date.now()`.
*/
serverTimeMs?: number;
/** Keep a newest bar that is open or of unknown status. Default false. */
includeUnclosed?: boolean;
/**
* Maximum requests. Default 1.
*
* Why one: HL keeps only the ~5000 most recent bars of EVERY interval, and a
* wider window returns exactly those. Live check 2026-09-14: 1m (~3.6 days),
* 5m (~17.5 days), 1h (~208 days) and 4h (~2.3 years) all returned ~5000 bars
* and a follow-up request for the older part of the window returned nothing.
* A backward pass therefore only adds heavy requests that return nothing.
* Raise it only to probe a market or interval whose depth you have not
* checked.
*/
maxPages?: number;
/**
* A response with at least this many bars is treated as cut by the ~5000 cap
* (so the window is not fully covered). Default 4000 (the exact cap is fuzzy:
* 5004-5220 bars were observed).
*/
fullPageThreshold?: number;
/** Pause between pages. Default 80 ms. */
pageDelayMs?: number;
signal?: AbortSignal;
}
export interface FetchCandlesResult {
candles: CandleWithStatus[];
pages: number;
/**
* The whole window is covered: the oldest bar reaches `startTime`, or the
* last response was clearly not truncated.
*/
complete: boolean;
/**
* The window starts before the history the server keeps: the response was
* cut by the cap (or an older page came back empty). A "90-day" 5m backtest
* really covers ~18 days - report `coveredFrom`, do not pretend.
*/
historyLimited: boolean;
/** Open time of the oldest returned bar, or null. Print the real range in reports. */
coveredFrom: number | null;
/** Open time of the newest returned bar, or null. */
coveredTo: number | null;
/** The newest bar was dropped as open / unknown. */
droppedUnclosed: boolean;
}
/**
* Loads candles for a window through `candleSnapshot`.
*
* Rules encoded here:
* - A response holds ~5000 bars, and for a wider window HL returns the MOST
* RECENT ones; older bars of that interval are not kept at all. One request
* per coin covers everything available (default `maxPages: 1`). Paging
* forward from the old edge with a `break` on the first empty chunk returns
* ZERO bars on 5m/15m, because the old edge is empty. With
* `maxPages > 1` the loader pages BACKWARD (`[startTime, oldest.t - 1]`).
* - The server includes the bar that CONTAINS `startTime` (its `t` may be
* earlier); such a bar is kept, like the raw API does.
* - Dedup by `t`, sorted ascending.
* - The newest bar is not closed until the interval ends: it is flagged via
* server evidence / `serverTimeMs` and dropped by default.
* - HIP-3: `coin: 'xyz:SP500'` and never a `dex` field (HTTP 500).
* - Errors propagate; an empty response for a HIP-3 coin means NO_DATA - do not
* substitute zeros.
* - Bars are keyed by OPEN time; weekend gaps are normal for tokenized stocks,
* so slice windows by time (`sliceCandlesByTime`), not by bar count.
*
* Weight: 20 per request plus extra per ~60 returned bars (per docs).
*/
export async function fetchCandles(info: InfoRequester, params: FetchCandlesParams): Promise<FetchCandlesResult> {
assertInterval(params.interval);
const { interval, startTime } = params;
if (!Number.isSafeInteger(startTime) || startTime < 0) {
throw new RangeError('history: startTime must be a non-negative integer ms');
}
if (params.endTime !== undefined && (!Number.isSafeInteger(params.endTime) || params.endTime < startTime)) {
throw new RangeError('history: endTime must be an integer ms not before startTime');
}
const coin = toCandleCoin(params.coin, params.dex);
const maxPages = Math.max(1, params.maxPages ?? 1);
const threshold = params.fullPageThreshold ?? 4000;
const byT = new Map<number, HlCandle>();
let cursorEnd = params.endTime;
let pages = 0;
let complete = false;
let historyLimited = false;
for (;;) {
if (pages >= maxPages) break;
params.signal?.throwIfAborted();
if (pages > 0) await sleep(params.pageDelayMs ?? 80, params.signal);
const raw = await info(
{
type: 'candleSnapshot',
req: { coin, interval, startTime, ...(cursorEnd !== undefined ? { endTime: cursorEnd } : {}) },
},
params.signal ? { signal: params.signal } : undefined,
);
pages += 1;
if (!Array.isArray(raw)) throw new TypeError('history: candleSnapshot returned a non-array response');
const page = normalizeCandles(raw);
if (page.length === 0) {
if (pages === 1) complete = true; // no data at all in the window
else historyLimited = true; // the server has nothing older
break;
}
let fresh = 0;
for (const c of page) {
if (candleCloseTime(c, interval) <= startTime || (params.endTime !== undefined && c.t > params.endTime)) continue;
if (byT.has(c.t)) continue;
byT.set(c.t, c);
fresh += 1;
}
const oldest = (page[0] as HlCandle).t;
if (oldest <= startTime || raw.length < threshold) {
complete = true;
break;
}
// Cut by the cap before reaching startTime. If no further page is allowed,
// the missing part is below the server's history depth (see maxPages).
historyLimited = true;
if (fresh === 0) break; // the server ignored the narrower window
cursorEnd = oldest - 1;
}
if (complete) historyLimited = false;
const sorted = [...byT.values()].sort((a, b) => a.t - b.t);
let marked = markCandleClosure(sorted, interval, params.serverTimeMs);
let droppedUnclosed = false;
const newest = marked[marked.length - 1];
if (!params.includeUnclosed && newest && newest.closed !== true) {
marked = marked.slice(0, -1);
droppedUnclosed = true;
}
return {
candles: marked,
pages,
complete,
historyLimited,
coveredFrom: sorted[0]?.t ?? null,
coveredTo: sorted[sorted.length - 1]?.t ?? null,
droppedUnclosed,
};
}
/** A hole in a candle series. */
export interface CandleGap {
/** Open time of the last bar before the gap. */
afterT: number;
/** Open time of the first bar after the gap. */
beforeT: number;
/** Number of missing bars. */
missingBars: number;
}
/**
* Finds holes in a sorted series: weekends and holidays of tokenized stocks on
* HIP-3 dexes, reconnect gaps of self-recorded data. A backtest over a gap must
* report no_data, not interpolate.
*/
export function findCandleGaps(candles: readonly Pick<HlCandle, 't'>[], interval: CandleInterval): CandleGap[] {
const gaps: CandleGap[] = [];
for (let i = 1; i < candles.length; i += 1) {
const prev = (candles[i - 1] as Pick<HlCandle, 't'>).t;
const cur = (candles[i] as Pick<HlCandle, 't'>).t;
const expected = nextCandleOpen(prev, interval);
if (cur <= expected) continue;
const missing = Math.floor((cur - prev) / candleIntervalMs(interval)) - 1;
if (missing > 0) gaps.push({ afterT: prev, beforeT: cur, missingBars: missing });
}
return gaps;
}
/**
* Bars fully inside `[from, to]`: `t >= from` and close time `t + interval <= to`.
*
* Why close time: a bar is labelled by its OPEN time, but its `c` is the price
* at `t + interval`. Filtering by `t <= decisionTime` leaks up to one interval
* of the future (visible on 1h/4h). Also slice by time, not by "last N bars":
* weekend holes in stock series would shift the window.
*/
export function sliceCandlesByTime<C extends Pick<HlCandle, 't'>>(
candles: readonly C[],
interval: CandleInterval,
window: { from?: number; to?: number },
): C[] {
return candles.filter(
(c) =>
(window.from === undefined || c.t >= window.from) &&
(window.to === undefined || candleCloseTime(c, interval) <= window.to),
);
}
/** Numeric OHLCV of a candle (for simulators; strings stay the source of truth). */
export function toOhlcv(c: HlCandle): { t: number; T: number; o: number; h: number; l: number; c: number; v: number; n: number } {
return { t: c.t, T: c.T, o: Number(c.o), h: Number(c.h), l: Number(c.l), c: Number(c.c), v: Number(c.v), n: c.n };
}