DEV Community

FoxyyyBusiness
FoxyyyBusiness

Posted on

The funding-rate gotcha that breaks 30% of Python crypto scrapers: per-symbol intervals

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
Enter fullscreen mode Exit fullscreen mode

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()}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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", [])}
Enter fullscreen mode Exit fullscreen mode

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", [])}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)