Files
kingchenc b948b0b9cf examples(python): add a stdlib-only fetch_btcusdt script
Python had no sibling for the Rust `fetch_btcusdt` data-generator —
adding it closes the "fetch (data-gen)" cell for Python and lets users
without a Rust toolchain regenerate the bundled BTCUSDT datasets.

* examples/python/fetch_btcusdt.py — uses only the standard library
  (urllib.request + json + csv); same pagination strategy as the Rust
  version (paginate backwards via `endTime`, drop the in-progress
  bucket, sort and trim to the configured target). Applies the same
  OHLC validity check the Rust `Candle::new` constructor enforces
  (finite fields, high >= low/open/close, low <= open/close,
  volume >= 0) so a malformed kline is skipped rather than written.
* Number formatting matches Rust's `f64` Display: shortest round-trip,
  no trailing `.0` for whole-number floats. Verified by running the
  script and `git diff`-ing against the checked-in dataset: every row
  older than the run is byte-identical to Rust's output; the diff only
  shows the most recent ~24 hours where Binance has produced fresh
  candles since the original snapshot.

examples/README.md gains the new row.
2026-05-23 00:38:38 +02:00

210 lines
6.6 KiB
Python

"""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())