Skip to content
markpaper

src/history/candles.test.ts

v0.3.0 · 12.7 KB

Download file
import { describe, expect, it } from 'vitest';
import type { InfoRequest, InfoRequester } from '../transport/types.js';
import {
  CANDLE_INTERVALS,
  VERIFIED_CANDLE_INTERVALS,
  candleCloseTime,
  candleIntervalMs,
  estimateCandleCount,
  fetchCandles,
  fetchServerTimeMs,
  findCandleGaps,
  isCandleClosed,
  isCandleInterval,
  markCandleClosure,
  nextCandleOpen,
  normalizeCandles,
  sliceCandlesByTime,
  toCandleCoin,
  toOhlcv,
} from './candles.js';
import type { HlCandle } from './types.js';

const H = 3_600_000;
const M5 = 300_000;
const T0 = Date.UTC(2026, 0, 5); // a Monday

function bar(t: number, ms: number, i = '1h', s = 'BTC'): HlCandle {
  return { t, T: t + ms - 1, s, i, o: '100', h: '110', l: '90', c: '105', v: '1.5', n: 3 };
}

/**
 * Simulates candleSnapshot: bars within [startTime, endTime], at most `cap`,
 * and when capped the MOST RECENT ones. `depthFrom` hides older history.
 */
function candleServer(bars: HlCandle[], opts: { cap?: number; depthFrom?: number } = {}) {
  const calls: InfoRequest[] = [];
  const cap = opts.cap ?? 5000;
  const info = (async (body: InfoRequest) => {
    calls.push(body);
    const req = body.req as { startTime: number; endTime?: number };
    const inWindow = bars.filter(
      (b) => b.t >= req.startTime && (req.endTime === undefined || b.t <= req.endTime) && b.t >= (opts.depthFrom ?? 0),
    );
    return inWindow.slice(Math.max(0, inWindow.length - cap));
  }) as InfoRequester;
  return { info, calls };
}

const series = (n: number, ms: number, from = T0, i = '1h') => Array.from({ length: n }, (_, k) => bar(from + k * ms, ms, i));

describe('intervals', () => {
  it('lists SDK intervals, marks verified ones and gives fixed lengths', () => {
    expect(CANDLE_INTERVALS).toContain('1M');
    expect(VERIFIED_CANDLE_INTERVALS).toEqual(['1m', '5m', '15m', '1h', '4h', '1d']);
    expect(candleIntervalMs('5m')).toBe(300_000);
    expect(candleIntervalMs('1w')).toBe(7 * 86_400_000);
    // HL's 1M is a fixed, epoch-aligned 30-day bucket (live: bars at 2026-07-06, 08-05, 09-04).
    expect(candleIntervalMs('1M')).toBe(30 * 86_400_000);
    expect(isCandleInterval('1s')).toBe(false);
    expect(nextCandleOpen(Date.UTC(2026, 6, 6), '1M')).toBe(Date.UTC(2026, 7, 5));
    expect(estimateCandleCount('5m', 0, 90 * 86_400_000)).toBeGreaterThan(5000);
    expect(estimateCandleCount('1h', 0, 180 * 86_400_000)).toBeLessThan(5000);
    expect(estimateCandleCount('1h', 10, 5)).toBe(0);
  });
});

describe('toCandleCoin', () => {
  it('builds the HIP-3 prefix idempotently and rejects a foreign prefix', () => {
    expect(toCandleCoin('SP500', 'xyz')).toBe('xyz:SP500');
    expect(toCandleCoin('xyz:SP500', 'xyz')).toBe('xyz:SP500');
    expect(toCandleCoin('BTC')).toBe('BTC');
    expect(() => toCandleCoin('flx:ABC', 'xyz')).toThrow(RangeError);
  });
});

describe('fetchCandles', () => {
  it('sends a prefixed HIP-3 coin and never a dex field', async () => {
    const { info, calls } = candleServer(series(3, H));
    await fetchCandles(info, { coin: 'SP500', dex: 'xyz', interval: '1h', startTime: T0, endTime: T0 + 3 * H, pageDelayMs: 0 });
    expect(calls[0]).toEqual({
      type: 'candleSnapshot',
      req: { coin: 'xyz:SP500', interval: '1h', startTime: T0, endTime: T0 + 3 * H },
    });
    expect('dex' in calls[0]!).toBe(false);
    expect('dex' in (calls[0]!.req as object)).toBe(false);
  });

  it('pages backward when the server returns only the freshest 5000 bars', async () => {
    const all = series(7200, H); // 300 days of 1h
    const { info, calls } = candleServer(all);
    const res = await fetchCandles(info, {
      coin: 'BTC',
      interval: '1h',
      startTime: T0,
      endTime: T0 + 7200 * H,
      serverTimeMs: T0 + 8000 * H,
      maxPages: 5,
      pageDelayMs: 0,
    });
    expect(res.pages).toBe(2);
    expect(res.candles).toHaveLength(7200);
    expect(res.complete).toBe(true);
    expect(res.historyLimited).toBe(false);
    expect((calls[1]!.req as { endTime: number }).endTime).toBe(T0 + 2200 * H - 1);
    const ts = res.candles.map((c) => c.t);
    expect(new Set(ts).size).toBe(ts.length);
    expect(ts).toEqual([...ts].sort((a, b) => a - b));
  });

  it('reports limited history depth instead of pretending to cover the window (5m over 90 days)', async () => {
    const total = 90 * 288;
    const all = series(total, M5, T0, '5m');
    const depthFrom = all[total - 5029]!.t;
    const { info } = candleServer(all, { cap: 5029, depthFrom });
    const res = await fetchCandles(info, {
      coin: 'BTC',
      interval: '5m',
      startTime: T0,
      endTime: T0 + total * M5,
      includeUnclosed: true,
      pageDelayMs: 0,
    });
    // Default is ONE request: the older part is not kept by HL, a second request only costs weight.
    expect(res.pages).toBe(1);
    expect(res.historyLimited).toBe(true);
    expect(res.complete).toBe(false);
    expect(res.coveredFrom).toBe(depthFrom);
    expect(res.candles).toHaveLength(5029);

    const probed = await fetchCandles(info, {
      coin: 'BTC',
      interval: '5m',
      startTime: T0,
      endTime: T0 + total * M5,
      includeUnclosed: true,
      maxPages: 3,
      pageDelayMs: 0,
    });
    expect(probed.pages).toBe(2); // second page is empty
    expect(probed.historyLimited).toBe(true);
    expect(probed.candles).toHaveLength(5029);
  });

  it('single-request mode is the default', async () => {
    const all = series(7200, H);
    const { info, calls } = candleServer(all);
    const res = await fetchCandles(info, { coin: 'BTC', interval: '1h', startTime: T0, includeUnclosed: true });
    expect(calls).toHaveLength(1);
    expect(res.complete).toBe(false);
    expect(res.historyLimited).toBe(true);
    expect(res.candles).toHaveLength(5000);
    expect('endTime' in (calls[0]!.req as object)).toBe(false);
  });

  it('keeps the bar that contains a misaligned startTime, like the raw API', async () => {
    // Live: startTime = hour + 1234 ms returned the bar opened at that hour.
    const bars = series(3, H);
    const info = (async () => bars) as InfoRequester;
    const res = await fetchCandles(info, {
      coin: 'BTC',
      interval: '1h',
      startTime: T0 + 1234,
      serverTimeMs: T0 + 10 * H,
    });
    expect(res.candles.map((c) => c.t)).toEqual([T0, T0 + H, T0 + 2 * H]);
    expect(res.complete).toBe(true);
    // A bar that closed exactly at startTime is outside the window.
    const res2 = await fetchCandles(info, { coin: 'BTC', interval: '1h', startTime: T0 + H, serverTimeMs: T0 + 10 * H });
    expect(res2.candles.map((c) => c.t)).toEqual([T0 + H, T0 + 2 * H]);
  });

  it('drops bars with empty or missing prices (Number("") is 0)', async () => {
    const info = (async () => [
      { ...bar(T0, H), h: '' },
      { ...bar(T0 + H, H), l: null },
      { ...bar(T0 + 2 * H, H), c: '-1' },
      bar(T0 + 3 * H, H),
    ]) as InfoRequester;
    const res = await fetchCandles(info, { coin: 'BTC', interval: '1h', startTime: T0, includeUnclosed: true });
    expect(res.candles.map((c) => c.t)).toEqual([T0 + 3 * H]);
  });

  it('a small window is one request; an empty window is complete with no data', async () => {
    const { info, calls } = candleServer(series(24, H));
    const res = await fetchCandles(info, { coin: 'BTC', interval: '1h', startTime: T0 + 30 * H, pageDelayMs: 0 });
    expect(calls).toHaveLength(1);
    expect(res.candles).toEqual([]);
    expect(res.complete).toBe(true);
    expect(res.coveredFrom).toBeNull();
  });

  it('drops the newest bar when its closure is unknown or it is still open at server time', async () => {
    const bars = series(3, H);
    const { info } = candleServer(bars);
    const base = { coin: 'BTC', interval: '1h' as const, startTime: T0, pageDelayMs: 0 };

    const unknown = await fetchCandles(info, base);
    expect(unknown.candles).toHaveLength(2);
    expect(unknown.droppedUnclosed).toBe(true);
    expect(unknown.candles.every((c) => c.closed === true)).toBe(true);

    const open = await fetchCandles(info, { ...base, serverTimeMs: T0 + 2 * H + 10 });
    expect(open.candles).toHaveLength(2);

    const closed = await fetchCandles(info, { ...base, serverTimeMs: T0 + 3 * H });
    expect(closed.candles).toHaveLength(3);
    expect(closed.droppedUnclosed).toBe(false);

    const kept = await fetchCandles(info, { ...base, includeUnclosed: true });
    expect(kept.candles[2]!.closed).toBeNull();
  });

  it('drops malformed bars and dedups by t', async () => {
    const good = bar(T0, H);
    const info = (async () => [
      good,
      { ...good },
      { ...bar(T0 + H, H), c: '0' },
      { ...bar(T0 + 2 * H, H), h: 'NaN' },
      bar(T0 + 3 * H, H),
    ]) as InfoRequester;
    const res = await fetchCandles(info, { coin: 'BTC', interval: '1h', startTime: T0, includeUnclosed: true });
    expect(res.candles.map((c) => c.t)).toEqual([T0, T0 + 3 * H]);
  });

  it('propagates errors and rejects bad input', async () => {
    const failing = (async () => {
      throw new Error('HTTP 500');
    }) as InfoRequester;
    await expect(fetchCandles(failing, { coin: 'BTC', interval: '1h', startTime: T0 })).rejects.toThrow('HTTP 500');
    const bad = (async () => ({})) as InfoRequester;
    await expect(fetchCandles(bad, { coin: 'BTC', interval: '1h', startTime: T0 })).rejects.toThrow(/non-array/);
    await expect(
      fetchCandles(bad, { coin: 'BTC', interval: '1s' as unknown as '1m', startTime: T0 }),
    ).rejects.toThrow(/interval/);
    await expect(fetchCandles(bad, { coin: 'BTC', interval: '1h', startTime: T0, endTime: T0 - 1 })).rejects.toThrow(
      RangeError,
    );
  });

  it('stops when the server ignores the narrower window', async () => {
    const all = series(5000, H, T0 + 1000 * H);
    const info = (async () => all) as InfoRequester;
    const res = await fetchCandles(info, {
      coin: 'BTC',
      interval: '1h',
      startTime: T0,
      pageDelayMs: 0,
      includeUnclosed: true,
      maxPages: 5,
    });
    expect(res.pages).toBe(2);
    expect(res.complete).toBe(false);
  });
});

describe('closure helpers', () => {
  it('isCandleClosed compares with t + interval and T', () => {
    const b = bar(T0, H);
    expect(isCandleClosed(b, '1h', T0 + H - 1)).toBe(false);
    expect(isCandleClosed(b, '1h', T0 + H)).toBe(true);
    expect(candleCloseTime(b, '1h')).toBe(T0 + H);
  });

  it('markCandleClosure: bars with a newer bar are closed regardless of server time', () => {
    const m = markCandleClosure(series(3, H), '1h', T0);
    expect(m.map((c) => c.closed)).toEqual([true, true, false]);
    expect(markCandleClosure([], '1h')).toEqual([]);
  });

  it('fetchServerTimeMs reads l2Book.time', async () => {
    const calls: InfoRequest[] = [];
    const info = (async (b: InfoRequest) => {
      calls.push(b);
      return { coin: 'BTC', time: 1_757_000_000_000, levels: [[], []] };
    }) as InfoRequester;
    expect(await fetchServerTimeMs(info)).toBe(1_757_000_000_000);
    expect(calls[0]).toEqual({ type: 'l2Book', coin: 'BTC' });
    const bad = (async () => null) as InfoRequester;
    await expect(fetchServerTimeMs(bad)).rejects.toThrow(/time/);
  });
});

describe('series helpers', () => {
  it('finds weekend gaps of a stock series', () => {
    // Mon..Fri daily bars, then next Monday
    const D = 86_400_000;
    const days = [0, 1, 2, 3, 4, 7].map((k) => bar(T0 + k * D, D, '1d'));
    expect(findCandleGaps(days, '1d')).toEqual([{ afterT: T0 + 4 * D, beforeT: T0 + 7 * D, missingBars: 2 }]);
    expect(findCandleGaps(series(5, H), '1h')).toEqual([]);
    // Real consecutive 1M bars (30-day buckets) have NO gap; a calendar-month model reported one.
    const hlMonths = [Date.UTC(2026, 6, 6), Date.UTC(2026, 7, 5), Date.UTC(2026, 8, 4)].map((t) => ({ t }));
    expect(findCandleGaps(hlMonths, '1M')).toEqual([]);
    const withHole = [hlMonths[0]!, { t: Date.UTC(2026, 8, 4) }];
    expect(findCandleGaps(withHole, '1M')).toEqual([{ afterT: hlMonths[0]!.t, beforeT: Date.UTC(2026, 8, 4), missingBars: 1 }]);
    // A 1M bar opened 2026-09-04 is final only on 2026-10-04, not on 2026-10-01.
    const sep = { t: Date.UTC(2026, 8, 4) };
    expect(sliceCandlesByTime([sep], '1M', { to: Date.UTC(2026, 9, 1) })).toEqual([]);
    expect(sliceCandlesByTime([sep], '1M', { to: Date.UTC(2026, 9, 4) })).toEqual([sep]);
  });

  it('slices by close time to avoid look-ahead', () => {
    const bars = series(4, H);
    // decision at T0 + 2.5h: the bar opened at T0 + 2h is not closed yet
    const visible = sliceCandlesByTime(bars, '1h', { to: T0 + 2.5 * H });
    expect(visible.map((c) => c.t)).toEqual([T0, T0 + H]);
    expect(sliceCandlesByTime(bars, '1h', { from: T0 + H }).map((c) => c.t)).toEqual([T0 + H, T0 + 2 * H, T0 + 3 * H]);
  });

  it('normalizeCandles and toOhlcv', () => {
    expect(normalizeCandles([null, 5, { t: 'x' }])).toEqual([]);
    expect(toOhlcv(bar(T0, H))).toEqual({ t: T0, T: T0 + H - 1, o: 100, h: 110, l: 90, c: 105, v: 1.5, n: 3 });
  });
});
All files