Files
wickra/examples/python/fetch_btcusdt.py
T

210 lines
6.6 KiB
Python
Raw Normal View History

"""Download real BTCUSDT spot candles from the Binance REST API and write
them as CSV datasets under ``examples/data/``.
The Python counterpart of ``examples/rust/src/bin/fetch_btcusdt.rs`` and
``examples/node/fetch_btcusdt.js`` — same pagination, same validity check,
same output layout. Uses only the standard library; zero third-party
dependencies.
Run with::
python -m examples.python.fetch_btcusdt
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
# Binance Spot REST endpoint for historical klines.
KLINES_URL = "https://api.binance.com/api/v3/klines"
SYMBOL = "BTCUSDT"
# Binance caps a single klines response at 1000 rows.
PAGE_LIMIT = 1000
REQUEST_PAUSE_S = 0.2
# (Binance interval code, output file name, target candle count).
# The monthly file is btcusdt-1month.csv, not btcusdt-1M.csv, so it does not
# collide with btcusdt-1m.csv on case-insensitive filesystems.
DATASETS: list[tuple[str, str, int]] = [
("1m", "btcusdt-1m.csv", 50_000),
("5m", "btcusdt-5m.csv", 10_000),
("15m", "btcusdt-15m.csv", 10_000),
("1h", "btcusdt-1h.csv", 10_000),
("12h", "btcusdt-12h.csv", 5_000),
("1d", "btcusdt-1d.csv", 5_000),
("1M", "btcusdt-1month.csv", 5_000),
]
@dataclass
class Kline:
open_time: int
close_time: int
open: float
high: float
low: float
close: float
volume: float
def parse_kline(raw: list) -> Optional[Kline]:
"""Parse one Binance kline array. Returns None for any row that is
malformed or fails the same OHLC validity check ``Candle::new`` applies
in the Rust core."""
if not isinstance(raw, list) or len(raw) < 7:
return None
try:
open_time = int(raw[0])
close_time = int(raw[6])
o = float(raw[1])
h = float(raw[2])
lo = float(raw[3])
c = float(raw[4])
v = float(raw[5])
except (TypeError, ValueError):
return None
if not all(math.isfinite(x) for x in (o, h, lo, c, v)):
return None
if h < lo or h < o or h < c or lo > o or lo > c or v < 0:
return None
return Kline(open_time, close_time, o, h, lo, c, v)
def fetch_page(interval: str, end_time: Optional[int]) -> list:
"""Fetch one page of klines (up to ``PAGE_LIMIT`` rows)."""
params: dict[str, str | int] = {
"symbol": SYMBOL,
"interval": interval,
"limit": PAGE_LIMIT,
}
if end_time is not None:
params["endTime"] = end_time
url = f"{KLINES_URL}?{urllib.parse.urlencode(params)}"
try:
with urllib.request.urlopen(url, timeout=30) as resp:
body = resp.read().decode("utf-8")
except urllib.error.URLError as exc:
raise RuntimeError(f"urlopen failed for {url}: {exc}") from exc
data = json.loads(body)
if not isinstance(data, list):
raise RuntimeError(
f"expected a JSON array from Binance, got: {body[:160]}"
)
return data
def collect(interval: str, target: int, now_ms: int) -> list[Kline]:
"""Paginate the Binance REST API backwards until ``target`` closed
candles have been collected (or the exchange runs out of history),
then return them in ascending open-time order."""
by_open: dict[int, Kline] = {}
end_time: Optional[int] = None
pages = 0
while True:
page = fetch_page(interval, end_time)
pages += 1
if not page:
break
oldest: Optional[int] = None
for raw in page:
k = parse_kline(raw)
if k is None:
continue
if oldest is None or k.open_time < oldest:
oldest = k.open_time
# Keep only fully closed candles — drop the in-progress bucket.
if k.close_time < now_ms:
by_open[k.open_time] = k
sys.stderr.write(
f"\r {interval}: collected {len(by_open)} candles over "
f"{pages} page(s)…"
)
sys.stderr.flush()
if len(by_open) >= target or len(page) < PAGE_LIMIT:
break
if oldest is None:
raise RuntimeError(
f"Binance page for {interval} held no parseable klines"
)
end_time = oldest - 1
time.sleep(REQUEST_PAUSE_S)
sys.stderr.write("\n")
candles = sorted(by_open.values(), key=lambda k: k.open_time)
if len(candles) > target:
candles = candles[-target:]
return candles
def format_number(v: float) -> str:
"""Match Rust's f64 ``Display``: shortest round-trip, no trailing ``.0``.
Python's :func:`repr` already gives the shortest round-trip
representation; Rust additionally drops the ``.0`` suffix for
whole-number floats. Strip that here so the CSVs produced by the
Rust, Python and Node fetchers are byte-for-byte identical on the
same Binance snapshot.
"""
s = repr(v)
if s.endswith(".0"):
return s[:-2]
return s
def write_csv(path: Path, candles: list[Kline]) -> None:
"""Write candles to ``path`` in the standard OHLCV layout the
``CandleReader`` accepts."""
with path.open("w", encoding="utf-8", newline="") as f:
f.write("timestamp,open,high,low,close,volume\n")
for k in candles:
f.write(
f"{k.open_time},"
f"{format_number(k.open)},"
f"{format_number(k.high)},"
f"{format_number(k.low)},"
f"{format_number(k.close)},"
f"{format_number(k.volume)}\n"
)
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__.splitlines()[0] if __doc__ else None,
)
parser.parse_args() # accept --help; no other CLI flags today.
now_ms = int(time.time() * 1000)
data_dir = Path(__file__).resolve().parent.parent / "data"
data_dir.mkdir(parents=True, exist_ok=True)
print(f"Fetching {SYMBOL} klines from Binance into {data_dir}")
for interval, filename, target in DATASETS:
candles = collect(interval, target, now_ms)
if not candles:
print(
f"error: Binance returned no closed candles for {interval}",
file=sys.stderr,
)
return 1
write_csv(data_dir / filename, candles)
print(
f" {interval:>3} {len(candles):>6} candles -> "
f"examples/data/{filename}"
)
print(f"Done — {len(DATASETS)} datasets written.")
return 0
if __name__ == "__main__":
raise SystemExit(main())