Optimizing Crypto Trading Algorithms: High-Performance Backtesting Insights
In the previous section, we introduced our tailored high-performance backtesting solutions. This section will offer an illustration of implementing a backtesting system for a grid trading strategy. Grid trading is a strategic approach that traders place buy and sell orders at predetermined intervals above and below a base price, creating a grid of orders.
Download our whitepaper for a new crypto management experience: Cryptocurrency Solutions — Dolphindb
4.5 Example: Backtesting a Grid Trading Strategy
The strategy operates on a countertrend principle, initiating purchases during price declines and sales during price increases. Additionally, in cases of large fluctuations, we reduce buying during market downturns and pause selling during uptrends, waiting for market stabilization before resuming normal operations.
Specifically, with the preset grid interval (alpha) and price reversal (beta),
when the price hits n intervals below the base price, a buy order is triggered after a beta rebound from the low.
when the price hits n intervals above the base price, a sell order is triggered after a beta pullback from the high.
We’ll use minute-level OHLC data as our sample dataset to evaluate the strategy’s performance.
4.5.1 Preparing Data
Minute-Level OHLC Data
Crypto1minData = select *,0 as contractType,
0.0 as upLimitPrice, 0.0 as downLimitPrice
from loadTable('dfs://klineMinuteLevel', 'klineSpot')
Crypto1minData_ = select symbol+"_"+string(contractType) as symbol,
exchange as symbolSource,
closeTime as tradeTime,
date(closeTime) as tradingDay,
decimal128(open,8) as open,decimal128(low,8) as low,
decimal128(high,8) as high,decimal128(close,8) as close,
decimal128(quoteVolume,8) as volume,
decimal128(volume,8) as amount,
decimal128(upLimitPrice,8) as upLimitPrice,
decimal128(downLimitPrice,8) as downLimitPrice,
fixedLengthArrayVector([close]).double() as signal,
decimal128(open,8) as prevClosePrice,
decimal128(close,8) as settlementPrice,
decimal128(open,8) as prevSettlementPrice,
contractType from Crypto1minData
Output:
Figure 4–6 Sample Data: Minute-Level OHLC Data
Basic Info
basicInfo = select last(contractType) as contractType,
1 as optType, decimal128(0, 8) as strikePrice, decimal128(100.,8) as contractSize,
decimal128(0.2,8) as marginRatio, decimal128(0.2,8) as tradeUnit,
decimal128(0.,8) as priceUnit, decimal128(0.,8) as priceTick,
takerRate=decimal128(0.,8) as takerRate,decimal128(0.,8) as makerRate,
iif(contractType==2,3,1) as deliveryCommissionMode, timestamp() as lastTradeTime
from Crypto1minData_ group by symbol
Output:
Figure 4–7 Sample Data: Basic Info
Funding Rate
CryptoFundingRate = select symbol, calcTime as settlementTime,
decimal128(lastFundingRate,8) as lastFundingRate
from loadTable('dfs://fundingRate', 'fundingRate')
Output:
Figure 4–8 Sample Data: Funding Rate
4.5.2 Customizing the Trading Strategy
Initialization
Use the initialize
function to set up the global variables.
def initialize(mutable contextDict){
print("initialize")
// initial price
contextDict["initPrice"] = dict(SYMBOL,ANY)
// grid interval
contextDict["alpha"]=0.01
// price reversal
contextDict["beta"] =0.005
// trade value per interval
contextDict["M"] = 100000
contextDict["baseBuyPrice"] = dict(SYMBOL,ANY)
contextDict["baseSellPrice"] = dict(SYMBOL,ANY)
contextDict["lowPrice"]=dict(SYMBOL,ANY)
contextDict["highPrice"]=dict(SYMBOL,ANY)
contextDict["N"]=dict(SYMBOL,ANY)
contextDict["feeRatio"] = 0.00015
}
Defining daily startup operations
The following callback function specifies “BNBUSDT_0” as the trading universe using the setUniverse
method.
def beforeTrading(mutable contextDict){
Backtest::setUniverse(contextDict["engine"],["BNBUSDT_0"])
}
Defining actions as new OHLC record arrives
The strategy callback function is the heart of our trading strategy’s decision-making process. Since minute-level OHLC data is used as our sample data, the strategy callback function should use onBar
.
We’ll start by creating a user-defined function updateBaseBuyPrice
. This function will be responsible for updating the base buy/sell prices and adjusting the upper/lower boundaries of the grid based on the latest price.
def updateBaseBuyPrice(istock,lastPrice,basePrice,mutable baseBuyPrice,mutable baseSellPrice,mutable N,mutable highPrice,mutable lowPrice,alpha,n,mode=0){
baseBuyPrice[istock]=basePrice*(1-alpha)
baseSellPrice[istock]=basePrice*(1+alpha)
N[istock]=n
if(mode==0){
// initial prices
lowPrice[istock]=0.
highPrice[istock]=10000.
}
else if(mode==1){
// update the lower boundary of grid
lowPrice[istock]=lastPrice
highPrice[istock]=10000.
}
else if(mode==2){
// update the upper boundary of grid
lowPrice[istock]=0.
highPrice[istock]=lastPrice
}
}
Then, define the onBar
. As new price data arrives, the function begins by updating the number of grids and the positions of the latest grid lines in real time. During these market movements, it tracks and records key price points, logging the lowest price reached in a downtrend and the highest price achieved in an uptrend.
It constantly monitors the latest price, watching for two specific triggers: the rebound price for buying opportunities and the fallback price for selling opportunities. When the latest price activates either of these triggers, the function springs into action, calculating the appropriate quantity of the underlying asset to buy or sell and then executing the trade accordingly. To maintain accurate position information, the function calls the getPosition
method, providing data on the quantity of assets bought or sold on the current trading day.
Following each trade, it dynamically updates the base price to reflect the latest trade price.
def onBar(mutable contextDict, msg){
expireTime = timestamp()
upperLimit = decimal128(0.0, 8)
lowerLimit = decimal128(0.0, 8)
slippage = decimal128(0.0, 8)
alpha=contextDict["alpha"]
beta=contextDict["beta"]
M=contextDict["M"]
for(row in 0 : msg.rows()){
istock = msg[row]["symbol"]
lastPrice=msg[row]["close"]
//print lastPrice
// initial price
if(not istock in contextDict["initPrice"].keys()){
contextDict["initPrice"][istock]=lastPrice
updateBaseBuyPrice(istock,lastPrice,lastPrice, contextDict["baseBuyPrice"],
contextDict["baseSellPrice"] , contextDict["N"], contextDict["highPrice"], contextDict["lowPrice"],alpha,1,0)
}
init_price=contextDict["initPrice"][istock]
if(lastPrice<=contextDict["baseBuyPrice"][istock]){
// update lower boundary of grid
n=floor(log(lastPrice\init_price)\log(1-alpha))+1
if(n>contextDict["N"][istock]){
newBasePrice=init_price*pow((1-alpha),n)
updateBaseBuyPrice(istock,lastPrice,newBasePrice, contextDict["baseBuyPrice"], contextDict["baseSellPrice"],
contextDict["N"], contextDict["highPrice"], contextDict["lowPrice"],alpha,n,1)
}
}
else if(lastPrice>contextDict["baseSellPrice"][istock]){
// update upper boundary of grid
n=floor(log(lastPrice\init_price)\log(1+alpha))+1
if(n>contextDict["N"][istock]){
newBasePrice=init_price*pow((1+alpha),n)
updateBaseBuyPrice(istock,lastPrice,newBasePrice, contextDict["baseBuyPrice"],
contextDict["baseSellPrice"] , contextDict["N"], contextDict["highPrice"], contextDict["lowPrice"],alpha,n,2)
}
}
source = msg[row]["symbolSource"]
if(contextDict["lowPrice"][istock]>0. and lastPrice>contextDict["lowPrice"][istock]*(1+beta)){
// buy
qty=decimal128(0.001,8)//decimal128((contextDict["N"][istock]*M\lastPrice)/100*100, 8)
orderId = Backtest::submitOrder(contextDict["engine"], (istock, source, contextDict["tradeTime"],5, lastPrice,
upperLimit, lowerLimit, qty,1, slippage, 1,expireTime ),"buy")
contextDict["initPrice"][istock]=lastPrice
updateBaseBuyPrice(istock,lastPrice,lastPrice, contextDict["baseBuyPrice"],
contextDict["baseSellPrice"] , contextDict["N"], contextDict["highPrice"], contextDict["lowPrice"],alpha,1,0)
}
else if(contextDict["highPrice"][istock]<10000. and lastPrice<contextDict["highPrice"][istock]*(1-beta)){
// sell
qty=Backtest::getPosition(contextDict["engine"],istock).todayBuyVolume[0]
if(qty<=0){
continue
}
qty =decimal128(0.001,8) //decimal128(min([int(contextDict["N"][istock]*M\lastPrice)/100*100,qty]), 8)
orderId = Backtest::submitOrder(contextDict["engine"], ( istock, source, contextDict["tradeTime"],5, lastPrice,
upperLimit, lowerLimit, qty,3, slippage, 1, expireTime),"sell")
contextDict["initPrice"][istock]=lastPrice
updateBaseBuyPrice(istock,lastPrice,lastPrice, contextDict["baseBuyPrice"], contextDict["baseSellPrice"] ,
contextDict["N"], contextDict["highPrice"], contextDict["lowPrice"],alpha,1,0)
}
// update the low and high prices in real time
if(contextDict["lowPrice"][istock]>0){
contextDict["lowPrice"][istock]=min([contextDict["lowPrice"][istock],lastPrice])
}
if(contextDict["highPrice"][istock]<10000.){
contextDict["highPrice"][istock]=max([contextDict["highPrice"][istock],lastPrice])
}
}
}
Defining actions for order status changes and fills
The onOrder
and onTrade
functions are used to manage events related to changes in order status and order fills. For a simple demonstration, we've chosen to implement onOrder
and onTrade
as functions without any specific operations or logic.
def onOrder(mutable contextDict,orders){
for ( iorder in orders){
if( not iorder.status in [1]){
return
}
}
}
def onTrade(mutable contextDict,trades){}
Defining end-of-day operations
The following callback function outputs the current trading date at the end of each day.
def afterTrading(mutable contextDict){
tradeDate=contextDict["tradeDate"]
print("afterTrading:"+tradeDate)
}
Finalization
Before the backtesting process concludes, we define the callback function to output the message “finalized”.
def finalized(mutable contextDict){
print("finalized")
}
4.5.3 Configuring Backtesting
Specifying Strategy Name
Specify the strategy name as Cryptocurrency.
strategyName="Cryptocurrency"
Setting User Configurations
Define a userConfig dictionary to set configurations for your backtesting. Refer to Table 4–2 for details.
userConfig=dict(STRING,ANY)
userConfig["startDate"]=2023.01.01
userConfig["endDate"]=2024.01.01
// set strategy group as cryptocurrency
userConfig["strategyGroup"]= "cryptocurrency"
// set frequency to generate snapshot
userConfig["frequency"]= 0
cash=dict(STRING,DOUBLE)
// set USTD value for each account
cash["spot"]=100000.
cash["future"]=100000.
cash["option"]=100000.
userConfig["cash"]= cash
// market data settings
userConfig["dataType"]=3
userConfig["matchingMode"]= 1
userConfig["fundingRate"]=select * from CryptoFundingRate
// process the input market data as table
userConfig["msgAsTable"]= true
4.5.4 Creating the Backtesting Engine
Use the createBacktestEngine
method to create an engine.
try{Backtest::dropBacktestEngine(strategyName)}catch(ex){print ex}
engine = Backtest::createBacktestEngine(strategyName,
userConfig,basicInfo,initialize, beforeTrading,onBar,,onOrder,
onTrade,afterTrading,finalized)
4.5.5 Appending Data to Backtesting Engine
Convert and reformat the minute-level OHLC data into a standardized structure that our framework can efficiently process and utilize.
Crypto1minData_ = select symbol+"_"+string(contractType) as symbol,
exchange as symbolSource,closeTime as tradeTime,date(closeTime) as tradingDay,
decimal128(open,8) as open,decimal128(low,8) as low,
decimal128(high,8) as high,decimal128(close,8) as close,decimal128(quoteVolume,8) as volume,
decimal128(volume,8) as amount,
decimal128(upLimitPrice,8) as upLimitPrice,decimal128(downLimitPrice,8) as downLimitPrice,
fixedLengthArrayVector([close]).double() as signal,decimal128(open,8) as prevClosePrice,
decimal128(close,8) as settlementPrice,
decimal128(open,8) as prevSettlementPrice,
contractType from Crypto1minData
The schema is as follows:
Figure 4–9 Schema of Input OHLC Data
Use the appendQuotationMsg
method to append the testData into the backtesting engine, initiating the backtesting process. After completing its analysis, the engine emits a message with the symbol "END" to indicate the process has finished.
strategyName="Cryptocurrency"
endFlag = select top 1* from Crypto1minData_ order by tradeTime desc
update endFlag set symbol = "END"
insert into testData endFlag
try{Backtest::dropBacktestEngine(strategyName)}catch(ex){print ex}
engine = Backtest::createBacktestEngine(strategyName, userConfig,basicInfo,initialize, beforeTrading,onBar,,onOrder,onTrade,afterTrading,finalized)
go
timer Backtest::appendQuotationMsg(engine,Crypto1minData_)
4.5.6 Obtaining Results
// trade details
tradeDetails=Backtest::getTradeDetails(engine)
// check open orders
openOrders=Backtest::getOpenOrders(long(engine))
// get daily position
dailyPosition=Backtest::getDailyPosition(long(engine))
// available cash
enableCash=Backtest::getAvailableCash(long(engine))
// equity of portfolios
totalPortfolios=Backtest::getTotalPortfolios(long(engine))
// summary of returns
returnSummary=Backtest::getReturnSummary(long(engine))
Output of returnSummary:
Figure 4–10 Output of returnSummary
4.6 Backtesting in Parallel
To achieve optimal performance, we can execute backtesting in parallel using DolphinDB’s job management functions.
The submitJob
function initiates concurrent backtesting processes.
First, we define a function runBacktest_CTA
that covers the entire backtesting process for the CTA strategy.
def runBacktest_CTA(strategyName, userConfig, initialize, userParam,
SecurityID, beforeTrading, onBar, onOrder, onTrade, afterTrading,
finalized, messageTable){
engine = Backtest::createBacktestEngine(strategyName, userConfig,,
initialize{,userParam}, beforeTrading,onBar,,onOrder,onTrade,afterTrading,finalized)
Backtest::appendQuotationMsg(engine,messageTable)
messageTableend=select top 1* from messageTable where tradeTime=max(tradeTime)
update messageTableend set symbol="END"
update messageTableend set tradeTime=concatDateTime(tradeTime.date(),16:00:00)
Backtest::appendQuotationMsg(engine,messageTableend)
ottb = Backtest::getReturnSummary(long(engine))
ottb['SymbolID'] = SymbolID
ottb['shortWindowN'] = userParam["shortWindowN"]
ottb['longWindowN'] = userParam["longWindowN"]
ottb = select SymbolID, shortWindowN, longWindowN,
totalReturn, annualReturn from ottb
return ottb
}
Securities are divided into multiple groups by symbol. For each group, the submitJob
function is employed to initiate individual runBacktest_CTA
processes. Every process is set up with its own unique shortWindowN and longWindowN values.
i=0
jods = []
for(icodes in codes){
// divide the asset into n segments
for(shortWindowN in [190,200, 210, 220, 230, 240, 250, 260, 270, 280, 290, 300]){
for(longWindowN in [440, 450, 460, 470, 480, 490, 500, 510, 520, 530, 540, 550]){
// strategy config
userParam=dict(STRING,INT)
userParam["shortWindowN"]= shortWindowN
userParam["longWindowN"]= longWindowN
// hedging config
//strategyName="marketMakingStrategy"
SecurityID = icodes
icodemessage = select * from messageTable where symbol == icodes
strategyName="mMStrategy"+string(i)
startDate=2021.01.01
endDate=2022.01.01
// userConfig=dict(STRING,ANY)
userConfig["startDate"]=startDate
userConfig["endDate"]=endDate
userConfig["strategyGroup"]= "stock"
userConfig["frequency"]= 0
userConfig["cash"]= 10000000
userConfig["commission"]= 0.00015
userConfig["tax"]= 0.001
userConfig["dataType"]= 3
userConfig["matchingMode"]= 2
print(strategyName)
// print(strategyName)
jobId=submitJob(strategyName, strategyName+"job",runBacktest_CTA,
strategyName, userConfig, initialize, userParam, SecurityID,
beforeTrading, onBar, onOrder, onTrade, afterTrading, finalized,
icodemessage)
jods=jods.append!(jobId)
i = i + 1
}
}
}
Obtain the results with the getJobReturn
function:
outcometb = getJobReturn(jods[0])
for(id in jods[1:]){
outcometb = unionAll(getJobReturn(id),outcometb)
}
4.7 DolphinDB Backtesting Engine vs. Backtrader
The following table compares the functionalities of DolphinDB vs Backtrader, a widely-adopted Python-based backtesting framework.
Table 4–4 DolphinDB Backtesting Engine vs. Backtrader
Download Whitepaper Here: Cryptocurrency Solutions — Dolphindb
Email us for more information or to schedule a demo: sales@dolphindb.com
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!
Subscribe to my newsletter
Read articles from DolphinDB directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by