Funding Rate Arbitrage on macOS: Multi-Exchange Scanner
Scan Bitget, Binance, Bybit, OKX, and Gate in parallel and find both perp-spot and perp-perp funding opportunities" slug: "funding-rate-arbitrage-macos-multi-exchange-scanner
Disclaimer: This is not financial advice. Crypto trading involves risk. Start with paper trading and only use capital you can afford to lose.
What changed from the single-exchange scanner
The earlier Phase 1 scanner only queried Bitget. This version covers five exchanges simultaneously: Bitget, Binance USDT-M, Bybit, OKX, and Gate. It runs the initial data fetch in parallel across venues, so total wall time stays roughly the same as querying one exchange.
It produces two kinds of opportunities in a single CSV:
Single-exchange (perp-spot): buy spot, short perp on the same venue, collect funding. Delta-neutral. Fee model uses spot maker + perp taker.
Cross-exchange (perp-perp): long the perp on the venue with the lowest funding, short the perp on the venue with the highest funding. No spot leg needed. Fee model uses two perp takers.
The analyzer reads that CSV and ranks both opportunity types.
Prerequisites
macOS with Python 3.11 installed.
No exchange accounts, no API keys, no deposits. Everything below uses public endpoints.
Step 1: Environment setup
mkdir -p ~/funding-arb-bot
cd ~/funding-arb-bot
python3.11 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install ccxt pandas numpy
mkdir -p data scripts logs
Step 2: Multi-exchange scanner
Create scripts/scan_funding.py:
cat > scripts/scan_funding.py << 'PYEOF'
import ccxt
import pandas as pd
import numpy as np
import time
import random
import sys
import argparse
from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
# --- Configuration ---
EXCHANGES = ['bitget', 'binanceusdm', 'bybit', 'okx', 'gate']
QUOTE = 'USDT'
DAYS_BACK = 30
MIN_24H_VOLUME_USD = 5_000_000
TOP_N_PER_EXCHANGE = 15
OUT_DIR = 'data'
# Perp taker fees per exchange (adjust to your VIP tier)
PERP_TAKER_FEES = {
'bitget': 0.0006,
'binanceusdm': 0.0005,
'bybit': 0.00055,
'okx': 0.0005,
'gate': 0.0005,
'hyperliquid': 0.00035,
}
DEFAULT_PERP_TAKER = 0.0006
# Spot leg fees (Binance with BNB discount)
SPOT_MAKER_FEE = 0.00075
SLIPPAGE_BPS = 5
def perp_fee(ex_id):
return PERP_TAKER_FEES.get(ex_id, DEFAULT_PERP_TAKER)
def round_trip_single(ex_id):
# spot buy maker + perp short taker, then exit both
return (SPOT_MAKER_FEE + perp_fee(ex_id) + SLIPPAGE_BPS / 10000) * 2
# --- Retry helper ---
def with_retry(fn, *args, max_retries=5, **kwargs):
for attempt in range(max_retries):
try:
return fn(*args, **kwargs)
except ccxt.RateLimitExceeded:
wait = (2 ** attempt) + random.uniform(0, 0.5)
time.sleep(wait)
except ccxt.NetworkError:
wait = (2 ** attempt) + random.uniform(0, 0.5)
time.sleep(wait)
raise RuntimeError("max retries exceeded")
# --- Exchange lifecycle ---
def load_exchange(ex_id):
ex = getattr(ccxt, ex_id)({'enableRateLimit': True, 'timeout': 30000})
ex.load_markets()
return ex_id, ex
def snapshot_exchange(ex_id, ex):
funding = with_retry(ex.fetch_funding_rates)
tickers = with_retry(ex.fetch_tickers)
rows = []
for sym, data in funding.items():
m = ex.markets.get(sym)
if not m or not m.get('active'):
continue
if not (m.get('swap') and m.get('linear')
and m.get('quote') == QUOTE and m.get('settle') == QUOTE):
continue
fr = data.get('fundingRate')
if fr is None:
continue
vol = float(tickers.get(sym, {}).get('quoteVolume') or 0)
rows.append({
'exchange': ex_id,
'symbol': sym,
'base': m['base'],
'cur_fr_pct': float(fr) * 100,
'vol_24h_usd': vol,
})
return rows
# --- Historical deep-dive ---
def fetch_history(ex, symbol, since):
out, cursor = [], since
while True:
batch = with_retry(ex.fetch_funding_rate_history, symbol, since=cursor, limit=100)
if not batch:
break
out.extend(batch)
if len(batch) < 100:
break
cursor = batch[-1]['timestamp'] + 1
time.sleep(ex.rateLimit / 1000 + random.uniform(0, 0.05))
return out
def deep_dive(ex_id, ex, row, since):
symbol = row['symbol']
hist = fetch_history(ex, symbol, since)
if len(hist) < 10:
return None
rates = pd.Series([float(h['fundingRate']) for h in hist])
avg_fr = rates.mean()
std = rates.std()
neg_count = int((rates < 0).sum())
cur, max_neg = 0, 0
for r in rates:
if r < 0:
cur += 1
max_neg = max(max_neg, cur)
else:
cur = 0
rt = round_trip_single(ex_id)
net_8h = avg_fr - rt / (DAYS_BACK * 3)
return {
'exchange': ex_id,
'symbol': symbol,
'base': row['base'],
'cur_fr_pct': row['cur_fr_pct'],
'vol_24h_musd': row['vol_24h_usd'] / 1e6,
'avg_fr_8h_pct': avg_fr * 100,
'median_fr_8h_pct': rates.median() * 100,
'min_fr_8h_pct': rates.min() * 100,
'max_fr_8h_pct': rates.max() * 100,
'std_fr_pct': std * 100,
'pct_pos': (rates > 0).mean() * 100,
'neg_count': neg_count,
'max_neg_streak': max_neg,
'ann_fund_pct': avg_fr * 3 * 365 * 100,
'net_8h_pct': net_8h * 100,
'ann_net_pct': net_8h * 3 * 365 * 100,
'sharpe': (rates.mean() / std) * np.sqrt(3 * 365) if std > 0 else 0,
'n_obs': len(hist),
}
# --- Main ---
parser = argparse.ArgumentParser()
parser.add_argument('--out', default=None)
args, _ = parser.parse_known_args()
print("Loading exchanges...")
exchanges = {}
with ThreadPoolExecutor(max_workers=len(EXCHANGES)) as pool:
futures = {pool.submit(load_exchange, ex_id): ex_id for ex_id in EXCHANGES}
for f in as_completed(futures):
try:
ex_id, ex = f.result()
exchanges[ex_id] = ex
print(f" OK {ex_id}: {len(ex.markets)} markets")
except Exception as e:
print(f" ERR {futures[f]}: {e}")
if not exchanges:
print("No exchanges loaded. Exiting.")
sys.exit(1)
print("\nFetching funding rates + tickers across all exchanges...")
all_rows = []
with ThreadPoolExecutor(max_workers=len(exchanges)) as pool:
futures = {pool.submit(snapshot_exchange, ex_id, ex): ex_id
for ex_id, ex in exchanges.items()}
for f in as_completed(futures):
try:
rows = f.result()
all_rows.extend(rows)
print(f" OK {futures[f]}: {len(rows)} linear perps")
except Exception as e:
print(f" ERR {futures[f]}: {e}")
df = pd.DataFrame(all_rows)
print(f"\nTotal linear perps across all exchanges: {len(df)}")
df = df[(df['vol_24h_usd'] >= MIN_24H_VOLUME_USD) & (df['cur_fr_pct'] > 0)]
print(f"After volume + positive-funding filter: {len(df)}")
# Deep-dive top N per exchange
since = int((datetime.now() - timedelta(days=DAYS_BACK)).timestamp() * 1000)
results = []
for ex_id, ex in exchanges.items():
subset = (df[df['exchange'] == ex_id]
.sort_values('cur_fr_pct', ascending=False)
.head(TOP_N_PER_EXCHANGE))
if len(subset) == 0:
continue
print(f"\nDeep-diving {len(subset)} symbols on {ex_id}...")
for i, (_, row) in enumerate(subset.iterrows(), 1):
print(f" [{i}/{len(subset)}] {row['symbol']} ...", end=' ', flush=True)
try:
r = deep_dive(ex_id, ex, row, since)
if r:
results.append(r)
print(f"ok avg {r['avg_fr_8h_pct']:.4f}%/8h ann_net {r['ann_net_pct']:.2f}%")
else:
print("skipped (insufficient history)")
except Exception as e:
print(f"error: {e}")
if not results:
print("\nNo valid results.")
sys.exit(1)
res_df = (pd.DataFrame(results)
.sort_values('ann_net_pct', ascending=False)
.reset_index(drop=True))
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
out_path = args.out or f"{OUT_DIR}/scan_multi_{stamp}.csv"
res_df.to_csv(out_path, index=False)
print(f"\nSaved {len(res_df)} rows to {out_path}")
print("\nTop 10 by annualized net carry (single-exchange perp-spot):")
print(res_df.head(10).to_string(index=False))
print(f"\nNext: python scripts/analyze_scan_multi.py {out_path}")
PYEOF
Step 3: Multi-exchange analyzer
Create scripts/analyze_scan_multi.py:
cat > scripts/analyze_scan_multi.py << 'PYEOF'
import pandas as pd
import numpy as np
import sys
import json
import argparse
from pathlib import Path
from datetime import datetime
# --- Thresholds ---
MIN_SHARPE = 1.0
MIN_VOL_MUSD = 20.0
MIN_NET_8H = 0.005 # single-exchange per 8h net carry
MIN_PCT_POS = 60.0
MIN_OBS = 60
# Cross-exchange thresholds
MIN_RATE_DIFF_8H = 0.01 # 0.01% per 8h difference between venues
MIN_X_VOL_MUSD = 10.0 # min volume on BOTH venues
CAPITAL_HKD = 10_000
HKD_USD = 0.128
DEPLOY_FRACTION = 0.6
MAX_SYMBOLS = 3
DAYS_BACK = 30
PERP_TAKER_FEES = {
'bitget': 0.0006,
'binanceusdm': 0.0005,
'bybit': 0.00055,
'okx': 0.0005,
'gate': 0.0005,
'hyperliquid': 0.00035,
}
DEFAULT_PERP_TAKER = 0.0006
SLIPPAGE_BPS = 5
def perp_fee(ex_id):
return PERP_TAKER_FEES.get(ex_id, DEFAULT_PERP_TAKER)
def round_trip_cross(ex_long, ex_short):
return (perp_fee(ex_long) + perp_fee(ex_short) + SLIPPAGE_BPS / 10000) * 2
# --- CLI ---
parser = argparse.ArgumentParser(description='Analyze multi-exchange funding scan.')
parser.add_argument('csv', help='Path to scan_multi_*.csv')
parser.add_argument('--json', action='store_true', help='Also emit JSON summary')
args = parser.parse_args()
CSV = Path(args.csv)
if not CSV.exists():
print(f"ERROR: {CSV} not found.")
sys.exit(1)
df = pd.read_csv(CSV)
scan_time = CSV.stem
# --- Helpers ---
def hr(ch='=', n=78): print(ch * n)
def section(t): print(); hr(); print(t); hr()
def pct(x, d=4): return f"{x:.{d}f}%"
# --- Header ---
hr('#', 78)
print("# MULTI-EXCHANGE FUNDING RATE REPORT")
print(f"# Source: {CSV}")
print(f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"# Exchanges: {', '.join(sorted(df['exchange'].unique()))}")
hr('#', 78)
# --- Section 1: Overview ---
section("1. OVERVIEW")
print(f"Rows scanned: {len(df)}")
print(f"Exchanges covered: {df['exchange'].nunique()}")
print(f"Unique base symbols: {df['base'].nunique()}")
print(f"Best ann_net (single): {pct(df['ann_net_pct'].max(), 2)}")
print(f"Median ann_net (single): {pct(df['ann_net_pct'].median(), 2)}")
print(f"Rows with ann_net > 0: {(df['ann_net_pct'] > 0).sum()}")
print()
print("Per-exchange counts:")
for ex_id, cnt in df['exchange'].value_counts().items():
print(f" {ex_id:<15} {cnt:>4} symbols")
# --- Section 2: Single-exchange shortlist ---
section("2. SINGLE-EXCHANGE (PERP-SPOT) SHORTLIST")
print(f"Filters: sharpe >= {MIN_SHARPE}, vol >= {MIN_VOL_MUSD}M, "
f"net_8h >= {MIN_NET_8H}%, pct_pos >= {MIN_PCT_POS}%")
s = df[
(df['sharpe'] >= MIN_SHARPE) &
(df['vol_24h_musd'] >= MIN_VOL_MUSD) &
(df['net_8h_pct'] >= MIN_NET_8H) &
(df['pct_pos'] >= MIN_PCT_POS) &
(df['n_obs'] >= MIN_OBS)
].sort_values('ann_net_pct', ascending=False).reset_index(drop=True)
if len(s) == 0:
print("\nNo single-exchange symbols pass all filters.")
else:
print()
cols = ['exchange', 'symbol', 'ann_net_pct', 'net_8h_pct',
'avg_fr_8h_pct', 'pct_pos', 'sharpe', 'vol_24h_musd']
print(s[cols].to_string(index=False))
# --- Section 3: Cross-exchange (perp-perp) opportunities ---
section("3. CROSS-EXCHANGE (PERP-PERP) OPPORTUNITIES")
# Group by base symbol
x_rows = []
for base, group in df.groupby('base'):
if group['exchange'].nunique() < 2:
continue
# short the venue with highest funding, long the venue with lowest
short_row = group.loc[group['avg_fr_8h_pct'].idxmax()]
long_row = group.loc[group['avg_fr_8h_pct'].idxmin()]
if short_row['exchange'] == long_row['exchange']:
continue
rate_diff = short_row['avg_fr_8h_pct'] - long_row['avg_fr_8h_pct']
if rate_diff <= 0:
continue
rt = round_trip_cross(long_row['exchange'], short_row['exchange'])
net_8h = rate_diff - (rt * 100) / (DAYS_BACK * 3)
ann_net = net_8h * 3 * 365
x_rows.append({
'base': base,
'short_exchange': short_row['exchange'],
'short_symbol': short_row['symbol'],
'short_avg_fr': short_row['avg_fr_8h_pct'],
'short_pct_pos': short_row['pct_pos'],
'short_vol': short_row['vol_24h_musd'],
'long_exchange': long_row['exchange'],
'long_symbol': long_row['symbol'],
'long_avg_fr': long_row['avg_fr_8h_pct'],
'long_pct_pos': long_row['pct_pos'],
'long_vol': long_row['vol_24h_musd'],
'rate_diff_8h_pct': rate_diff,
'net_8h_pct': net_8h,
'ann_net_pct': ann_net,
'round_trip_cost_pct': rt * 100,
})
x_df = pd.DataFrame(x_rows)
if len(x_df) == 0:
print("\nNo cross-exchange pairs found.")
else:
x_df = (x_df[
(x_df['rate_diff_8h_pct'] >= MIN_RATE_DIFF_8H) &
(x_df['short_vol'] >= MIN_X_VOL_MUSD) &
(x_df['long_vol'] >= MIN_X_VOL_MUSD)
]
.sort_values('ann_net_pct', ascending=False)
.reset_index(drop=True))
print(f"\nFilters: rate_diff >= {MIN_RATE_DIFF_8H}%, "
f"vol on both venues >= {MIN_X_VOL_MUSD}M")
print()
if len(x_df) == 0:
print("No cross-exchange pairs pass the filters.")
else:
cols = ['base', 'short_exchange', 'long_exchange',
'rate_diff_8h_pct', 'net_8h_pct', 'ann_net_pct',
'short_vol', 'long_vol']
print(x_df[cols].to_string(index=False))
# --- Section 4: Deep dive top cross-exchange pairs ---
section("4. TOP CROSS-EXCHANGE PAIRS - DEEP DIVE")
if len(x_df) > 0:
for _, r in x_df.head(MAX_SYMBOLS).iterrows():
print(f" {r['base']}")
print(f" SHORT {r['short_exchange']:<12} "
f"({r['short_symbol']}) avg {r['short_avg_fr']:.4f}%/8h "
f"vol ${r['short_vol']:.0f}M pos {r['short_pct_pos']:.1f}%")
print(f" LONG {r['long_exchange']:<12} "
f"({r['long_symbol']}) avg {r['long_avg_fr']:.4f}%/8h "
f"vol ${r['long_vol']:.0f}M pos {r['long_pct_pos']:.1f}%")
print(f" Rate diff: {r['rate_diff_8h_pct']:.4f}% per 8h")
print(f" Round-trip cost: {r['round_trip_cost_pct']:.4f}%")
print(f" Net per 8h: {r['net_8h_pct']:.4f}%")
print(f" Annualized net: {r['ann_net_pct']:.2f}%")
print()
else:
print(" No cross-exchange pairs to deep-dive.")
# --- Section 5: Allocation ---
section("5. SUGGESTED ALLOCATION")
capital_usd = CAPITAL_HKD * HKD_USD
deployable = capital_usd * DEPLOY_FRACTION
print(f" Capital: {CAPITAL_HKD:,} HKD (~{capital_usd:.0f} USD)")
print(f" Deploy fraction: {DEPLOY_FRACTION*100:.0f}%")
print(f" Deployable: {deployable:.0f} USD")
# Pick the best pool of opportunities
use_cross = len(x_df) >= 1
if use_cross:
picks = x_df.head(MAX_SYMBOLS)
per_symbol = deployable / len(picks)
print(f"\n Strategy: CROSS-EXCHANGE PERP-PERP")
print(f" Symbols: {len(picks)}")
print(f" Per symbol: {per_symbol:.0f} USD notional per leg")
print()
print(f" {'Base':<10}{'Short':<14}{'Long':<14}"
f"{'Notional':>12}{'Est. annual':>14}{'Est. monthly':>14}")
for _, r in picks.iterrows():
ann = per_symbol * r['ann_net_pct'] / 100
mon = ann / 12
print(f" {r['base']:<10}{r['short_exchange']:<14}{r['long_exchange']:<14}"
f"{per_symbol:>10.0f} $ {ann:>11.2f} $ {mon:>11.2f} $")
elif len(s) >= 1:
picks = s.head(MAX_SYMBOLS)
per_symbol = deployable / len(picks)
print(f"\n Strategy: SINGLE-EXCHANGE PERP-SPOT")
print(f" Symbols: {len(picks)}")
print(f" Per symbol: {per_symbol:.0f} USD notional per leg")
print()
print(f" {'Exchange':<14}{'Symbol':<20}"
f"{'Notional':>12}{'Est. annual':>14}{'Est. monthly':>14}")
for _, r in picks.iterrows():
ann = per_symbol * r['ann_net_pct'] / 100
mon = ann / 12
print(f" {r['exchange']:<14}{r['symbol']:<20}"
f"{per_symbol:>10.0f} $ {ann:>11.2f} $ {mon:>11.2f} $")
else:
print("\n No viable opportunities to allocate.")
# --- Section 6: Verdict ---
section("6. VERDICT")
verdict = 'NOT_VIABLE'
if len(x_df) >= 1:
print(f" STATUS: VIABLE (cross-exchange)")
print(f" {len(x_df)} perp-perp pair(s) pass filters.")
print(f" Top picks: " + ", ".join(
f"{r['base']} ({r['short_exchange']} vs {r['long_exchange']})"
for _, r in x_df.head(3).iterrows()))
verdict = 'VIABLE_CROSS'
elif len(s) >= 1:
print(f" STATUS: VIABLE (single-exchange)")
print(f" {len(s)} perp-spot symbol(s) pass filters.")
print(f" Top picks: " + ", ".join(
f"{r['symbol']}@{r['exchange']}" for _, r in s.head(3).iterrows()))
verdict = 'VIABLE_SINGLE'
else:
print(" STATUS: NOT VIABLE")
print(" No opportunity passes thresholds on any exchange.")
print(" Wait for higher funding regimes or lower fee assumptions.")
# --- Section 7: JSON summary ---
if args.json:
section("7. JSON SUMMARY")
summary = {
'scan_file': str(CSV),
'generated': datetime.now().isoformat(),
'exchanges': sorted(df['exchange'].unique().tolist()),
'total_rows': int(len(df)),
'single_shortlist': s.to_dict(orient='records') if len(s) > 0 else [],
'cross_opportunities': x_df.to_dict(orient='records') if len(x_df) > 0 else [],
'capital_hkd': CAPITAL_HKD,
'deployable_usd': round(deployable, 2),
'verdict': verdict,
}
print(json.dumps(summary, indent=2, default=str))
print()
hr('#', 78)
print("# END OF REPORT")
hr('#', 78)
PYEOF
Step 4: Run the full pipeline
One command, no script changes:
python scripts/scan_funding.py && python scripts/analyze_scan_multi.py "$(ls -t data/scan_multi_*.csv | head -1)"
With JSON summary:
python scripts/scan_funding.py && python scripts/analyze_scan_multi.py "$(ls -t data/scan_multi_*.csv | head -1)" --json
What each section of the report tells you
| Section | Content |
|---|---|
| 1. Overview | Total rows, exchanges covered, unique bases, per-exchange counts |
| 2. Single-exchange shortlist | Perp-spot candidates ranked by annualized net carry after spot+perp fees |
| 3. Cross-exchange opportunities | Perp-perp pairs where the funding differential exceeds fees |
| 4. Deep dive | Per-pair breakdown: both venues, rate diff, round-trip cost, net carry |
| 5. Allocation | Concrete notional per symbol for 10k HKD with estimated annual and monthly returns |
| 6. Verdict | Clear VIABLE_CROSS / VIABLE_SINGLE / NOT_VIABLE with top picks |
| 7. JSON | Machine-readable summary for downstream pipelines |
How the parallel fetching works
The scanner uses ThreadPoolExecutor to load all five exchanges and fetch their funding rates + tickers simultaneously. Per CCXT's documentation, rate limits are enforced per exchange instance, so parallel requests across different venues do not interfere. Deep-dive history fetches run sequentially to keep per-exchange rate limits comfortable.
Wall time for a full scan





