DEV Community

FoxyyyBusiness
FoxyyyBusiness

Posted on

Three crypto exchange volume bugs that were hiding in plain sight

I run a service that pulls funding rates from 16 perpetual futures exchanges every five minutes and exposes a unified API for cross-venue arbitrage. Each fetcher is ~50 lines of Python — request, parse, normalize, store. Boring stuff, in theory.

In practice, three of those 50-line fetchers were silently broken in ways that produced plausible-looking numbers. My unit tests passed. The data looked sane. But when I added a simple /api/funding/by_volume endpoint that ranks base coins by their total cross-exchange 24h dollar volume, the leaderboard came back with SATS showing $180 trillion in daily turnover.

That number is roughly the entire global stock-and-bond market, traded in one day, in one shitcoin, on perpetual futures. Something was off.

Here are the three bugs I dug out, in the order I found them.

Bug #1 — OKX is denominated in BASE coin, not USDT

OKX's /api/v5/market/tickers?instType=SWAP endpoint returns a volCcy24h field for every perp. The name reads like "volume in counter currency" — i.e. the quote, USDT. That's how Binance, Bybit, Bitget, Gate.io, MEXC, and most others structure it.

OKX does not. For SWAPs, volCcy24h is in base coin units.

So my fetcher was treating 138000 (138k BTC traded in 24h, which is correct) as $138k in USD volume. OKX BTC volume came back as $136k against Binance's $16.5B. I had been staring at this in the dashboard for two days without noticing because BTC was already at the top of the list — the ranking was right, the magnitude was off by five orders of magnitude.

The fix is one line:

def _okx_tickers() -> dict[str, dict]:
    r = requests.get("https://www.okx.com/api/v5/market/tickers",
                     params={"instType": "SWAP"}, timeout=10)
    out = {}
    for item in r.json().get("data", []) or []:
        inst = item.get("instId", "")
        base_vol = float(item.get("volCcy24h", 0) or 0)
        last     = float(item.get("last", 0) or 0)
        # volCcy24h is in BASE coin units for SWAPs — multiply by last price for USD.
        vol_usd  = base_vol * last
        out[inst] = {"vol_usd": vol_usd, "last": last}
    return out
Enter fullscreen mode Exit fullscreen mode

After the fix, OKX BTC volume jumped from $136k to $9.7B. That matches every other public reporter (CoinGecko, Coinglass, etc).

Lesson: field names are descriptions, not contracts. Read the docs, but trust the cross-exchange comparison even more.

Bug #2 — BTSE counts CONTRACTS, not coins

BTSE's /futures/api/v2.1/market_summary returns a volume field for each market. It is, like everywhere else, a number. But it's not a number of base coins. It's a number of contracts.

A contract on BTSE has a configurable contractSize. For BTC perps, the contract size is 0.001. For some alts, it's 1. For some new perps, it's a weird fraction like 0.0001. The volume field is the count of contracts traded — and the actual notional in base coin units is volume * contractSize.

I had defaulted my BTSE fetcher to assuming one contract = one base coin (a reasonable Binance-shaped assumption). Result: BTSE BTC was reporting $60 trillion of 24h volume, because I was interpreting 1 million contracts (= 1000 BTC) as 1 million BTC.

The fix:

vol_contracts = float(m.get("volume", 0) or 0)
contract_size = float(m.get("contractSize", 1) or 1)
vol_base_units = vol_contracts * contract_size
vol_usd = vol_base_units * mark_price
Enter fullscreen mode Exit fullscreen mode

After the fix: BTSE BTC ~$0.6B per day. That matches BTSE's own published volume widget on their landing page, which I should have checked first.

Lesson: any time an exchange exposes a contractSize field, you almost certainly have to apply it. Even if the default for BTC happens to be 1 on the venue you tested first.

Bug #3 — Kraken Futures funding rate is in dollars, not percent

This one was the most expensive bug because it took the longest to notice.

Kraken Futures' /derivatives/api/v3/tickers endpoint returns a fundingRate field. Every other exchange I integrate (16 of them now) returns this as a decimal: 0.0001 means 0.01% per funding period. Annualized at 8h funding intervals, that's about 11% APY. Sane.

Kraken Futures does not. Their fundingRate is the USD payment per contract per period. So a Kraken fundingRate of 7.0 doesn't mean 700% per period — it means $7 paid per contract held over the funding period.

To convert to a comparable decimal rate, you divide by the mark price:

# Kraken Futures returns fundingRate as USD-per-contract per period.
# Divide by mark price to get the normalized decimal rate.
rate = raw_rate / mark_price
Enter fullscreen mode Exit fullscreen mode

The bug was visible in my dashboard for over a day before I caught it. Kraken ETH was showing 1893% APY — clearly wrong, but it sat in the "extreme rates" view alongside legitimately weird shitcoin funding rates (some alts genuinely run at hundreds of percent), so it didn't pop out. After the fix, Kraken ETH funding came back to ~7% APY, which lines up with the rest of the market.

Lesson: extreme outliers in financial data are sometimes real, sometimes wrong, and almost always worth a five-minute manual sanity check before you ship.

The one-line sanity check that catches all three

Here's the trick I now run after every new exchange integration:

For BTC, sort all exchanges by 24h volume in USD. The numbers should be within one order of magnitude of each other, and no single exchange should be more than ~5x larger than the median.

Concretely:

btc_rows = [r for r in rows if r["base"] == "BTC"]
btc_rows.sort(key=lambda r: -r["volume_24h_usd"])
for r in btc_rows:
    print(f"{r['exchange']:12s} ${r['volume_24h_usd']:>15,.0f}")
Enter fullscreen mode Exit fullscreen mode

What healthy looks like (at the time of writing, end of March 2026):

binance      $ 17,960,723,449
okx          $  9,153,894,221
bybit        $  7,983,221,887
gateio       $  6,510,448,775
bitget       $  4,869,514,230
hyperliquid  $  3,420,118,002
mexc         $  2,914,702,118
bingx        $  1,822,978,884
htx          $    902,541,003
btse         $    614,923,937
phemex       $    321,108,775
kucoin       $    298,992,881
bitfinex     $    132,414,498
kraken       $    101,302,440
bitmex       $     78,200,116
dydx         $     45,109,332
Enter fullscreen mode Exit fullscreen mode

Three properties:

  1. The leader (Binance) is about 250x the smallest entrant (dYdX) — that's the realistic span of CEX/DEX liquidity for the dominant pair.
  2. There are no zeros, and there are no $1T entries.
  3. The shape of the curve is roughly log-linear when plotted, which is what you'd expect from the long tail of crypto venues.

If any single exchange comes back with three more zeros than its neighbors, you have a unit bug. If it comes back with three fewer zeros, same thing. Both my OKX and BTSE bugs were instantly visible in this view — I just hadn't built the view until after the bugs had been live for two days.

For funding rates, the equivalent sanity check is:

No exchange's funding rate for a major (BTC/ETH/SOL) should annualize to more than ±100%. If one does, you have a normalization bug or the venue is in liquidation.

A 1893% APY does not survive this filter. Build the filter first.

What I changed in my workflow

After fixing these three I added two things to the integration template for any new exchange:

  1. A regression test that pins the unit semantics, not just the happy path. For example, my OKX test now constructs a ticker with volCcy24h: "138000" and last: "70000" and asserts that vol_usd equals 138_000 * 70_000, NOT just that the function returns a non-empty dict. If a future refactor accidentally drops the multiplication, the test fails.
  2. A post-deploy cross-exchange diff check that runs after every collector restart. It pulls the BTC volume across all exchanges and screams if any single one is more than 50x or less than 1/50th of the median. It's a 30-line cron job and it would have caught all three of my bugs within five minutes of deployment.

Both of these are straightforward, but they're easy to skip when you're heads-down adding venues. I skipped them, and it cost me two days of looking at a leaderboard with SATS showing $180T in volume and thinking "huh, that's weird, I'll deal with it later."

The bugs were never in the parsing code. They were in the units of the inputs. The exchanges that ship the cleanest documentation and most consistent field semantics make this kind of normalization invisible. The exchanges that don't will silently corrupt your dashboard until the day you build a leaderboard.

If you're aggregating data across venues, build the leaderboard first.


If you found this useful and you're trading crypto perps, the dashboard I was debugging is live at https://fundingfinder.foxyyy.com (free tier, no signup) — it now covers 16 exchanges and ~6,200 USDT-margined perps with five-minute refresh. The fixes from this article are deployed in v0.6.10.

Top comments (0)