164 lines
5.8 KiB
JavaScript
164 lines
5.8 KiB
JavaScript
|
|
// Download real BTCUSDT spot candles from the Binance REST API and write
|
||
|
|
// them as CSV datasets under `examples/data/`.
|
||
|
|
//
|
||
|
|
// The Node counterpart of `examples/rust/src/bin/fetch_btcusdt.rs` and
|
||
|
|
// `examples/python/fetch_btcusdt.py` — same pagination, same validity
|
||
|
|
// check, same output layout. Uses Node's built-in global `fetch` and
|
||
|
|
// `node:fs` only; zero npm dependencies beyond the bundled `wickra`
|
||
|
|
// loader (which this script does not even import — it just talks HTTP
|
||
|
|
// and writes CSV).
|
||
|
|
//
|
||
|
|
// Requires Node 18 or newer (`fetch` is built-in there).
|
||
|
|
//
|
||
|
|
// Run with:
|
||
|
|
// node fetch_btcusdt.js
|
||
|
|
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const path = require('node:path');
|
||
|
|
|
||
|
|
// Binance Spot REST endpoint for historical klines.
|
||
|
|
const KLINES_URL = 'https://api.binance.com/api/v3/klines';
|
||
|
|
const SYMBOL = 'BTCUSDT';
|
||
|
|
// Binance caps a single klines response at 1000 rows.
|
||
|
|
const PAGE_LIMIT = 1000;
|
||
|
|
const REQUEST_PAUSE_MS = 200;
|
||
|
|
|
||
|
|
/** @type {{interval: string, file: string, target: number}[]} */
|
||
|
|
const DATASETS = [
|
||
|
|
{ interval: '1m', file: 'btcusdt-1m.csv', target: 50_000 },
|
||
|
|
{ interval: '5m', file: 'btcusdt-5m.csv', target: 10_000 },
|
||
|
|
{ interval: '15m', file: 'btcusdt-15m.csv', target: 10_000 },
|
||
|
|
{ interval: '1h', file: 'btcusdt-1h.csv', target: 10_000 },
|
||
|
|
{ interval: '12h', file: 'btcusdt-12h.csv', target: 5_000 },
|
||
|
|
{ interval: '1d', file: 'btcusdt-1d.csv', target: 5_000 },
|
||
|
|
// The monthly file is `btcusdt-1month.csv`, not `btcusdt-1M.csv`, so it
|
||
|
|
// does not collide with `btcusdt-1m.csv` on case-insensitive filesystems.
|
||
|
|
{ interval: '1M', file: 'btcusdt-1month.csv', target: 5_000 },
|
||
|
|
];
|
||
|
|
|
||
|
|
// Parse one Binance kline array. Returns null for any row that is malformed
|
||
|
|
// or fails the same OHLC validity check `Candle::new` applies in the Rust
|
||
|
|
// core.
|
||
|
|
function parseKline(raw) {
|
||
|
|
if (!Array.isArray(raw) || raw.length < 7) return null;
|
||
|
|
const openTime = Number(raw[0]);
|
||
|
|
const closeTime = Number(raw[6]);
|
||
|
|
const o = Number(raw[1]);
|
||
|
|
const h = Number(raw[2]);
|
||
|
|
const l = Number(raw[3]);
|
||
|
|
const c = Number(raw[4]);
|
||
|
|
const v = Number(raw[5]);
|
||
|
|
if (![openTime, closeTime, o, h, l, c, v].every(Number.isFinite)) return null;
|
||
|
|
if (h < l || h < o || h < c || l > o || l > c || v < 0) return null;
|
||
|
|
return { openTime, closeTime, o, h, l, c, v };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchPage(interval, endTime) {
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
symbol: SYMBOL,
|
||
|
|
interval,
|
||
|
|
limit: String(PAGE_LIMIT),
|
||
|
|
});
|
||
|
|
if (endTime !== null) params.set('endTime', String(endTime));
|
||
|
|
const url = `${KLINES_URL}?${params}`;
|
||
|
|
|
||
|
|
const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||
|
|
if (!resp.ok) {
|
||
|
|
throw new Error(`Binance returned HTTP ${resp.status} for ${url}`);
|
||
|
|
}
|
||
|
|
const data = await resp.json();
|
||
|
|
if (!Array.isArray(data)) {
|
||
|
|
const sample = JSON.stringify(data).slice(0, 160);
|
||
|
|
throw new Error(`expected a JSON array from Binance, got: ${sample}`);
|
||
|
|
}
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function collect(interval, target, nowMs) {
|
||
|
|
// Keyed by open time so the map sorts chronologically and dedupes the
|
||
|
|
// small overlap that can occur between adjacent pages.
|
||
|
|
const byOpen = new Map();
|
||
|
|
let endTime = null;
|
||
|
|
let pages = 0;
|
||
|
|
|
||
|
|
while (true) {
|
||
|
|
const page = await fetchPage(interval, endTime);
|
||
|
|
pages += 1;
|
||
|
|
if (page.length === 0) break;
|
||
|
|
|
||
|
|
let oldest = Number.MAX_SAFE_INTEGER;
|
||
|
|
for (const raw of page) {
|
||
|
|
const k = parseKline(raw);
|
||
|
|
if (!k) continue;
|
||
|
|
if (k.openTime < oldest) oldest = k.openTime;
|
||
|
|
// Keep only fully closed candles — drop the in-progress bucket.
|
||
|
|
if (k.closeTime < nowMs) byOpen.set(k.openTime, k);
|
||
|
|
}
|
||
|
|
process.stderr.write(
|
||
|
|
`\r ${interval}: collected ${byOpen.size} candles over ${pages} page(s)…`,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (byOpen.size >= target || page.length < PAGE_LIMIT) break;
|
||
|
|
if (oldest === Number.MAX_SAFE_INTEGER) {
|
||
|
|
throw new Error(`Binance page for ${interval} held no parseable klines`);
|
||
|
|
}
|
||
|
|
endTime = oldest - 1;
|
||
|
|
await new Promise((r) => setTimeout(r, REQUEST_PAUSE_MS));
|
||
|
|
}
|
||
|
|
process.stderr.write('\n');
|
||
|
|
|
||
|
|
const sorted = [...byOpen.values()].sort((a, b) => a.openTime - b.openTime);
|
||
|
|
return sorted.length > target ? sorted.slice(sorted.length - target) : sorted;
|
||
|
|
}
|
||
|
|
|
||
|
|
// JavaScript's `String(v)` already produces the shortest round-trip
|
||
|
|
// representation for finite numbers, and (unlike Python's repr) it already
|
||
|
|
// strips the `.0` suffix for whole-number floats — `String(77552.0)` is
|
||
|
|
// `'77552'`. The CSVs the Rust, Python and Node fetchers write are
|
||
|
|
// byte-for-byte identical on the same Binance snapshot.
|
||
|
|
function fmtNumber(v) {
|
||
|
|
return String(v);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeCsv(filePath, candles) {
|
||
|
|
const out = ['timestamp,open,high,low,close,volume'];
|
||
|
|
for (const k of candles) {
|
||
|
|
out.push(
|
||
|
|
`${k.openTime},${fmtNumber(k.o)},${fmtNumber(k.h)},${fmtNumber(k.l)},${fmtNumber(k.c)},${fmtNumber(k.v)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
fs.writeFileSync(filePath, out.join('\n') + '\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
if (typeof fetch !== 'function') {
|
||
|
|
console.error(
|
||
|
|
'This example needs Node 18+ for the global fetch() function.',
|
||
|
|
);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const nowMs = Date.now();
|
||
|
|
const dataDir = path.join(__dirname, '..', 'data');
|
||
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
||
|
|
console.log(`Fetching ${SYMBOL} klines from Binance into ${dataDir}`);
|
||
|
|
|
||
|
|
for (const ds of DATASETS) {
|
||
|
|
const candles = await collect(ds.interval, ds.target, nowMs);
|
||
|
|
if (candles.length === 0) {
|
||
|
|
console.error(`error: Binance returned no closed candles for ${ds.interval}`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
writeCsv(path.join(dataDir, ds.file), candles);
|
||
|
|
console.log(
|
||
|
|
` ${ds.interval.padStart(3)} ${String(candles.length).padStart(6)} candles -> examples/data/${ds.file}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
console.log(`Done — ${DATASETS.length} datasets written.`);
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((err) => {
|
||
|
|
console.error(`error: ${err.message || err}`);
|
||
|
|
process.exit(1);
|
||
|
|
});
|