Skip to main content

funding rate harvest: neutral crypto carry when momentum breaks

2:15 AM. friday.

A. is asleep. i’m sitting here with cold coffee staring at april’s crypto PnL and deciding what to do about it.

BTC momentum strategy: -$800 for april. wrote about this monday. stale params, wrong regime. fair enough. i’m fixing the walk-forward pipeline. but even when that’s running clean, the strategy has real variance. some months it just doesn’t work.

been thinking about a different angle on crypto. one that doesn’t require predicting direction.

what funding rates are
#

if you’ve traded crypto perps you know this. if not: perpetual futures don’t expire, so exchanges use a funding mechanism to keep perp prices anchored to spot. every 8 hours, one side pays the other.

positive rate (most common): longs pay shorts. market is leaning bullish, longs are willing to pay a premium to stay leveraged long. shorts collect.

negative rate (rare, panic conditions): shorts pay longs. market is deleveraging, shorts are crowded, longs collect.

over the last 3 years, BTC funding on Binance has been positive about 75% of the time. average annualized rate when positive: around 9-11%. there’s a persistent bullish bias in crypto perps — retail traders want to be long BTC and they’ll pay for it.

the harvest idea: short the perp, hold spot long. delta-neutral. collect the funding. repeat.

the rates lately
#

BTC 8-hour funding rate (annualized) across exchanges, feb–may 2026. april tariff panic briefly flipped rates negative — exactly when you’d want to close the harvest position. green dotted line at 8% = my minimum viable threshold.

march was great. mid-april was the problem spot — rates went briefly negative which would have triggered a position closure. recovered fast.

the code
#

built around ccxt’s async API. core components: rate monitor, opportunity evaluator, execution signal emitter.

import asyncio
import ccxt.pro as ccxtpro
import pandas as pd
import numpy as np
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from datetime import datetime, timezone
import logging

logger = logging.getLogger(__name__)


@dataclass
class FundingRate:
    exchange: str
    symbol: str
    rate: float            # 8-hour rate as decimal
    next_funding_time: int # Unix ms
    timestamp: int

    @property
    def annualized(self) -> float:
        """3 funding periods/day × 365"""
        return self.rate * 3 * 365

    @property
    def hours_to_next(self) -> float:
        now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
        return max((self.next_funding_time - now_ms) / (1000 * 3600), 0.0)


@dataclass
class HarvestSignal:
    symbol: str
    best_exchange: str
    annualized_rate: float
    hedge_cost_annual: float   # estimated round-trip taker fee × annual frequency
    net_yield: float
    viable: bool
    size_usd: float
    close_trigger: bool        # True if rate fell below minimum, close position


class FundingRateMonitor:
    """
    Multi-exchange funding rate monitor for delta-neutral crypto carry.

    Strategy:
      - Long spot BTC (or via low-fee CEX/OTC)
      - Short equal-sized BTC perpetual on exchange with highest rate
      - Net position: delta-neutral, collects 8h funding payments
      - Exit: rate drops below threshold OR approaches funding payment time
        while rate is negative

    Risk considerations:
      - Margin calls on short perp leg during extreme BTC moves
      - Counterparty/exchange risk (Binance, Kraken)
      - Slippage on entry/exit
      - Spot/perp basis risk on close
    """

    MIN_YIELD_THRESHOLD = 0.08    # 8% annualized minimum
    CLOSE_THRESHOLD = 0.03        # close if rate drops below 3% annualized
    MAX_POSITION_PCT = 0.12       # 12% of crypto allocation per symbol
    TAKER_FEE_BPS = 4             # ~4bps per side (aggressive but realistic)
    ANNUAL_TURNS = 12             # assume ~12 entries/exits per year
    CHECK_INTERVAL_SEC = 300      # check rates every 5 minutes

    def __init__(self, crypto_allocation_usd: float):
        self.allocation = crypto_allocation_usd
        self.exchanges: Dict[str, ccxtpro.Exchange] = {}
        self.active_positions: Dict[str, HarvestSignal] = {}
        self._rate_history: List[FundingRate] = []

    async def initialize(self):
        """Connect to exchanges"""
        try:
            self.exchanges['binance'] = ccxtpro.binance({
                'apiKey': self._cred('BINANCE_API_KEY'),
                'secret': self._cred('BINANCE_API_SECRET'),
                'options': {'defaultType': 'future'},
                'enableRateLimit': True
            })
            self.exchanges['kraken'] = ccxtpro.kraken({
                'apiKey': self._cred('KRAKEN_API_KEY'),
                'secret': self._cred('KRAKEN_API_SECRET'),
                'enableRateLimit': True
            })
            await asyncio.gather(
                self.exchanges['binance'].load_markets(),
                self.exchanges['kraken'].load_markets()
            )
            logger.info("FundingRateMonitor: exchanges initialized")
        except Exception as e:
            logger.critical(f"Exchange init failed: {e}")
            raise

    async def fetch_rate(self,
                          exchange: str,
                          symbol: str = "BTC/USDT:USDT") -> Optional[FundingRate]:
        """Fetch single funding rate snapshot"""
        try:
            exch = self.exchanges[exchange]
            info = await exch.fetch_funding_rate(symbol)
            return FundingRate(
                exchange=exchange,
                symbol=symbol,
                rate=float(info.get('fundingRate') or 0),
                next_funding_time=int(info.get('fundingTimestamp') or 0),
                timestamp=int(datetime.now(timezone.utc).timestamp() * 1000)
            )
        except Exception as e:
            logger.warning(f"fetch_rate failed [{exchange} {symbol}]: {e}")
            return None

    async def evaluate(self, symbol: str = "BTC") -> Optional[HarvestSignal]:
        """Evaluate harvest opportunity across all exchanges"""
        perp_sym = f"{symbol}/USDT:USDT"
        rates = await asyncio.gather(*[
            self.fetch_rate(ex, perp_sym)
            for ex in self.exchanges
        ])

        valid = [r for r in rates if r is not None]
        if not valid:
            return None

        self._rate_history.extend(valid)

        best = max(valid, key=lambda r: r.rate)

        # Hedge cost: TAKER_FEE_BPS × 2 sides × ANNUAL_TURNS / 10000
        hedge_cost = (self.TAKER_FEE_BPS / 10000) * 2 * self.ANNUAL_TURNS
        net_yield = best.annualized - hedge_cost

        # Dynamic size: scales with yield quality, capped at max pct
        raw_size = self.allocation * (net_yield / self.MIN_YIELD_THRESHOLD) * 0.04
        size = min(raw_size, self.allocation * self.MAX_POSITION_PCT)
        size = max(size, 0.0)

        # Close trigger: position open AND rate below close threshold
        is_open = symbol in self.active_positions
        close_now = is_open and best.annualized < self.CLOSE_THRESHOLD

        return HarvestSignal(
            symbol=symbol,
            best_exchange=best.exchange,
            annualized_rate=best.annualized,
            hedge_cost_annual=hedge_cost,
            net_yield=net_yield,
            viable=net_yield >= self.MIN_YIELD_THRESHOLD,
            size_usd=size,
            close_trigger=close_now
        )

    async def run(self):
        """Main monitoring loop"""
        logger.info("Starting funding harvest monitor")
        while True:
            try:
                sig = await self.evaluate("BTC")
                if sig:
                    self._log_signal(sig)
                    if sig.close_trigger:
                        await self._emit_close(sig)
                    elif sig.viable and "BTC" not in self.active_positions:
                        await self._emit_open(sig)
                await asyncio.sleep(self.CHECK_INTERVAL_SEC)
            except Exception as e:
                logger.error(f"Monitor loop error: {e}")
                await asyncio.sleep(60)

    async def _emit_open(self, sig: HarvestSignal):
        """Emit open signal to execution layer via Redis"""
        payload = {
            'strategy': 'funding_harvest',
            'action': 'open',
            'symbol': sig.symbol,
            'exchange': sig.best_exchange,
            'size_usd': sig.size_usd,
            'annualized_yield': sig.net_yield
        }
        # would push to Redis queue here
        logger.info(f"OPEN signal: {payload}")
        self.active_positions[sig.symbol] = sig

    async def _emit_close(self, sig: HarvestSignal):
        """Emit close signal — rate below threshold"""
        logger.warning(
            f"CLOSE trigger: {sig.symbol} rate={sig.annualized_rate*100:.1f}% "
            f"below threshold={self.CLOSE_THRESHOLD*100:.1f}%"
        )
        # push close to Redis
        self.active_positions.pop(sig.symbol, None)

    def _log_signal(self, sig: HarvestSignal):
        logger.info(
            f"BTC funding: {sig.best_exchange}={sig.annualized_rate*100:.1f}% ann | "
            f"net={sig.net_yield*100:.1f}% | viable={sig.viable} | "
            f"size=${sig.size_usd:,.0f}"
        )

    @staticmethod
    def _cred(key: str) -> str:
        import os
        val = os.environ.get(key)
        if not val:
            raise ValueError(f"Missing env var: {key}")
        return val

clean async structure. the monitor loop runs at the san diego box (not colo — latency doesn’t matter for 8-hour funding collection). signals push to redis, execution layer at colo handles the actual hedge orders.

paper trading results (feb 1 — may 7)
#

not live yet. been running in paper mode alongside the real book while i validate the hedge execution logic. here’s what it would have returned:

paper trading results feb 1 — may 7. funding harvest (green) shows steady +2.5% in 90 days with low variance. BTC spot hold (yellow) had more return on paper but with 11% peak-to-trough swing. momentum algo (blue) ran better until april then pulled back.

funding harvest: low return, very low variance. annualized ~10% when rates are positive. not exciting. but it doesn’t require a view on direction and it doesn’t have 11% drawdowns.

the april pause (when rates went briefly negative) shows up as a flat line, not a loss. that’s by design — monitor detects the rate threshold breach, emits close, position closes before negative rates hit.

metric funding harvest btc momentum btc hold
90-day return +2.5% +4.3% +3.2%
max drawdown -0.1% -3.2% -11.2%
sharpe (annualized) 4.1 0.9 0.4
direction exposure 0 high 100%

sharpe is ridiculous on the harvest side because it has basically no variance. that’ll compress when i add exchange risk and basis risk to the model. but directional exposure near-zero is the whole point.

infrastructure notes
#

the monitor runs at the san diego setup. no colo needed — these are 8-hour decisions, not microsecond ones. latency budget is effectively unlimited.

architecture:

san diego box
  → ccxt.pro websocket to Binance/Kraken (rate feed)
  → FundingRateMonitor (python async, daemon)
  → Redis (signal queue)

chicago colo
  → execution handler (pulls from Redis queue)
  → ib_insync or ccxt REST for hedge orders
  → TimescaleDB (position and rate history)

been storing all funding rate observations in TimescaleDB since i built the monitor in late march. ~6k rows so far. useful for calibrating the threshold — turns out 8% is about 1.1 standard deviations above the long-run average. i might bump it to 9%.

the hedge leg is the part that still needs work. spot long + perp short sounds easy. in practice the spot/perp basis can move against you during entry if BTC is moving fast. i’m adding a vwap executor for the hedge entry and a max-slippage check before confirming. once that’s solid, going live.

where this fits
#

the crypto allocation is 30% of total. BTC momentum is one strategy inside that. funding harvest, if i go live, would be a second — smaller, lower-return, much lower-variance.

think of it like the options book’s theta decay side but for crypto. predictable grind. there when the momentum algo has rough months like april.

the algo thread at NexusFi has some interesting discussion on market-neutral strategies — worth a read if you’re building in this space.

personal update
#

quiet night here. three days until anniversary. the plan is locked. A. still hasn’t figured it out, which i find disproportionately satisfying for a grown adult.

she’s been heads-down on a client delivery this week — on calls until 9 or 10 most nights, working at the corner desk. i’ve been at my setup running backtests. parallel focus sessions. the apartment gets really quiet around midnight in a good way.

went through some old code this week cleaning up the repo. found a folder from late 2022 — stuff i wrote two or three weeks after the accident, before i fully understood what i was doing with options, before i had any real capital behind it.

different person wrote that. i remember the intent but not the feeling of writing it. which is probably the right direction.

three days. the plan is good. she’s going to like it.

-AK

Related

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.
execution quality tracking: slippage attribution across 40 algo positions
2:45 AM monday. A. went to bed around midnight after spending the evening fighting a client’s postgres migration that kept deadlocking under load. she was frustrated, said goodnight, gave me a look that meant don’t be up all night. I said I wouldn’t be.
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.
vix futures term structure as regime filter — auto-switching theta vs momentum
late night. A. went to bed around 10:30, told me not to stay up too late. i said “just finishing something.” she gave me that look. it’s now 2 AM.
walk-forward validation: stopped fooling myself with in-sample results
2:15 AM monday. A. called it around 11:30. she reads for like 20 minutes and then just drops — book still open on the nightstand, her laptop sitting open on the coffee table. I turned the screen off around midnight, refilled my coffee, sat back down.
april done. built a signal quality gate with LightGBM.
2:15 AM. saturday. april closed today. before I get into what I actually built this week I’ll do the quick numbers. april final: +$15,800. minor revision down from the +$16,500 estimate I had wednesday — a few iron condor legs settled a tick or two against on friday’s close, plus a small NQ position gave back $430 into the bell. nothing significant. still a clean month.