Funding Carry Is a First-Class Citizen: Backtesting Perp Basis Without Hacks
Jonny Bravo
-Funding Carry Is a First-Class Citizen: Backtesting Perp Basis Without Hacks
Here's a backtest that lies to you: a delta-neutral perp strategy — long one venue, short another — that shows a smooth, low-volatility equity curve grinding upward. Beautiful. Allocatable. Completely fake.
It's fake because the largest cash flow in that strategy never appears in the simulation. On crypto perpetual futures, funding is the basis. It's the periodic payment between longs and shorts that tethers the perp to spot. For a carry or basis trade, funding is the P&L — and most backtesting frameworks treat it as someone else's problem.
The result is an entire category of strategies — funding-rate arbitrage, cash-and-carry, delta-neutral yield — that you cannot honestly research in a tool that bolts funding on as an afterthought. So we didn't bolt it on. Funding is a first-class primitive in the engine, accrued every bar, on the right notional, prorated to the right schedule.
What "bolted on" gets wrong
The naive way to add funding to a backtest is to compute total funding paid at the end and subtract it from the final P&L. This is wrong in three compounding ways:
- Wrong notional. Your position's notional drifts as the mark moves. Charging funding on the entry notional for the whole life of a position over- or under-charges every single bar after the first.
- Wrong schedule. Binance funds every 8 hours, some venues every 1 hour, others every 4. If you assume a single global interval, every instrument on a different schedule is mispriced.
- Wrong timing. Funding compounds with your equity curve. Subtracting a lump sum at the end ignores that the carry you earned in month one was itself earning (or financing) for months two through twelve.
A strategy whose entire thesis is harvesting funding cannot be validated by a tool that gets funding's notional, schedule, and timing all wrong.
How the engine accrues carry
Inside the bar loop, right after fills execute and before mark-to-market, the engine runs accrue_carry. Here's the core of it:
def accrue_carry(ts): if not strategy.uses_funding() or not bundle.funding: return for iid, pos in positions.items(): s = bundle.funding.get(iid) if s is None or ts not in s.index: skipped_funding += 1 continue rate = s.loc[ts] if pd.isna(rate): skipped_funding += 1 continue # prorate by THIS instrument's own funding interval (inferred per venue) fi = funding_interval.get(iid) or cfg.funding_interval_seconds prorate = bar_seconds / float(fi) sign = 1 if pos.side == "long" else -1 # charge funding on the CURRENT mark notional, not the entry notional bars_f = bundle.bars.get(iid) if bars_f is not None and ts in bars_f.index: notional = abs(pos.size_base * float(bars_f.loc[ts, "close"])) else: notional = abs(pos.size_usd) carry = -sign * notional * float(rate) * prorate realized_cum += carry cash += carry
Every design decision above maps directly to one of the failure modes:
-
Per-venue proration.
prorate = bar_seconds / fi, wherefiis this instrument's funding interval. A 1-hour bar against an 8-hour funding feed charges 1/8 of the rate each bar; against a 1-hour feed it charges the full rate. A mixed universe — Binance 8h and a 1h-funding venue side by side — is each charged on its own clock, automatically. -
Mark-notional, not entry-notional. Notice the comment: charge funding on the CURRENT mark notional, not the entry notional.
size_usdis frozen at the moment you opened. Funding isn't — it's paid on what your position is worth now. So the engine recomputes notional fromsize_base × this bar's closeevery accrual. As your winner runs, you finance a bigger position; as a loser shrinks, you finance less. That's how a real exchange charges you. -
Correct sign. Longs pay funding when the rate is positive and receive it when negative; shorts are the mirror.
carry = -sign * notional * rate * prorategets this right in one line, and it's the line that turns a funding-arb thesis into real, compounding cash in the equity curve.
Because carry lands in realized_cum and cash every bar, it compounds with the rest of your P&L exactly as it would live. No end-of-run lump sum. No fiction.
Opt-in, and honest about gaps
Funding only accrues if your strategy actually asks for it — declared via a Funding data requirement:
@dataclass(frozen=True) class Funding(DataRequest): """Funding-rate series; drives the optional carry/PnL component.""" required: bool = False
def uses_funding(self): return any(isinstance(r, Funding) for r in self.data_requirements())
A pure price-momentum strategy doesn't pay a phantom carry tax. A basis strategy declares Funding(...) and gets the full treatment. And when a funding print is missing or NaN, the engine doesn't guess — it increments skipped_funding and surfaces the count in the data-quality report, so you know precisely how much of your window had clean funding coverage versus how much was patched over. (Spoiler: a carry strategy with 30% missing funding is a strategy you shouldn't trust, and now you'll know.)
A real shape: cross-venue funding arbitrage
Put the pieces together and a funding-arb strategy is a few lines. Go long the venue paying you to hold, short the venue charging the other side, size both legs equally so you're delta-neutral, and let the carry accrue:
class Strategy(BaseStrategy): class Params(SingleParams): symbols: list = ["BINANCEUSDM:BTCUSDT", "HYPERLIQUID:BTCUSDT"] def data_requirements(self): return [Bars("ohlcv", interval=3600), Funding("funding", required=True)] def on_bar(self): a, b = self.symbols() fa, fb = self.funding(a), self.funding(b) if fa is None or fb is None: return # stale funding → hold, no blind bets # finance the leg that pays, collect on the leg that charges if fa - fb > 0: # a's longs pay more → short a, long b self.target(a, weight=-0.5) self.target(b, weight=0.5) else: self.target(a, weight=0.5) self.target(b, weight=-0.5)
The equity curve this produces includes the funding leg of every bar, on live notional, prorated per venue, compounding through the window. It's the first time most people see their carry strategy's real shape — and it's the shape that survives contact with production, because production charged funding the same way the engine just did.
Why it matters to whoever's writing the check
Funding-rate strategies are some of the most attractive in crypto: market-neutral, capacity-rich, uncorrelated to directional beta. They're also the easiest to fake in a backtest, which means they're the hardest to raise capital for. "Trust me, the carry works out" is not a pitch.
When your research platform models funding as a real, per-bar, per-venue, mark-notional cash flow, you can hand an allocator a backtest where the carry is in the curve — not in a footnote. That's the difference between a story about yield and evidence of it.
Running a basis or delta-neutral book? Declare a Funding requirement, point it at two venues, and watch the carry accrue bar by bar. Open the strategy editor →