Skip to content
markpaper

src/history/candles.ts

v0.3.0 · 15.1 KB

Download file
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 };
}
All files