Best Practices for Strategy Backtesting in Cryptocurrency Markets with DolphinDB
Backtesting cryptocurrency trading strategies is often challenging due to the market's high volatility, complexity, and a lack of mature tools. Many existing tools suffer from low performance and intricate implementation processes. DolphinDB addresses these issues by providing a powerful backtesting plugin that integrates distributed storage and computing, a multi-paradigm programming language, and a matching engine. It enables fast and efficient development and evaluation of crypto strategies.
The plugin supports multi-instrument trading, including spot, futures, perpetual contracts, and options, across various markets. This document introduces the usage of the DolphinDB backtesting plugin for cryptocurrencies and demonstrates practical case studies. All examples are based on tests performed with DolphinDB Server version 3.00.2.
1. Instructions
DolphinDB’s backtesting plugin supports various types of cryptocurrency market data, including snapshots, minute-level, and daily. This section explains how to backtest cryptocurrency strategies in DolphinDB, focusing on engine configuration, market data, and relevant interfaces.
1.1 Configurations
When creating a backtesting engine, you need to specify the engine parameters
using the config parameter in the createBacktestEngine
interface. The config parameter is a dictionary in which you can define
various key-value pairs, such as the start and end dates, initial account
balance, commission, market data type, and order matching mode, as outlined in
Table 1-1.
First, the strategyGroup parameter must be set to "cryptocurrency" to conduct backtesting for cryptocurrency strategies. Unlike other asset classes, cryptocurrency backtesting supports simultaneous trading of spot, futures, and options instruments. Corresponding management accounts are created for each type, and you can specify their initial cash flow using the cash parameter.
In addition, the plugin supports trading of perpetual futures. You can configure the funding rate table for such contracts using the fundingRate parameter. See Table 1-2 for details.
Table 1-1: User Configurations
key | Description | Note |
---|---|---|
startDate | start date | DATE type, required (e.g., “2020.01.01”). |
endDate | end date | DATE type, required (e.g., “2020.01.01”). |
strategyGroup | strategy group | Must be "cryptocurrency" |
cash | initial cash flow (of dictionary form) for each account | DOUBLE type, required.
|
commission | commission | DOUBLE type, required. |
dataType | the type of market data: 1, 3, 4 |
|
msgAsTable | process the input market data as table or dictionary |
|
matchingMode | matching mode: 1,2, 3 | daily data:
|
orderBookMatchingRatio | the proportion of an order that gets filled | DOUBLE type, default is 1.0, valid range: 0 to 1.0. |
matchingRatio | matching ratio within price intervals | DOUBLE type, valid range: 0 to 1.0. By default, it is equal to the orderBookMatchingRatio. |
latency | the latency from order submission to execution. | DOUBLE type, in milliseconds. |
fundingRate | funding rate | Accepts a table. For detailed schema, see Table 1-2. |
benchmark | benchmark instrument | STRING or SYMBOL type, used in
getReturnSummary (e.g.,
“BTCUSDT_0”) |
isBacktestMode | backtesting mode |
|
enableIndicatorOptimize | enable indicator calculation optimization |
|
dataRetentionWindow | data retention policy for indicator optimization | STRING/INT type. Effective only when both
enableIndicatorOptimize = true and
isBacktestMode = true:
|
addTimeColumnInIndicator | add time column to indicator subscription results |
|
context | strategy context structure | A dictionary consisting of global strategy
variables, for example:context=dict(STRING,ANY)
context["buySignalRSI"]=70. context["buySignalRSI"]=30.
config["context"]=context |
Table 1-2 Schema of Configuration fundingRate
Field | Data Type | Description |
---|---|---|
symbol | STRING or SYMBOL | contract |
settlementTime | TIMESTAMP | settlement time |
lastFundingRate | DECIMAL128(8) | funding rate at settlement |
Since cryptocurrency backtesting supports trading of futures and options
contracts, the securityReference parameter must be specified when
creating a backtest engine using
Backtest::createBacktestEngine
. This parameter provides the
contract metadata required for simulation (see Table 1-3).
You are responsible for preparing a reference table containing the basic information of all contracts involved in the backtest. The table must conform to the required structure to be used as securityReference.
For code examples, refer to Section 2.2.3.
Table 1-3 Schema of Configuration securityReference
Field | Data Type | Description |
---|---|---|
symbol | SYMBOL or STRING | symbol |
contractType | INT | contract type:
|
optType | INT | Options type:
|
strikePrice | DECIMAL128(8) | strike price |
contractSize | DECIMAL128(8) | contract multiplier |
marginRatio | DECIMAL128(8) | margin ratio |
tradeUnit | DECIMAL128(8) | trade unit |
priceUnit | DECIMAL128(8) | price unit |
priceTick | DECIMAL128(8) | price tick |
takerRate | DECIMAL128(8) | taker fee rate |
makerRate | DECIMAL128(8) | maker fee rate |
deliveryCommissionMode | INT | Specifies how the transaction fee is calculated
when a trade is executed:
|
fundingSettlementMode | INT | Defines how the funding fee is settled between
long and short positions for perpetual futures:
|
lastTradeTime | TIMESTAMP | Last trade time |
Note:
- The calculation of trading costs depends on parameters configured in Table 1-3 for each contract, such as margin ratio, fee rates, price units, etc. Since different contract may have different trading rules and fee standards, the required parameters must be configured individually for each contract.
- For perpetual futures (contractType = 2), funding fees must be settled periodically while holding a position. The settlement method is defined by the fundingSettlementMode field, while the actual funding rate (e.g., lastFundingRate) should be retrieved from a separately configured funding rate table for perpetual futures.
1.2 Market Data
The structure of cryptocurrency market data slightly varies with data frequency. When using the backtesting engine, you must ensure that the input data strictly conforms to the required field names, field order, and data types. Failure to do so may result in backtest errors. If the existing data does not meet these requirements, you should perform data transformation during the import stage.
Currently, the backtesting engine supports three types of market data: snapshot, minute-level, and daily. This example uses minute-level data (dataType = 3), with its structure shown in Table 1-4.
Table 1-4 Schema of minute-level data
Field | Data Type | Description |
---|---|---|
symbol | SYMBOL | symbol |
symbolSource | SYMBOL | exchange |
tradeTime | TIMESTAMP | timestamp |
tradingDay | DATE | Trading Day / Settlement Date(Used to determine backtest start/end dates and trading day switches) |
open | DECIMAL128(8) | opening price |
low | DECIMAL128(8) | lowest price |
high | DECIMAL128(8) | highest price |
close | DECIMAL128(8) | closing price |
volume | DECIMAL128(8) | trading volume |
amount | DECIMAL128(8) | trading amount |
upLimitPrice | DECIMAL128(8) | limit up price |
downLimitPrice | DECIMAL128(8) | limit down price |
signal | DOUBLE[] | user-defined field |
prevClosePrice | DECIMAL128(8) | previous closing price |
settlementPrice | DECIMAL128(8) | settlement price |
prevSettlementPrice | DECIMAL128(8) | previous settlement price |
contractType | INT | contract type:
|
After the replay of historical data in backtesting is completed, you can append a message with the symbol set to "END" to indicate the end of the strategy backtest. For example:
messageTable = select top 1* from messageTable where tradeTime = max(tradeTime)
update messageTable set symbol = "END"
update messageTable set tradeTime = concatDateTime(tradeTime.date(), 16:00:00)
Backtest::appendQuotationMsg(engine, messageTable)
1.3 Backtesting Interfaces
You can build and run a backtest engine based on their custom strategies to retrieve backtest results such as daily positions, daily equity, summary of returns, trade details, and any user-defined logic outputs within the strategy. Some methods include additional parameters such as contractType and accountType to filter results by instrument type and account type.
This section focuses on the methods employed in the current example.
Backtest::createBacktestEngine
: Initializes the backtest engine.- name: Name of the backtest engine.
- config: Configuration settings for the engine. See Section 1.1 for details.
- securityReference: The instrument reference table. See Section 1.1 for required fields.
- initialize, beforeTrading, onTick/onBar, onSnapshot, onOrder, onTrade, afterTrading, and finalize: Event functions to be implemented based on the custom trading strategy.
Backtest::createBacktestEngine(name, Config, [securityReference], initialize, beforeTrading, onTick/onBar, onSnapshot, onOrder, onTrade, afterTrading, finalize)
Backtest::appendQuotationMsg
: Appends market data to the engine and executes the strategy.- engine: The engine instance.
- msg: The input market data. See Section 1.2 for required structure of minute-level data.
Backtest::appendQuotationMsg(engine, msg)
Backtest::getPosition
: Retrieves current position information.- engine: The engine instance.
- symbol: Symbol code (optional; if not set, returns all symbols).
- accountType: Account type ("spot", "futures", or "option").
Backtest::getPosition(engine, symbol = "", accountType)
Backtest::submitOrder
: Submits an order for the specified account.- engine: The engine instance.
- msg: A tuple or table containing the order info (asset name, time to place, order type, price, quantity, direction).
- label: optional. The custom label to distinguish orders.
- orderType: optional. Algorithmic order type (default is 0).
- accountType: optional. Account type ("spot", "futures", or "option").
Backtest::submitOrder(engine, msg, label="",orderType = 0, accountType)
The methods above form the basic set for conducting backtests. The following result retrieval methods are typically used after the backtest ends to analyze strategy performance.
Backtest::getTradeDetails
: Returns details about orders.Backtest::getTradeDetails(engine, accountType)
Backtest::getDailyPosition
: Returns the position of a specific asset, including long/short position volume, average execution price, and trade volume.Backtest::getDailyPosition(engine, accountType)
Backtest::getDailyTotalPortfolios
: Returns the total equity of all portfolios, including available funds, daily return, and daily profit and loss.Backtest::getDailyTotalPortfolios(engine, accountType)
Backtest::getReturnSummary
: Returns a summary of returns, including total return, annualized return, and annualized volatility.Backtest::getReturnSummary(engine, accountType)
2. Cryptocurrency Backtesting Case
Arbitrage strategies aim to profit from price discrepancies across different markets or instruments. In the cryptocurrency market, high volatility and differences in pricing mechanisms across exchanges create natural opportunities for arbitrage. Common strategies include cross-exchange arbitrage, contract arbitrage, statistical arbitrage, triangular arbitrage, and funding rate arbitrage.
These strategies typically involve hedging to lock in profits and reduce exposure to price movements, resulting in relatively low risk. However, traders must still consider market volatility, liquidity constraints, and transaction costs to ensure profitability and robustness.
This chapter demonstrates how to implement and backtest a funding rate arbitrage strategy using the DolphinDB backtesting engine.
2.1 Funding Rate Arbitrage
Funding rate arbitrage is a strategy unique to the cryptocurrency market, based on perpetual contracts. Key characteristics of perpetual contracts include:
- No expiry date, allowing for continuous position holding.
- Periodic funding rate payments (typically every 8 hours), where long positions pay shorts when the funding rate is positive, and shorts pay longs when it is negative.
Funding rates are set by exchanges to help maintain parity between the contract price and the spot price. This strategy exploits changes in the funding rate by dynamically adjusting positions in both perpetual contracts and the underlying spot asset to hedge against price fluctuations and capture funding payments. The core logic is:
- Short perpetual + long spot: When the funding rate exceeds 0.03%, short the perpetual contract and buy the equivalent spot asset. Once the rate becomes negative, close the perpetual position and sell the spot holding.
- Long perpetual + short spot: When the funding rate drops below -0.03%, go long the contract and sell the spot. When the rate turns positive, close the contract and buy back the spot.
Currently, DolphinDB does not support initializing positions at the beginning of a backtest. Therefore, this example implements only the short arbitrage strategy. Support for initial holdings will be added in future versions.
The following sections walk through implementing this strategy using the DolphinDB backtesting engine.
2.2 Strategy Implementation
2.2.1 Custom Trading Strategies
The backtesting system utilizes an event-driven design and provides a suite of event functions, including initialization function, daily callback, and snapshot data callback. You can implement custom strategy logic within the corresponding callback functions. This section demonstrates how each event function is implemented in this case.
- Using the
initialize
function, you can set up your strategy's key metrics and variables. In this example, userParam is a funding rate table. For detailed configuration, refer to the next section on engine parameter settings.Line 5
context["lastlastFunding"]
stores the previous funding rate, which serves as a condition for closing positions.def initialize(mutable context, userParam){ // Initialization print("initialize") context["fundingRate"] = userParam context["lastlastFunding"] = dict(SYMBOL,ANY) }
- In daily callback function
beforeTrading
, the trading date can be obtained viacontextDict["tradeDate"]
, as shown in line 4. In this case, the current day's funding rate table is transformed into a nested dictionary d, which is then stored in the context for convenient access. A nested dictionary structure is used here, with the instrument symbol and settlement time as the keys.def beforeTrading(mutable context){ // Daily callback // Get the current trading day print ("beforeTrading: " + context["tradeDate"]) // Retrieve current funding rates and store them as a dictionary 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"] d[i] = dict(temp.settlementTime,temp.lastFundingRate, true) } context["dailyLastFundingPrice"] = d }
- In the market data callback function
onBar
, the parameter msg contains the latest minute-level market data passed from the backtesting engine, while the parameter indicator contains the subscribed indicators. Since this case does not involve indicator subscriptions, indicator will not be discussed in detail. The following code demonstrates the logic for opening and closing positions:- Since the msg dictionary contains market data for multiple
instruments, a
for(i in msg.keys()) {}
loop is used to iterate through each symbol. - For each instrument, line 11 extracts the current funding rate—i.e., the fundingRate of the previous funding rate table. Then it checks whether the funding rate and current positions meet the criteria for opening or closing positions, and submits orders accordingly.
def onBar(mutable context, msg, indicator = NULL){ //... dailyFundingRate = context["dailyLastFundingPrice"] // Iterate over multiple instruments for(i in msg.keys()){ istockSymbol = msg[i]["symbol"] istock = split(istockSymbol,"_")[0] source = msg[i]["symbolSource"] closePrice = msg[i]["close"] // Get the funding rate for the current symbol based on the corresponding time interval 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[istock][fundingRateTime] // Check the current position status: includes both spot and futures spotPos = Backtest::getPosition(context["engine"], istock+"_0", "spot") futurePos = Backtest::getPosition(context["engine"],istock+"_2","futures") //... } }
- Since the msg dictionary contains market data for multiple
instruments, a
In this example, context["fundingRate"]
is the funding rate
passed during initialization. You need to extract the rate for the
corresponding symbol at the given time. Use
Backtest::getPosition
to retrieve current spot and
futures positions.
Open position conditions are based on funding rate and position size, and
orders are placed via Backtest::submitOrder
.
// If funding rate > 0.03%, short perpetual contract and buy equivalent spot (max order size: 0.1)
if(spotPos.longPosition[0] <= 0.1 and futurePos.shortPosition[0] < 0.1
and lastFundingPrice > 0.0003){
// Spot position
if(istockSymbol == istock+"_0"){
Backtest::submitOrder(context["engine"], (istockSymbol, source,
context["tradeTime"], 5, closePrice,lowerLimit, upperLimit, qty,1,
slippage, 1, expireTime ), "buyopen_spot", 0, "spot")
}
// Futures position
if(istockSymbol == istock + "_2"){
Backtest::submitOrder(context["engine"], (istockSymbol, source,
context["tradeTime"], 5, closePrice,lowerLimit, upperLimit, qty, 2,
slippage, 1, expireTime ), "sellopen_contract", 0, "futures")
}
}
context["lastlastFunding"][istock] = lastFundingPrice[0]
}
For closing positions, check if the previous funding rate
(context["lastlastFunding"]
) was positive and the
current rate is negative, and whether the position size is greater than
0.01.
// Close positions when funding rate turns negative
if(Pos_spot.longPosition[0] > 0.1 and Pos_futures.shortPosition[0] > 0.1
and context["lastlastFunding"][istock] >= 0 and lastFundingPrice < 0){
// Close spot
if(istockSymbol == istock+"_0"){
Backtest::submitOrder(context["engine"], (istockSymbol, source,
context["tradeTime"], 5, closePrice,lowerLimit, upperLimit, qty,3,
slippage, 1,expireTime ), "sellclose_spot", 0, "spot")
}
// Close futures
if(istockSymbol == istock+"_2"){
Backtest::submitOrder(context["engine"], (istockSymbol, source,
context["tradeTime"], 5, closePrice,lowerLimit, upperLimit, qty, 4,
slippage, 1,expireTime ), "buyclose_contract", 0, "futures")
}
}
Note: In submitOrder
, the parameters
upperLimit, lowerLimit, slippage, qty, and
expireTime refer to take-profit price, stop-loss price, slippage,
order quantity, and order expiration time, respectively. You can customize
these based on your strategy.
In addition to market-driven strategies, the backtesting engine also supports handling order status updates, trade execution events, end-of-day accounting, and other finalization logic.
2.2.2 Parameter Configuration
The backtest engine allows configuration of parameters such as start and end dates, initial capital, market type, order delay, and funding rates for perpetual contracts. You can adjust these settings to simulate different market conditions and evaluate strategy performance.
In this example, the strategy is for cryptocurrency trading, so the strategyType parameter should be set to "cryptocurrency". This ensures the engine applies the correct rules and logic for digital asset markets.
Example code for initializing parameters:
startDate = 2024.01.01
endDate = 2024.01.15
config = dict(STRING, ANY)
config["startDate"] = startDate
config["endDate"] = endDate
config["strategyGroup"] = "cryptocurrency" // Strategy type: cryptocurrency
cash = dict(STRING, DOUBLE) // Initial account capital
cash["spot"] = 1000000.
cash["futures"] = 1000000.
cash["option"] = 1000000.
config["cash"] = cash
config["dataType"] = 3 // Market data type: minute-level
// Funding rate: passed to the engine via userParam
config["fundingRate"] = select symbol, settlementTime, decimal128(lastFundingRate,8)
as lastFundingRate from CryptoFundingRate where date(settlementTime) >= startDate
and date(settlementTime) <= endDate order by settlementTime
userParam = table(1:0,[`symbol,`settlementTime,`lastFundingRate],[SYMBOL,TIMESTAMP,DECIMAL128(8)])
userParam = config["fundingRate"]
Note: The config["fundingRate"]
parameter specifies
the funding rate for cryptocurrency perpetual contracts. It can be passed
into event functions using userParam. In this case, make sure to add
userParam as an argument when defining and registering event
functions, e.g., def initialize(mutable contextDict,
userParam)
or initialize{, userParam}
.
2.2.3 Creating and Executing the Backtest
When creating the backtestint engine, select event functions based on data
frequency and strategy needs. In this case, only onBar
is
needed for minute-level data, and onSnapshot
is skipped by
leaving its position empty (using a comma placeholder).
// Create a backtest engine
strategyName = "Cryptocurrency"
try{Backtest::dropBacktestEngine(strategyName)}catch(ex){print ex}
engine = Backtest::createBacktestEngine(strategyName, config, securityReference,
initialize{,userParam}, beforeTrading, onBar,, onOrder, onTrade,, finalize)
go
For cryptocurrency backtests, set the securityReference parameter
based on contract info. Use SQL (e.g., update
,
select
) to ensure the schema matches requirements in
Section 1.1, or load a prepared table with loadText
.
// Security reference table
securityReference = select last(contractType) as contractType
from testData group by symbol
update securityReference set optType = 1
update securityReference set strikePrice = decimal128(0, 8)
update securityReference set contractSize = decimal128(100.,8)
update securityReference set marginRatio = decimal128(0.2,8)
update securityReference set tradeUnit = decimal128(0.2,8)
update securityReference set priceUnit = decimal128(0.,8)
update securityReference set priceTick = decimal128(0.,8)
update securityReference set takerRate = decimal128(0.,8)
update securityReference set makerRate = decimal128(0.,8)
update securityReference set deliveryCommissionMode = iif(contractType!=2,1,2)
update securityReference set fundingSettlementMode = iif(contractType==2,1,2)
update securityReference set lastTradeTime = timestamp() // Last settlement time
Once you’ve set up the engine, you can use the
appendQuotationMsg
method to append market data to the
engine and execute the backtesting. The testData variable contains
minute-level market data. Convert it if needed to match the required format
(see Section 1.2).
// Market data: extracted and converted to match required structure and types
cryptoMinData = loadText("root/data/Crypto1minData.csv")
testData = select symbol + "_" + string(contractType) as symbol,symbolSource,
tradeTime,tradingDay,decimal128(open,8) as open,decimal128(low,8) as low,
decimal128(high,8)as high,decimal128(close,8)as close,decimal128(volume,8)as volume,
decimal128(amount,8) as amount, decimal128(upLimitPrice,8) as upLimitPrice,
decimal128(downLimitPrice,8) as downLimitPrice,signal,decimal128(prevClosePrice,8)as
prevClosePrice,decimal128(settlementPrice,8) as settlementPrice,
decimal128(prevSettlementPrice,8) as prevSettlementPrice,contractType
from Crypto1minData where date(tradeTime) >= startDate and date(tradeTime) <=
endDate order by tradeTime
//......
Backtest::appendQuotationMsg(engine,testData)
2.2.4 Checking results
The Backtest plugin offers a list of methods to obtain the corresponding results, including:
getPosition
/getDailyPosition
: Returns the position of a specific asset.getTradeDetails
: Returns details about orders.getAvailableCash
: Returns the account's available cash.getTodayPnl
: Returns the profit and loss for the current day.getTotalPortfolios
/getDailyTotalPortfolios
: Returns the total equity of all portfolios.getReturnSummary
: Returns a summary of returns.getContextDict
: Returns the global variables.
For cryptocurrency strategies, some methods support specifying the account type (e.g., spot, futures, option). Below are examples of commonly used result queries. The field “direction” indicates order direction (1 means buy open).
// Retrieve trade details; returns all trades if accountType is not set
Backtest::getTradeDetails(engine, 'spot')

Use Backtest::getDailyPosition
to get daily positions by
account type. The futures position data in this example is shown in Figure
2-2. Fields like “lastDayLongPosition”, “shortPosition”, and
“shortPositionAvgPrice” represent the previous day's long position, current
short position, and average short price, respectively.
// Retrieve daily positions; accountType can be specified
Backtest::getDailyPosition(engine, 'futures')

Use Backtest::getReturnSummary
to view the overall strategy
performance. If accountType is specified, results are limited to that
account; otherwise, returns cover all accounts. Figure 2-3 shows the return
summary. Key fields include “totalReturn” and “annualReturn”.
// Return summary; specify accountType to filter, or omit to show all accounts
Backtest::getReturnSummary(engine)

3. Advantages
DolphinDB's backtesting plugin integrates distributed storage and computing, a multi-paradigm programming language, and a matching engine simulator, offering several advantages for cryptocurrency strategy development.
- It provides a standardized framework with multiple event functions and a comprehensive set of engine APIs. Users can simply implement event functions, call relevant APIs, and configure parameters to build and run strategies. After the backtest is completed, users can retrieve the results via APIs for comprehensive performance analysis.
- DolphinDB includes rich indicator libraries like mytt and ta. It also supports custom factor development using powerful built-in functions such as sliding windows and aggregations, greatly improving both performance and development efficiency.
- The plugin supports multi-account trading across various asset types, including spot, futures, perpetual contracts, and options. Users can run strategies for multiple accounts and analyze the results separately.
- For high-frequency strategies, the built-in matching engine accurately simulates real trading logic, helping to better estimate real-world performance.
These advantages position DolphinDB as a robust and practical choice for complex cryptocurrency backtesting, outperforming tools like Backtrader and QuantConnect in areas such as high-precision computation and multi-account strategy support.
4. Conclusion
DolphinDB's backtesting plugin provides a standardized framework, rich APIs, and high-performance computing, enabling fast and flexible cryptocurrency strategy development. This document demonstrates how to configure the engine, define custom strategy functions, and create a backtesting engine. Through a funding rate arbitrage case, we demonstrate how to implement and validate a complete crypto strategy using DolphinDB.
5. Appendix
Example Script: CryptoArbitrageStrategy.dos
Example Data: CryptoFundingRate.csv, Crypto1minData.csv