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
- Binance spot market-data endpoints:
klinesanddepthparameters, defaults, weights, and response field order. - Binance spot general API information: base hosts,
data-api.binance.vision, timestamp units. - Binance spot API limits: per-IP limits, 429 and 418,
Retry-After. - Binance USDⓈ-M funding-rate history: default and max limit, ascending order, 500 per 5 minutes,
rateType. - Bybit V5 kline, funding-rate history, and order book: parameters, sort order, response fields.
- Bybit rate limits: 600 requests per 5 seconds per IP, 403 handling.
- binance-public-data: bulk history files, CHECKSUM files, microsecond spot timestamps from 2025.
- The code examples were verified only against fictional values shaped like the documented responses. This article does not claim that any indicator is profitable or that funding-rate signals work.
