If you've ever used or written a Python script to scrape crypto perpetual funding rates from Binance, Bybit, Bitget, MEXC, Gate.io, OKX, or Hyperliquid, there's a 70% chance you have a subtle bug that quietly inflates your annualized yields by 2-8x on roughly 30% of symbols.
I'm not exaggerating. I checked.
The bug
Every popular GitHub repo I've found that scrapes funding rates assumes an 8-hour funding interval for every symbol. The annualization is typically computed as:
annualized_yield_pct = funding_rate * 3 * 365 * 100
Where 3 is "3 funding settlements per day" because there are 24 hours in a day and 8 hours per funding period.
This is wrong for ~30% of perpetual symbols on Binance and Bybit, ~25% of Bitget, and 100% of Hyperliquid and dYdX.
Binance has 425 USDT-margined perps on a 4-hour cycle and 4 perps on a 1-hour cycle. Bybit has 377 on 4-hour and 3 on 1-hour. Bitget has a similar mix. Hyperliquid and dYdX v4 are entirely on 1-hour cycles. MEXC even has a few perps on 24-hour cycles.
A 0.05% per-period funding rate is:
- 55% APY if the symbol is on an 8h cycle
- 109% APY if it's 4h
- 438% APY if it's 1h
- 18% APY if it's 24h
If your scanner shows the same annualized number for all of them — or worse, applies 8h to all of them — you're either over-reporting or under-reporting by 2-8x on roughly a third of your universe. The mistake silently propagates into your "best opportunities" ranking and leads to wasted capital on misranked trades.
How to verify whether your code has the bug
Pick any USDT-margined perp from one of the affected exchanges that has a non-standard interval. A few examples that were on 4h funding intervals as of writing (always check the live values, exchanges shift their cycles):
- Binance: many small-cap altcoins, especially recently listed
- Bybit: similar pattern
- Hyperliquid: literally all of them are 1h
Run your scraper. Look at the symbol's annualized yield. Then manually compute the correct number using the actual funding interval. If they differ by 2x or more, you have the bug.
Or just look for the magic number 3 * 365 (or 1095) in your code. If it's there without a funding_interval_hours lookup nearby, you have the bug.
The fix
Each exchange exposes the funding interval per symbol in a different endpoint, with a different field name, and a different unit. Welcome to crypto data plumbing. Here's the per-exchange fix:
Binance
def _binance_funding_intervals():
"""{symbol: hours}. Cache process-wide, refresh weekly."""
r = requests.get("https://fapi.binance.com/fapi/v1/fundingInfo", timeout=15)
return {item["symbol"]: int(item.get("fundingIntervalHours", 8)) for item in r.json()}
The endpoint only returns symbols with non-default intervals (i.e. anything that's not 8h). For symbols not in the response, assume 8h. Cache this dict at startup and refresh once a week — the assignments change rarely.
Bybit
def _bybit_funding_intervals():
"""Bybit returns the interval in MINUTES via instruments-info."""
out = {}
cursor = ""
for _ in range(20):
params = {"category": "linear", "limit": 1000}
if cursor:
params["cursor"] = cursor
r = requests.get("https://api.bybit.com/v5/market/instruments-info", params=params, timeout=15)
data = r.json()
if data.get("retCode") != 0:
break
for item in data.get("result", {}).get("list", []):
sym = item.get("symbol", "")
minutes = int(item.get("fundingInterval", 480)) # default 8h = 480 min
out[sym] = max(1, minutes // 60)
cursor = data.get("result", {}).get("nextPageCursor", "")
if not cursor:
break
return out
Note the unit: minutes. Convert to hours.
Bitget
def _bitget_funding_intervals():
"""Returned in HOURS as fundingRateInterval. Single bulk call."""
r = requests.get(
"https://api.bitget.com/api/v2/mix/market/current-fund-rate",
params={"productType": "usdt-futures"}, timeout=15
)
return {item["symbol"]: int(item.get("fundingRateInterval", 8)) for item in r.json().get("data", [])}
Cleanest of the bunch. Bulk endpoint, hours.
MEXC
def _mexc_funding_intervals_inline(funding_rate_response):
"""MEXC bundles the interval into the funding_rate response itself, as `collectCycle`."""
return {item["symbol"]: int(item.get("collectCycle", 8)) for item in funding_rate_response.get("data", [])}
Inline with the funding rate data — no separate call needed. The field is collectCycle and the unit is hours.
Gate.io
def _gateio_funding_intervals():
"""Gate.io exposes funding_interval in SECONDS via /futures/usdt/contracts."""
r = requests.get("https://api.gateio.ws/api/v4/futures/usdt/contracts", timeout=15)
out = {}
for c in r.json():
seconds = int(c.get("funding_interval", 28800)) # default 8h = 28800s
out[c["name"]] = max(1, seconds // 3600)
return out
Note the unit: seconds. Divide by 3600.
OKX
def _okx_funding_intervals_from_response(funding_rate_response):
"""OKX doesn't expose the interval directly. Compute it from
nextFundingTime - fundingTime in the funding-rate response."""
next_t = int(funding_rate_response.get("nextFundingTime", 0)) // 1000
prev_t = int(funding_rate_response.get("fundingTime", 0)) // 1000
if next_t and prev_t and next_t > prev_t:
return max(1, round((next_t - prev_t) / 3600))
return 8
OKX is the trickiest. They don't expose the interval as a field — you compute it from the time difference between consecutive settlements.
Hyperliquid and dYdX v4
HYPERLIQUID_INTERVAL = 1 # always 1h
DYDX_INTERVAL = 1 # always 1h
These two venues have a single funding interval across their entire universe. No lookup needed.
The corrected annualization
Once you have the per-symbol interval, the annualization is:
def annualize(funding_rate, interval_hours):
if interval_hours <= 0:
return 0.0
periods_per_year = (24 / interval_hours) * 365
return funding_rate * periods_per_year * 100 # in %
For an 8h symbol, this gives funding_rate * 1095 * 100.
For a 4h symbol: funding_rate * 2190 * 100.
For a 1h symbol: funding_rate * 8760 * 100.
For a 24h symbol: funding_rate * 365 * 100.
That's it. Five lines of code, applied per symbol with the correct interval, and your scanner is no longer lying to you.
What I see in the wild
I built Funding Finder partly because none of the public alternatives I tried got this right. The free n8n templates assume 8h. The popular GitHub repos assume 8h. Even some commercial scanners I tried showed obvious signs of this bug — claiming 200% APY on a 1h-funding Hyperliquid perp where the actual rate was modest.
Coinglass gets it right (they're a serious commercial product). Coinalyze is BTC-only so it doesn't matter. Most everything else gets it wrong.
If you maintain a public crypto data scraper or scanner, please fix this. The fix is small and the impact on user trust is significant.
Sample broken code from a popular GitHub repo (anonymized)
# Found in a public repo with 800+ stars
def get_funding_apy(rate):
return rate * 1095 * 100 # 3 settlements per day * 365 days
Not naming the repo, but I've found this exact pattern in at least 5 popular crypto scrapers. If you wrote one of them, you know who you are. The fix is 20 lines.
The OSS data layer that gets it right
I extracted the data plumbing from Funding Finder into an MIT-licensed Python package called funding-collector that handles the per-symbol funding interval correctly across 8 exchanges (Binance, Bybit, OKX, Bitget, MEXC, Hyperliquid, Gate.io, dYdX v4). 700 lines of Python, no async, no dependencies beyond requests.
pip install funding-collector
Or just copy the relevant _<exchange>_funding_intervals() function from this post into your existing scraper. The fix takes 20 minutes.
— Clément
Top comments (0)