Skip to main content

crypto funding rate arb: the quiet alpha i've been running for months

past 2am. BTC arb just settled a clean +$870 position. wide awake. writing this instead of sleeping.

been running a funding rate arb strategy across my crypto book since late november. never posted about it because honestly it’s kind of a boring trade to explain. no dramatic wins, no blowups, just steady quiet alpha sitting off to the side of everything else. averaging $2-4k a month net with basically zero correlation to my options or futures book.

which is exactly why i care about it.

what’s a funding rate
#

quick primer: perpetual futures contracts don’t expire. to keep the perp price anchored to spot, exchanges use a funding rate mechanism. every 8 hours (typically midnight, 8am, 4pm UTC), longs pay shorts — or vice versa — based on a calculated rate.

when market is bullish and leveraged longs pile in, rate goes positive. longs pay shorts. when market dumps and shorts dominate, rate goes negative, shorts pay longs.

typical rate: 0.01% to 0.05% per 8h period. during momentum runs? can hit 0.2-0.3%+. annualize that and you understand why people pay a lot of attention to it.

the arb opportunity
#

funding rates don’t sync instantly across exchanges. Binance.US and Kraken have different liquidity pools, different trader compositions, and different regulatory constraints. divergences happen constantly. they’re usually small and short-lived, but they’re predictable in timing (8h settlement cycles) and measurable in real time.

the trade: when BTC-PERP funding rate on exchange A significantly exceeds exchange B, short A and long B. collect the rate differential at settlement. positions are roughly delta-neutral against each other.

that Feb 26-27 spike is where i made the most. rates briefly diverged 14bps in a single period. executed fast, settled clean.

python implementation
#

here’s the core of the monitoring and execution system. i’m using ccxt for unified exchange access and asyncio to poll both exchanges simultaneously without blocking:

import asyncio
import ccxt.async_support as ccxt
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
import asyncpg  # TimescaleDB connection
import aiohttp

logger = logging.getLogger(__name__)

@dataclass
class FundingSnapshot:
    exchange: str
    symbol: str
    rate: float
    next_settlement: datetime
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


@dataclass
class ArbOpportunity:
    long_exchange: str
    short_exchange: str
    symbol: str
    rate_spread: float          # percentage points
    long_rate: float
    short_rate: float
    estimated_pnl_per_lot: float
    settlement_dt: datetime
    valid: bool = True


class FundingRateMonitor:
    """
    Monitors funding rate divergences across exchanges.
    Polls every 30 seconds, stores to TimescaleDB, alerts on spread > threshold.
    """

    SYMBOL_MAP = {
        "BTC": {
            "binanceus": "BTC/USDT:USDT",
            "kraken": "BTC/USD:USD",
        },
        "ETH": {
            "binanceus": "ETH/USDT:USDT",
            "kraken": "ETH/USD:USD",
        },
    }

    # Fee structure: maker/taker per exchange
    FEE_TABLE = {
        "binanceus": {"maker": 0.0004, "taker": 0.0006},
        "kraken":    {"maker": 0.0002, "taker": 0.0005},
    }

    def __init__(
        self,
        min_spread_bps: float = 1.5,   # minimum 1.5 bps to enter
        position_usd: float = 40_000,   # per leg
        poll_interval: int = 30,        # seconds
        db_dsn: str = "",
    ):
        self.min_spread_bps = min_spread_bps / 100 / 100  # convert to decimal
        self.position_usd = position_usd
        self.poll_interval = poll_interval
        self.db_dsn = db_dsn
        self._exchanges: dict[str, ccxt.Exchange] = {}
        self._db_pool: Optional[asyncpg.Pool] = None
        self._running = False
        self._open_positions: dict[str, ArbOpportunity] = {}

    async def initialize(self):
        self._exchanges = {
            "binanceus": ccxt.binanceus({"enableRateLimit": True}),
            "kraken":    ccxt.kraken({"enableRateLimit": True}),
        }
        if self.db_dsn:
            self._db_pool = await asyncpg.create_pool(self.db_dsn, min_size=2, max_size=10)
        logger.info("FundingRateMonitor initialized")

    async def _fetch_funding_rate(self, exchange_id: str, symbol: str) -> Optional[FundingSnapshot]:
        ex = self._exchanges[exchange_id]
        try:
            data = await ex.fetch_funding_rate(symbol)
            rate = data.get("fundingRate", 0.0)
            next_ts = data.get("nextFundingDatetime")
            next_dt = (
                datetime.fromisoformat(next_ts.replace("Z", "+00:00"))
                if next_ts else datetime.now(timezone.utc)
            )
            return FundingSnapshot(
                exchange=exchange_id,
                symbol=symbol,
                rate=float(rate),
                next_settlement=next_dt,
            )
        except Exception as e:
            logger.warning(f"Error fetching {exchange_id} {symbol}: {e}")
            return None

    async def _fetch_all_rates(self, base: str) -> dict[str, FundingSnapshot]:
        tasks = {
            ex_id: self._fetch_funding_rate(ex_id, sym)
            for ex_id, sym in self.SYMBOL_MAP[base].items()
        }
        results = await asyncio.gather(*tasks.values(), return_exceptions=True)
        snapshots = {}
        for ex_id, result in zip(tasks.keys(), results):
            if isinstance(result, FundingSnapshot):
                snapshots[ex_id] = result
        return snapshots

    def _calculate_opportunity(
        self, base: str, snapshots: dict[str, FundingSnapshot]
    ) -> Optional[ArbOpportunity]:
        if len(snapshots) < 2:
            return None

        ex_ids = list(snapshots.keys())
        s1, s2 = snapshots[ex_ids[0]], snapshots[ex_ids[1]]

        if s1.rate > s2.rate:
            short_snap, long_snap = s1, s2
        else:
            short_snap, long_snap = s2, s1

        spread = short_snap.rate - long_snap.rate

        # Round-trip fees for both legs
        total_fees = (
            self.FEE_TABLE[long_snap.exchange]["taker"] +
            self.FEE_TABLE[short_snap.exchange]["taker"]
        ) * 2  # entry + exit

        net_rate = spread - total_fees

        if net_rate < self.min_spread_bps:
            return None

        pnl_est = net_rate * self.position_usd

        return ArbOpportunity(
            long_exchange=long_snap.exchange,
            short_exchange=short_snap.exchange,
            symbol=base,
            rate_spread=spread,
            long_rate=long_snap.rate,
            short_rate=short_snap.rate,
            estimated_pnl_per_lot=pnl_est,
            settlement_dt=min(s1.next_settlement, s2.next_settlement),
        )

    async def _store_snapshot(self, snapshot: FundingSnapshot):
        if not self._db_pool:
            return
        async with self._db_pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO funding_rates (ts, exchange, symbol, rate, next_settlement)
                VALUES ($1, $2, $3, $4, $5)
                ON CONFLICT DO NOTHING
                """,
                snapshot.timestamp, snapshot.exchange,
                snapshot.symbol, snapshot.rate, snapshot.next_settlement,
            )

    async def _alert_opportunity(self, opp: ArbOpportunity):
        spread_bps = opp.rate_spread * 100 * 100
        logger.info(
            f"ARB OPPORTUNITY | {opp.symbol} | "
            f"short {opp.short_exchange} ({opp.short_rate*100:.4f}%) "
            f"long {opp.long_exchange} ({opp.long_rate*100:.4f}%) | "
            f"spread {spread_bps:.2f}bps | est P&L ${opp.estimated_pnl_per_lot:.2f}"
        )
        # POST to alerting endpoint (Grafana/Prometheus pushgateway)
        async with aiohttp.ClientSession() as session:
            await session.post(
                "http://localhost:9091/metrics/job/funding_arb",
                data=f'funding_arb_spread_bps{{symbol="{opp.symbol}"}} {spread_bps}\n',
            )

    async def run(self):
        self._running = True
        logger.info("Starting funding rate monitor loop")
        while self._running:
            for base in self.SYMBOL_MAP:
                try:
                    snapshots = await self._fetch_all_rates(base)
                    for snap in snapshots.values():
                        await self._store_snapshot(snap)
                    opp = self._calculate_opportunity(base, snapshots)
                    if opp:
                        await self._alert_opportunity(opp)
                except Exception as e:
                    logger.error(f"Monitor loop error ({base}): {e}")
            await asyncio.sleep(self.poll_interval)

    async def shutdown(self):
        self._running = False
        for ex in self._exchanges.values():
            await ex.close()
        if self._db_pool:
            await self._db_pool.close()


async def main():
    monitor = FundingRateMonitor(
        min_spread_bps=1.5,
        position_usd=42_500,
        poll_interval=30,
        db_dsn="postgresql://user:pass@localhost:5432/trading",
    )
    await monitor.initialize()
    try:
        await monitor.run()
    finally:
        await monitor.shutdown()


if __name__ == "__main__":
    asyncio.run(main())

execution lives in a separate module that listens on the alert queue and submits market orders. keeping them decoupled means i can test the monitor without touching live positions.

infrastructure
#

this is where it matters: funding divergences close fast. sometimes within 30 minutes of appearing, market makers have arbitraged them away across exchanges.

my Chicago colo server runs the execution layer. ~45ms round-trip to both exchange APIs vs 200-250ms from home in San Diego. for a trade where the edge is 8-14bps and fees eat 4-6bps of that, slippage matters.

monitoring stack:

  • asyncio polling loop every 30 seconds — both exchanges hit in parallel
  • TimescaleDB stores every snapshot with microsecond timestamps — lets me backtest which hours generate the most divergences (answer: 23:00-01:00 UTC, right before midnight settlement)
  • Prometheus pushgateway receives spread metrics, Grafana fires alerts when spread exceeds threshold and persists 2+ consecutive polls
  • position tracker separate process, reads from Redis, handles execution and settlement confirmation

the hardest part wasn’t the strategy. it was making the monitoring reliable enough that i’m not sitting here manually checking rates. it runs while i sleep. that’s the whole point.

most divergences are small and below threshold. the ones i enter (10+ bps net of fees) happen roughly 4-6 times per month on BTC, fewer on ETH. not high frequency — but each entry has a definable edge.

results: four months
#

metric value
avg monthly P&L (net fees) +$2,380
best month (Dec 2025) +$4,100
worst month (Jan 2026) +$640
max single-period drawdown -$320
deployed capital (both legs) ~$85k
annualized return on deployed ~33%
correlation to options book 0.04

that 33% annualized sounds impressive but it’s bounded by exchange liquidity. realistically can’t deploy more than $150k across both legs before slippage erodes the edge entirely. so this caps out at roughly $5-6k/month max.

not the strategy that retires you. but it’s correlated with nothing, requires no prediction about market direction, and runs completely unattended. i check it once a day at most.

that’s rare enough that i’ll take it.

seen some discussion about cross-exchange alpha on NexusFi in the automated trading section — a lot of the same “boring is beautiful” philosophy applies here. consistent uncorrelated return streams matter more than big flashy wins once you’re managing real size.


A. came in around 1am, saw me at my desk with three monitors up. “funding thing?” yeah. she’s learned the vocabulary. went back to bed without another word.

dad was weirdly into arbitrage concepts even though he was an engineer, not a trader. used to say “the safest money is money that doesn’t depend on being right about direction.” took me four months of live trading to understand why this particular trade fits that description.

ok. actually sleeping now.

-AK

Related

order book imbalance - building a real-time alpha signal for crypto momentum
lied about sleeping. got into bed, laid there for 45 minutes, kept thinking about something. went back to the desk. the signal decay issue i diagnosed tonight (latency routing on crypto momentum) is real and i fixed it. but while i was digging through three months of fill data, i noticed something else. something i’d been ignoring entirely.
exchange connectivity layer - handling binance, kraken, and coinbase in one abstraction
one of the most annoying parts of crypto algo trading is that every exchange has a different API. different auth schemes, different rate limits, different order types, different error codes. writing strategy logic for each exchange separately is a nightmare and a maintenance disaster.
coinbase advanced vs binance.us - crypto algo trading comparison
been using both for 2+ years. different strengths. here’s the breakdown. coinbase advanced # what I use it for:
kraken vs coinbase - staking and yield comparison for algo traders
been staking on both platforms. different approaches to yield. here’s my comparison after 18 months. kraken staking # what they offer:
crypto momentum algo - btc breakout strategy implementation
BTC broke out of 3-month range today. my momentum algo caught it. time to document the implementation. the context # BTC been consolidating between $25,000 and $28,000 since june.
coinbase advanced vs kraken - python API comparison for algo trading
been using both coinbase and kraken for 2+ years. here’s the real comparison for algo traders. quick verdict # coinbase advanced: better for fiat on/off ramp, simpler API