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