Optimizing Crypto Trading Algorithms: High-Performance Backtesting Insights

DolphinDBDolphinDB
8 min read

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!

0
Subscribe to my newsletter

Read articles from DolphinDB directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

DolphinDB
DolphinDB