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