Profiting from Perpetuals: Implementing a Funding Rate Arbitrage Strategy with Backtesting

6 min read Original article ↗

DolphinDB

Press enter or click to view image in full size

Introduction

In traditional financial markets, arbitrage opportunities are rare, fleeting, and fiercely competed over. Cryptocurrency markets are different. One structural feature unique to crypto — the funding rate mechanism of perpetual contracts — creates a recurring, systematic opportunity that sophisticated traders have long exploited: funding rate arbitrage.

Unlike futures contracts with fixed expiry dates, perpetual contracts stay open indefinitely. To keep their prices anchored to the spot market, exchanges periodically charge funding fees between long and short holders. When the market is overwhelmingly bullish, longs pay shorts. When bearish sentiment dominates, shorts pay longs. A trader who can simultaneously hold an opposing spot position stands to collect these fees while remaining largely neutral to price direction.

This post walks through a complete implementation of a funding rate arbitrage strategy — from logic design to backtesting — using DolphinDB’s cryptocurrency backtesting engine and minute-level market data.

Strategy Logic

The core idea is straightforward: earn funding fees by taking a position in the perpetual contract market, while hedging price risk with an equivalent spot position. The result is a portfolio whose value is largely insensitive to price movements — profits come from the funding rate itself.

Short the contract (positive funding rate): When the funding rate exceeds +0.03%, the market is in a bullish state and long holders pay funding fees to shorts. The strategy responds by:

  • Shorting the perpetual contract
  • Buying an equivalent amount of the asset in the spot market

When the funding rate turns negative, signaling a sentiment shift, both positions are closed.

Long the contract (negative funding rate): When the funding rate falls below −0.03%, the market is bearish and short holders pay longs. The strategy responds by:

  • Going long on the perpetual contract
  • Selling an equivalent amount of the asset in the spot market

When the funding rate turns positive again, both positions are unwound.

Note: The implementation below covers the short-side strategy (positive funding rate scenario).

Strategy Implementation

Initialization

The initialize callback runs once when the engine is created. It loads the funding rate data for the backtest period and initializes a dictionary to track the previous funding rate — used later to detect trend reversals and determine exit timing.

def initialize(mutable context){
// Initialization
print("initialize")
// Backtest::setUniverse(context["engine"], context.Universe)
context["fundingRate"] = Backtest::getConfig(context["engine"])[`fundingRate]
context["lastlastFunding"] = dict(SYMBOL,ANY) // Stores the previous funding rate, used to determine exit conditions
}

Pre-Market Setup

The beforeTrading callback runs once per trading day before market open. It organizes the day's funding rate data into a nested dictionary keyed by symbol and settlement time, making lookups fast and straightforward within the bar callback.

def beforeTrading(mutable context){
// Daily pre-market callback function
// The current trading date can be obtained via context["tradeDate"]
print("beforeTrading: " + context["tradeDate"])
// Retrieve the funding rate table for the current day and store it as a dictionary keyed by symbol
fundingRate = context["fundingRate"]
d = dict(STRING,ANY)
for (i in distinct(fundingRate.symbol)){
temp = select * from fundingRate where symbol = i and date(settlementTime) = context["tradeDate"]
replaceColumn!(temp, `settlementTime, datetime(exec settlementTime from temp))
d[i] = dict(temp.settlementTime, temp.lastFundingRate, true)
}
context["dailyLastFundingPrice"] = d
}

Bar-Level Signal Logic

The onBar callback fires on every incoming minute bar. For each asset, it first resolves the applicable funding rate for the current time window — funding settlements occur at 00:00, 08:00, and 16:00 UTC — then checks current spot and futures positions before evaluating entry or exit conditions.

def onBar(mutable context, msg, indicator = NULL){
// ...
dailyFundingRate = context["dailyLastFundingPrice"]
// Iterate over multiple assets
for(i in msg.keys()){
istock = split(i,"_")[0]
istockFut = istock + "_futures"
istockSpo = istock + "_spot"
source = msg[i]["symbolSource"]
closePrice = msg[i]["close"]
// Current asset funding rate; select the corresponding funding rate based on the current time window
if(second(context["tradeTime"]) >= 16:00:00){fundingRateTime = temporalAdd(datetime(context["tradeDate"]), 16, "h")}
if(second(context["tradeTime"]) >= 08:00:00 and second(context["tradeTime"]) < 16:00:00){fundingRateTime = temporalAdd(datetime(context["tradeDate"]), 8, "h")}
if(second(context["tradeTime"]) < 08:00:00){fundingRateTime = datetime(context["tradeDate"])}
lastFundingPrice = dailyFundingRate[istockFut][fundingRateTime]
// print(fundingRateTime)
// Position status: spot and futures
spotPos = Backtest::getPosition(context["engine"], istockSpo, "spot")
futurePos = Backtest::getPosition(context["engine"], istockFut, "futures")
// ...
}
}

Running the Backtest

Once the funding rate and position checks pass, orders are submitted simultaneously for both the spot and futures legs via Backtest::submitOrder.

// When the funding rate is greater than 0.03%, short the perpetual contract and buy an equivalent amount of spot
// Here the maximum order quantity is set to 0.1. To allow short-to-open, set 0.1 to 0.
if(spotPos.longPosition[0] <= 0.1 and futurePos.shortPosition[0] < 0.1 and lastFundingPrice > 0.0003){
// Spot
if(i == istockSpo){
Backtest::submitOrder(context["engine"], (i, source, context["tradeTime"], 5, closePrice,
lowerLimit, upperLimit, qty, 1, slippage, 1, expireTime ), "buyopen_spot", 0, "spot")
}
// Contact
if(i == istockFut){
Backtest::submitOrder(context["engine"], (i, source, context["tradeTime"], 5, closePrice,
lowerLimit, upperLimit, qty, 2, slippage, 1, expireTime ), "sellopen_contract", 0, "futures")
}
context["lastlastFunding"][istockFut] = lastFundingPrice
}

Strategy execution:

strategyName = "cryptocurrencyStrategy"  // The code name must match the module name when storing
eventCallbacks = {
"initialize": initialize,
"beforeTrading": beforeTrading,
"onBar": onBar
}
strategyType = 0 // Backtest — default is 10 days; start and end dates can be customized via userConfig
engine, straname_ = CryptocurrencySolution::manageScripts::runCryptoAndUploadToGit(strategyName, eventCallbacks, strategyType)

The userConfig parameter is set via the getUnifiedConfig() function in the CryptocurrencySolution::utils module by default. To run over a custom date range, pass a userConfig dictionary:

userConfig = dict(STRING,ANY)
userConfig["startDate"] = 2025.12.01
userConfig["endDate"] = 2025.12.05
engine,straname_= CryptocurrencySolution::manageScripts::runCryptoAndUploadToGit(strategyName, eventCallbacks,strategyType,userConfig)

The full strategy script is provided in the Appendix.

Conclusion

Funding rate arbitrage is one of the more accessible market-neutral strategies available in crypto — but implementing it correctly requires careful attention to settlement timing, position hedging, and entry/exit logic. By building the strategy within a structured backtesting framework, you can validate the logic rigorously against historical minute-level data before committing any capital.

The implementation shown here is intentionally modular: the same callback structure — initialize, beforeTrading, onBar — carries directly into simulation and live trading modes with minimal changes. Once you're satisfied with backtest results, moving to live execution is a matter of switching the strategy type, not rewriting the strategy.

From here, natural extensions include adding the long-side (negative funding rate) logic, incorporating transaction costs and slippage modeling, or running multi-asset versions of the strategy across a broader universe of perpetual contracts.

Thanks for your reading! To keep up with our latest news, please follow our Twitter @DolphinDB_Inc and Linkedin. You can also join our Slack to chat with the author! 😉