Funding Carry Is a First-Class Citizen: Backtesting Perp Basis Without Hacks

The Fluxy Team
-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, settled on each venue's own clock, on the right notional, automatically for any held perp.
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): for iid, pos in positions.items(): s = bundle.funding.get(iid) # auto-loaded for any held perp 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 # DISCRETE settlement on the venue's own clock: funding_accrual_arr # holds the rates that actually SETTLE inside this bar. No smearing a # rate across every bar — the payment interval is respected by WHEN # settlements occur, so a position opened and closed between two # settlements pays nothing. (Proration by bar_seconds/interval # survives only as the fallback for feeds with no settlement stamps.) 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_settling_this_bar) realized_cum += carry cash += carry
Every design decision above maps directly to one of the failure modes:
-
Settlement-clock accrual. Funding is charged when the venue actually settles it, at the venue's own timestamps aligned onto the bar clock. A Binance 8h feed settles three times a day; a 1h-funding venue settles hourly; a mixed universe is each charged on its own clock, automatically — and a position that's flat at every settlement pays nothing, exactly as it would live.
-
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 * rateat each settlement gets 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.
On by default, and honest about gaps
Funding is asset-driven, not opt-in. Hold a perp and the engine auto-loads its funding feed and accrues the carry (auto_funding=True is the default), whether or not your strategy ever thought about funding — because the venue will charge you whether or not you thought about it. Spot instruments simply have no funding feed and are never charged. A strategy that depends on funding can still declare it explicitly:
@dataclass(frozen=True) class Funding(DataRequest): """Funding-rate series; declare required=True to make gaps a hard failure.""" required: bool = False
A price-momentum strategy on perps pays the same carry it would pay live — no flattering omission. A basis strategy declares Funding(..., required=True) and turns missing coverage into a refusal instead of a footnote. 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 every funding settlement, on live notional, on each venue's own clock, 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 →
Related reading