αAlphaware
TECH10 min read

Collecting crypto market data from exchange APIs: Binance and Bybit requests and parsing checks

Endpoints, parameters, and rate limits to match against the docs when pulling candles, funding rates, and order books from Binance and Bybit public market-data APIs, plus parsing code you can verify offline.

Collecting crypto market data from exchange APIs: Binance and Bybit requests and parsing checks

Updated November 3, 2026. Code and explanations were corrected against the official docs for Binance spot, Binance USDⓈ-M futures, and Bybit V5 public market-data endpoints. Parameter caps and rate limits change, so check the official docs linked below before you rely on them.

Public data needs no key, but every venue has its own rules

Candles (OHLCV), funding rates, and order-book snapshots are public market data on both Binance and Bybit and can be queried without an API key. Keys are needed for account functions such as balances, orders, and position management. A research collector is safer without a key from the start. If a later stage needs one, give it read-only permission and an IP restriction, and leave withdrawals disabled.

The hard part is not the key but the differences between venues. The same “1-hour candle” differs in interval notation, sort order, page size, and the response code for excess requests. If the collector does not normalize these, the backtest is quietly wrong.

Data Binance Bybit V5
Candles GET /api/v3/klines (spot), limit default 500 / max 1000, weight 2 GET /v5/market/kline, limit default 200 / max 1000, newest first
Funding-rate history GET /fapi/v1/fundingRate (USDⓈ-M), limit default 100 / max 1000, ascending GET /v5/market/funding/history, limit default and max 200
Order-book snapshot GET /api/v3/depth, limit default 100 / max 5000, weight 5–250 GET /v5/market/orderbook, linear default 25 / max 1000
Excess requests HTTP 429; continuing after it leads to 418 (IP ban, 2 minutes to 3 days) Default 600 requests per 5 seconds per IP; above that, 403 and a wait of at least 10 minutes

Summary of the official docs, checked November 3, 2026. Binance limits are per IP; current values are in the rateLimits array of /api/v3/exchangeInfo.

Request construction: pin endpoints and parameters to the docs

Unofficial wrapper libraries are convenient, but they hide which endpoint and defaults are used underneath. For the collector’s core paths, use the documented paths and parameter names directly, and keep URL construction separate from the network call so it is easy to test. The code below sends nothing; it only builds URLs.

from urllib.parse import urlencode

BINANCE_SPOT = "https://api.binance.com"
BINANCE_USDM = "https://fapi.binance.com"
BYBIT = "https://api.bybit.com"

def build_url(base, path, **params):
    query = {k: v for k, v in params.items() if v is not None}
    return f"{base}{path}?{urlencode(query)}"

def binance_klines(symbol, interval, start_ms=None, limit=1000):
    return build_url(BINANCE_SPOT, "/api/v3/klines", symbol=symbol,
                     interval=interval, startTime=start_ms, limit=limit)

def binance_funding(symbol, start_ms=None, end_ms=None, limit=1000):
    return build_url(BINANCE_USDM, "/fapi/v1/fundingRate", symbol=symbol,
                     startTime=start_ms, endTime=end_ms, limit=limit)

def binance_depth(symbol, limit=100):
    return build_url(BINANCE_SPOT, "/api/v3/depth", symbol=symbol, limit=limit)

def bybit_klines(symbol, interval, category="linear", end_ms=None, limit=1000):
    return build_url(BYBIT, "/v5/market/kline", category=category,
                     symbol=symbol, interval=interval, end=end_ms, limit=limit)

def bybit_funding(symbol, category="linear", end_ms=None, limit=200):
    return build_url(BYBIT, "/v5/market/funding/history", category=category,
                     symbol=symbol, endTime=end_ms, limit=limit)

url = binance_klines("BTCUSDT", "1h", start_ms=1767225600000)
assert url == ("https://api.binance.com/api/v3/klines?symbol=BTCUSDT"
               "&interval=1h&startTime=1767225600000&limit=1000")
assert "startTime" not in binance_funding("BTCUSDT")
assert bybit_klines("BTCUSDT", "60").startswith(
    "https://api.bybit.com/v5/market/kline?category=linear&symbol=BTCUSDT&interval=60")
assert bybit_funding("BTCUSDT").endswith("limit=200")

Parameter names differ. Binance candles use startTime and endTime; Bybit candles use start and end. The interval is 1h on Binance and the minute count 60 on Bybit. Bybit’s category defaults to linear when omitted, so spot data needs an explicit spot. For Binance spot, the docs list a public-market-data host, data-api.binance.vision, in addition to api.binance.com.

Candles: fix sort order and time units first

A Binance candle is a 12-element array (open time, OHLC, volume, close time, quote volume, trade count, taker buy volumes, and an ignored field), with prices and quantities as strings. A Bybit candle is a 7-element string array (start time, OHLC, volume, turnover), and the newest candle comes first. The parser converts both into the same UTC index and float columns and sorts explicitly.

import pandas as pd

# Sample values shaped like the documented response (not real prices)
binance_payload = [
    [1767225600000, "100.0", "101.0", "99.5", "100.5", "12.0",
     1767229199999, "1206.0", 340, "6.0", "603.0", "0"],
    [1767229200000, "100.5", "102.0", "100.0", "101.5", "8.0",
     1767232799999, "812.0", 210, "5.0", "507.5", "0"],
]
bybit_payload = {
    "retCode": 0, "retMsg": "OK",
    "result": {"category": "linear", "symbol": "BTCUSDT", "list": [
        ["1767229200000", "100.6", "102.1", "100.1", "101.4", "9.0", "910.0"],
        ["1767225600000", "100.1", "101.1", "99.6", "100.6", "11.0", "1105.0"],
    ]},
}

COLS = ["open", "high", "low", "close", "volume"]

def parse_binance_klines(rows):
    df = pd.DataFrame([r[:6] for r in rows], columns=["open_time", *COLS])
    df["open_time"] = pd.to_datetime(df["open_time"].astype("int64"), unit="ms", utc=True)
    df[COLS] = df[COLS].astype(float)
    return df.set_index("open_time")

def parse_bybit_klines(payload):
    if payload["retCode"] != 0:
        raise RuntimeError(payload["retMsg"])
    rows = [r[:6] for r in payload["result"]["list"]]
    df = pd.DataFrame(rows, columns=["open_time", *COLS])
    df["open_time"] = pd.to_datetime(df["open_time"].astype("int64"), unit="ms", utc=True)
    df[COLS] = df[COLS].astype(float)
    return df.set_index("open_time").sort_index()  # Bybit returns the newest candle first

a = parse_binance_klines(binance_payload)
b = parse_bybit_klines(bybit_payload)
assert a.index.is_monotonic_increasing and b.index.is_monotonic_increasing
assert list(a.index) == list(b.index)
assert str(a.index[0]) == "2026-01-01 00:00:00+00:00"
assert a["close"].iloc[-1] == 101.5 and b["close"].iloc[0] == 100.6

Check what volume means as well. Binance spot volume is the base-asset quantity, and Bybit documents volume and turnover (quote-currency value) separately. Align units before combining or comparing volume across venues. Candle timestamps are start times, so record that a signal can use the bar only after its close. Availability timing follows the same principle as the point-in-time data column.

Long history comes in pages; check overlaps and gaps

Both Binance spot and Bybit return at most 1000 candles per request. A year of 1-hour candles (about 8,760) needs several requests. Start each page at the last candle’s close time + 1 ms, then count duplicates and gaps once everything is in. The example below verifies the paging logic with a fake response function.

HOUR_MS = 3_600_000

def fake_klines(start_ms, limit, last_open_ms):
    """Offline stand-in: return up to `limit` 1h candles starting at start_ms."""
    opens = range(start_ms, last_open_ms + 1, HOUR_MS)
    return [[t, "1", "1", "1", "1", "0", t + HOUR_MS - 1] for t in opens][:limit]

def paginate(fetch, start_ms, limit=1000):
    rows, cursor = [], start_ms
    while True:
        page = fetch(cursor, limit)
        if not page:
            break
        rows.extend(page)
        cursor = page[-1][6] + 1          # last candle's close_time + 1 ms
        if len(page) < limit:
            break
    return rows

def check_continuity(rows, step_ms):
    opens = [r[0] for r in rows]
    dupes = len(opens) - len(set(opens))
    gaps = [(a, b) for a, b in zip(opens, opens[1:]) if b - a != step_ms]
    return dupes, gaps

start = 1767225600000                      # 2026-01-01 00:00 UTC
last = start + 2_499 * HOUR_MS             # 2,500 candles
rows = paginate(lambda s, n: fake_klines(s, n, last), start)
assert len(rows) == 2_500
assert check_continuity(rows, HOUR_MS) == (0, [])

holed = rows[:10] + rows[11:]             # one candle missing
dupes, gaps = check_continuity(holed, HOUR_MS)
assert dupes == 0 and len(gaps) == 1

When a gap appears, record it rather than filling it automatically. Exchange maintenance, the pre-listing period, and a failed request are different causes with different fixes. Because Bybit returns newest first, it is more natural to move end backward in time.

For multi-year history, the daily and monthly files Binance publishes at data.binance.vision are more efficient than repeated REST calls. Each zip has a .CHECKSUM file for integrity checks. According to that repository, spot file timestamps from January 1, 2025 onward are in microseconds, so align units when mixing them with REST data (milliseconds by default).

Funding rates: read the settlement interval from data, do not assume it

The earlier version of this article assumed an 8-hour interval and called 21 records a “7-day average.” That assumption does not fit every contract. Bybit’s docs state that each symbol has its own funding interval, and Binance exposes a fundingIntervalHours field in GET /fapi/v1/fundingInfo. Comparing per-settlement rates across contracts with different intervals distorts the difference.

import pandas as pd

# Sample values shaped like the documented response (not real funding rates)
binance_funding = [
    {"symbol": "BTCUSDT", "fundingRate": "0.00010000", "fundingTime": 1767225600000,
     "markPrice": "100.0", "rateType": "Regular"},
    {"symbol": "BTCUSDT", "fundingRate": "0.00008000", "fundingTime": 1767254400000,
     "markPrice": "101.0", "rateType": "Regular"},
    {"symbol": "BTCUSDT", "fundingRate": "-0.00002000", "fundingTime": 1767283200000,
     "markPrice": "99.0", "rateType": "Regular"},
]
bybit_funding = {"retCode": 0, "retMsg": "OK", "result": {"category": "linear", "list": [
    {"symbol": "XYZUSDT", "fundingRate": "0.0002", "fundingRateTimestamp": "1767240000000"},
    {"symbol": "XYZUSDT", "fundingRate": "0.0001", "fundingRateTimestamp": "1767225600000"},
    {"symbol": "XYZUSDT", "fundingRate": "0.0003", "fundingRateTimestamp": "1767211200000"},
]}}

def normalize(rows, venue, time_key):
    df = pd.DataFrame({
        "venue": venue,
        "symbol": [r["symbol"] for r in rows],
        "funding_time": pd.to_datetime([int(r[time_key]) for r in rows], unit="ms", utc=True),
        "rate": [float(r["fundingRate"]) for r in rows],
    })
    df = df.sort_values("funding_time").reset_index(drop=True)
    step = df["funding_time"].diff().dt.round("min").median()
    df["interval_h"] = step / pd.Timedelta(hours=1)
    return df

a = normalize(binance_funding, "binance", "fundingTime")
b = normalize(bybit_funding["result"]["list"], "bybit", "fundingRateTimestamp")

assert a["interval_h"].iloc[0] == 8.0      # 8 hours in this sample
assert b["interval_h"].iloc[0] == 4.0      # 4 hours in this sample
assert b["funding_time"].is_monotonic_increasing
# Per-settlement rates are not comparable across intervals: put them on a per-day basis
a["rate_per_day"] = a["rate"] * 24 / a["interval_h"]
b["rate_per_day"] = b["rate"] * 24 / b["interval_h"]
assert abs(a["rate_per_day"].iloc[0] - 0.0003) < 1e-12
assert abs(b["rate_per_day"].iloc[-1] - 0.0012) < 1e-12

A few documented behaviors belong in the code as well. Without a time range, Binance fundingRate returns the latest 200 records in ascending order, and it shares a limit of 500 requests per 5 minutes per IP with fundingInfo. The response’s rateType is Regular for normal funding. On Bybit, sending only startTime is an error, while sending only endTime, or neither, returns up to 200 records up to that time.

An interval inferred from data is wrong when there is only one record or the interval changed midway. In production, store the interval from fundingInfo (Binance) or instruments-info (Bybit) and warn when it disagrees with the inferred value. This article does not propose thresholds that turn funding levels into signals such as “overheated”; those thresholds need their own validation.

An order-book snapshot is a record of one moment

A REST order book is a snapshot at the moment of the request. The data does not guarantee that a price level with large resting orders acts as support or resistance; orders can be cancelled without trading. What a snapshot gives consistently is descriptive statistics such as mid price, spread, and quantity imbalance within a band.

# Sample values shaped like the documented response (not a real book)
binance_depth = {
    "lastUpdateId": 1027024,
    "bids": [["99.90", "2.0"], ["99.80", "5.0"], ["99.00", "40.0"]],
    "asks": [["100.10", "1.5"], ["100.20", "4.0"], ["101.00", "30.0"]],
}
bybit_book = {"retCode": 0, "retMsg": "OK", "result": {
    "s": "BTCUSDT", "b": [["99.90", "2.0"], ["99.80", "5.0"]],
    "a": [["100.10", "1.5"], ["100.20", "4.0"]],
    "ts": 1767225600123, "u": 18521288, "seq": 7961638724, "cts": 1767225600100}}

def to_levels(pairs):
    return [(float(p), float(q)) for p, q in pairs]

def book_summary(bids, asks, band_bps=50):
    best_bid, best_ask = bids[0][0], asks[0][0]
    if best_bid >= best_ask:
        raise ValueError("crossed book: snapshot is inconsistent")
    mid = (best_bid + best_ask) / 2
    lo, hi = mid * (1 - band_bps / 1e4), mid * (1 + band_bps / 1e4)
    bid_qty = sum(q for p, q in bids if p >= lo)
    ask_qty = sum(q for p, q in asks if p <= hi)
    return {
        "mid": mid,
        "spread_bps": (best_ask - best_bid) / mid * 1e4,
        "imbalance": (bid_qty - ask_qty) / (bid_qty + ask_qty),
    }

s1 = book_summary(to_levels(binance_depth["bids"]), to_levels(binance_depth["asks"]))
r = bybit_book["result"]
s2 = book_summary(to_levels(r["b"]), to_levels(r["a"]))

assert s1["mid"] == 100.0 and round(s1["spread_bps"], 6) == 20.0
assert round(s1["imbalance"], 6) == round((7.0 - 5.5) / 12.5, 6)   # levels outside 50 bp excluded
assert s1 == s2                                                     # same book, same summary

Binance spot depth weight grows with limit, from 5 (1–100) to 250 (1001–5000), so frequent deep snapshots use up the limit quickly. In Bybit’s response, ts is the system generation time and cts the matching-engine time, and the docs state that RPI orders are not included. A continuous book needs a WebSocket stream plus a snapshot synchronization procedure, not repeated REST snapshots.

Operations: rate limits, retries, and storage rules

Excess-request handling differs by venue. Binance expects you to back off after a 429; continuing leads to an IP ban with 418. Both responses carry a Retry-After header, and the X-MBX-USED-WEIGHT-* response headers show current usage. Bybit returns 403 above the default 600 requests per 5 seconds per IP and tells you to close all sessions and wait at least 10 minutes.

# Not executed: this sends real network requests. Build URLs with the request functions above.
import json
import time
import urllib.error
import urllib.request

def get_json(url, max_tries=5):
    for attempt in range(max_tries):
        try:
            with urllib.request.urlopen(url, timeout=10) as resp:
                used = {k: v for k, v in resp.headers.items()
                        if k.lower().startswith("x-mbx-used-weight")}  # Binance only
                return json.load(resp), used
        except urllib.error.HTTPError as err:
            if err.code == 418 or (err.code == 403 and "bybit" in url):
                raise  # Binance IP ban or Bybit rate-limit: stop collecting and let a person check
            if err.code == 429:
                time.sleep(int(err.headers.get("Retry-After") or 2 ** attempt * 5))
                continue
            raise
    raise RuntimeError(f"gave up after {max_tries} tries: {url}")

This code was not executed. The endpoints and header names were checked against the official docs, but actual response codes and limits can vary by account, region, and time.

For reproducibility, append rather than overwrite. Keep the raw response, retrieval time, and request URL, and build a separate cleaned table deduplicated on a key such as (venue, symbol, open_time). A cron job or systemd timer is enough as a scheduler; making a re-fetch of the same range produce the same result matters more than the choice of scheduler.

For reference, the alphaware.xyz homepage ticker does not call exchange REST directly. Binance public REST returns 403 from Cloudflare egress IPs, so the same-origin /api/quotes reads BTC, ETH, and macro series through the Yahoo Finance chart API. The code in this article is a research collection example, not the live ticker.

Using collected bars as a volatility distance is covered in ATR for stops and position size, and validating the resulting rule in Preventing overfitting.

Sign up for Binance — this referral link grants trading fee discounts.

Sources and scope

Signal

Was this brief useful?

Subscribe

Get new briefs

Get an email when a new brief ships. RSS and X stay available.

Subscribe
More in this categoryTECH →