Backtesting strategy

Introduction

  1. Step 1 (order scheduling): build a signal to be tested. It can be build complex as you want, however the output should have 3 outcomes buy, sell, neutral.
  2. Step 2 (order execution): backtest the signal. In this step we consider stop loss, take profit, transaction fees, funding fees…
  • If the signal build in Step 1 depends on some parameters, it is possible to iterate Step 2. multiple times in order to find the best settings.

1. Signal

add_moving_average <- function(data, cols = "close", ma.type = c("sma", "ema"), n = 12){
  
  ma.type <- match.arg(ma.type, choices = c("sma", "ema"))
  
  for( i in 1:length(cols)){
    for(j in 1:length(n)){
      
      old_col_name = cols[i]
      new_col_name <- paste0(old_col_name,"_", tolower(ma.type), "_", n[j])
      data[[new_col_name]] <- NA_integer_
  
      if(ma.type == "sma"){
        data[[new_col_name]] <- TTR::SMA(data[[old_col_name]], n = n[j]) 
      } else if(ma.type == "ema"){
        data[[new_col_name]] <- TTR::EMA(data[[old_col_name]], n = n[j]) 
      }
    }
  }
  return(data)
}

signal_moving_average <-  function(data, col_ = "open", ma_type = "sma", n = 38){
  
  db <- add_moving_average(data, cols = col_, ma.type = ma_type, n = n)
  db <- na.omit(db)
  strategy_name <- paste0(col_, "_", ma_type, "_", n)
  
  db$open > db[[strategy_name]] & lag(db$open) < lag(db[[strategy_name]])
  
  db$buy_signal <- db$open > db[[strategy_name]] & lag(db$open) < lag(db[[strategy_name]])
  db$sell_signal <- db$open < db[[strategy_name]] & lag(db$open) > lag(db[[strategy_name]])
  
  db$signal <- ifelse(db$buy_signal, "buy", ifelse(db$sell_signal, "sell", "neutral"))
  db$signal[1] <- "neutral"
  attr(db, "strategy") <- strategy_name
  db <- dplyr::select(db, date, open, high, low, close, volume, signal)
  
  return(db)
}

2. Backtest

# data: datasets with columns date, close, open, high, low, signal
# capital: capital used for each trade, e.g. 1000.
# stop_loss: stop loss rate (in %). 
# take_profit: take profit rate (in %)
# txs_fee: transaction fee (in %)
# funding: daily funding rate (in %, should be specified if futures are used)
# trailing: TRUE for using the trailing stop, FALSE for not using.
# retracement: retracement rate (in %). Used only if trailing = TRUE. 
# quiet: if TRUE the function will not display any update messages. 

backtest_strategy <- 
  function(data, capital = 1000, stop_loss = 0, take_profit = 0, txs_fee = 0, funding = 0, trailing = FALSE, retracement = 2, quiet = FALSE){
  
  # initialize the trading list (general information)
  td <- list()
  td$capital <- capital # capital used for each trade 
  td$funding <- funding/100 # funding rate (in %, should be specified if futures are used)
  td$txs_fee <- txs_fee/100 # transaction fees (in %)
  
  # stop loss parameters 
  td$stop_loss <- stop_loss/100 # stop loss rate (in %)
  td$stop_loss_price <- 0 # stop loss price
  td$is_used_stop_loss <- ifelse(stop_loss == 0, 0, 1) # 1 if the stop loss is used, 0 otherwise 
  td$is_stop_loss <- FALSE # stop loss condition
  
  # take profit parameters 
  td$take_profit <- take_profit/100 # take profit rate (in %)
  td$take_profit_price <- 0 # take profit price
  td$is_used_take_profit <- ifelse(take_profit == 0, 0, 1) # 1 if the take profit is used of 0 otherwise 
  td$is_take_profit <- FALSE # take profit condition
  
  # trailing parameters 
  td$trailing <- trailing
  td$retracement <- retracement/100
  td$best_price <- 0
  
  # trade data 
  td$data <- list()  # list with all the trades 
  td$last_trade <- list()  # contains the last trade 
  td$trade_open <- ""  # if trade_open = "" no trade are open, can be either "buy" (long) or "sell" (short)
  n_trade <- 1  # counter with the number of trades 
  
  i <- 1
  while(i <= nrow(data)){

    df <- data[i, ]
    df$signal <- as.character(df$signal)
    
    if (!quiet){
      message("Processing: ", round(i/nrow(data)*100, 2), "% \r", appendLF = FALSE)
      flush.console()
    }
    
    # if trailing = TRUE: update take profit and stop loss 
    if (td$trade_open == "sell" && (td$last_trade$entry_price/df$open - 1) >= td$retracement & trailing) {
      td$take_profit_price <- td$take_profit_price*(1 - td$retracement)
      td$stop_loss_price <-  td$stop_loss_price*(1 - td$retracement)
    } else if(td$trade_open == "buy" && (df$open/td$last_trade$entry_price - 1) > td$retracement & trailing) {
      td$take_profit_price <- td$take_profit_price*(1 + td$retracement)
      td$stop_loss_price <-  td$stop_loss_price*(1 + td$retracement)
    }
    
    # check stop loss conditions 
    if (td$trade_open == "sell") {
      td$is_stop_loss <- df$high >= td$stop_loss_price & td$is_used_stop_loss
      td$is_take_profit <- df$low <= td$take_profit_price & td$is_used_take_profit
      df$signal <- ifelse(td$is_stop_loss | td$is_take_profit, "buy", df$signal)
    } else if(td$trade_open == "buy") {
      td$is_stop_loss <- df$low <= td$stop_loss_price & td$is_used_stop_loss
      td$is_take_profit <- df$high >= td$take_profit_price & td$is_used_take_profit
      df$signal <- ifelse(td$is_stop_loss | td$is_take_profit, "sell", df$signal)
    }
    
    # open a "buy" trade if no other trade is already opened 
    if (df$signal == "buy" & td$trade_open == "") {
      
      # capital spent to enter into the trade 
      df$entry_capital <- td$capital 
      # entry price (close price)
      df$entry_price <- df$close 
      # transaction fees 
      df$txs_fee <- df$entry_capital*td$txs_fee 
      # quantity bought at entry price  
      df$entry_amount <- df$entry_capital/df$entry_price  
      # trade side 
      df$side <- "long"  
      # store the information in td$last_trade
      td$last_trade <- df  
    
      # update general information
      td$trade_open <- "buy"  # set the actual trade to "buy" 
      td$stop_loss_price <- df$entry_price*(1 - td$stop_loss) # stop loss price 
      td$take_profit_price <- df$entry_price*(1 + td$take_profit) # take profit price
      
      # "buy" trade when a "sell" trade was previously opened 
    }  else if (df$signal == "buy" & td$trade_open == "sell") {
      
      # exit price 
      if(td$is_stop_loss){
        td$last_trade$exit_price <- td$stop_loss_price
        td$last_trade$side <- "short (stop loss)"
      } else if(td$is_take_profit) {
        td$last_trade$exit_price <- td$take_profit_price  
        td$last_trade$side <- "short (take profit)"
      } else {
        td$last_trade$exit_price <- df$close 
        td$last_trade$side <- "short"
      } 
      
      # time to exit the trade in days 
      td$last_trade$ttm <- as.numeric(difftime(df$date, td$last_trade$date, units = "days")) 
      # capital when exit the trade
      td$last_trade$exit_capital <- td$last_trade$exit_price*td$last_trade$entry_amount
      # amount bought to exit the short position  
      td$last_trade$exit_amount <- td$last_trade$exit_capital/td$last_trade$exit_price 
      # funding fees paid
      td$last_trade$funding <- td$last_trade$entry_capital*(exp(td$funding*td$last_trade$ttm) - 1) 
      # gross P&L (with fees and funding)
      td$last_trade$pnl <- td$last_trade$entry_capital - td$last_trade$exit_capital 
      # net P&L (without fees and funding)
      td$last_trade$net_pnl <- td$last_trade$pnl - td$last_trade$txs_fee - td$last_trade$funding 
      # gROC: return on capital (with fees and funding)
      td$last_trade$ret <- td$last_trade$pnl/td$last_trade$entry_capital  
      # nROC: net return on capital (without fees and funding)
      td$last_trade$net_ret <- td$last_trade$net_pnl/td$last_trade$entry_capital
      # date in which the trade has been closed 
      td$last_trade$date_close <- df$date
      # store the trade intto the list of trades 
      td$data[[n_trade]] <- td$last_trade 
      
      # update general informations 
      td$trade_open <- ""  # set the actual trade to ""
      td$last_trade <- list()  # remove last trade 
      n_trade <- n_trade + 1 # update trade index 
      td$stop_loss_price <- 0 # reset the stop loss price 
      td$take_profit_price <- 0 # reset the take profit price 
      td$is_take_profit <- FALSE
      td$is_stop_loss <- FALSE
    }
    
      # open a "sell" trade if no other trade is already opened 
    if (df$signal == "sell" & td$trade_open == "") {
      
      # capital spent to enter into the trade 
      df$entry_capital <- td$capital 
      # entry price (close price)
      df$entry_price <- df$close 
      # transaction fees 
      df$txs_fee <- df$entry_capital*td$txs_fee 
      # quantity bought at entry price  
      df$entry_amount <- df$entry_capital/df$entry_price  
      # trade side 
      df$side <- "short"  
      # store the information in td$last_trade
      td$last_trade <- df  
    
      # update general information
      td$trade_open <- "sell"  # set the actual trade to "buy" 
      td$stop_loss_price <- df$entry_price*(1 + td$stop_loss) # stop loss price 
      td$take_profit_price <- df$entry_price*(1 - td$take_profit) # take profit price
      
      # "sell" trade when a "buy" trade was previously opened 
    }  else if (df$signal == "sell" & td$trade_open == "buy") {
      
      # exit price 
      if(td$is_stop_loss){
        td$last_trade$exit_price <- td$stop_loss_price
        td$last_trade$side <- "long (stop loss)"
      } else if(td$is_take_profit) {
        td$last_trade$exit_price <- td$take_profit_price  
        td$last_trade$side <- "long (take profit)"
      } else {
        td$last_trade$exit_price <- df$close 
        td$last_trade$side <- "long"
      } 
      
      # time to exit the trade in days 
      td$last_trade$ttm <- as.numeric(difftime(df$date, td$last_trade$date, units = "days")) 
      # capital when exit the trade
      td$last_trade$exit_capital <- td$last_trade$exit_price*td$last_trade$entry_amount
      # amount bought to exit the short position  
      td$last_trade$exit_amount <- td$last_trade$exit_capital/td$last_trade$exit_price 
      # funding fees paid
      td$last_trade$funding <- td$last_trade$entry_capital*(exp(td$funding*td$last_trade$ttm) - 1) 
      # gross P&L (with fees and funding)
      td$last_trade$pnl <- -(td$last_trade$entry_capital - td$last_trade$exit_capital)
      # net P&L (without fees and funding)
      td$last_trade$net_pnl <- td$last_trade$pnl - td$last_trade$txs_fee - td$last_trade$funding 
      # gROC: return on capital (with fees and funding)
      td$last_trade$ret <- td$last_trade$pnl/td$last_trade$entry_capital  
      # nROC: net return on capital (without fees and funding)
      td$last_trade$net_ret <- td$last_trade$net_pnl/td$last_trade$entry_capital
      # date in which the trade has been closed 
      td$last_trade$date_close <- df$date
      # store the trade intto the list of trades 
      td$data[[n_trade]] <- td$last_trade 
      
      # update general informations 
      td$trade_open <- ""  # set the actual trade to ""
      td$last_trade <- list()  # remove last trade 
      n_trade <- n_trade + 1 # update trade index 
      td$stop_loss_price <- 0 # reset the stop loss price 
      td$take_profit_price <- 0 # reset the take profit price 
      td$is_take_profit <- FALSE
      td$is_stop_loss <- FALSE
    }
    
    i <- i + 1
  }
  return(td)
}

Example

library(tidyverse)
db <- readr::read_csv("../../databases/temporary/BTCUSDT_1h.csv")
data <- dplyr::select(db, date, open, high, low, close, volume)
signal <- signal_moving_average(data, n = 38)

head(signal)
# A tibble: 6 × 7
  date                  open   high    low  close volume signal 
  <dttm>               <dbl>  <dbl>  <dbl>  <dbl>  <dbl> <chr>  
1 2018-01-02 12:00:00 13490  13590  13450. 13489.   671. neutral
2 2018-01-02 13:00:00 13489. 13875  13470. 13865.  1037. neutral
3 2018-01-02 14:00:00 13865. 13895. 13630  13664.   705. neutral
4 2018-01-02 15:00:00 13661. 13740. 13510  13690.   953. neutral
5 2018-01-02 16:00:00 13690. 13843. 13585. 13590.  1021. neutral
6 2018-01-02 17:00:00 13600. 13800. 13503  13780    818. neutral
bt <- backtest_strategy(signal, capital = 1000, stop_loss = 3, take_profit = 10, txs_fee = 0.00, funding = 0.0, trailing = TRUE, retracement = 2.5, quiet = TRUE)
bt2 <- backtest_strategy(signal, capital = 1000, stop_loss = 3, take_profit = 10, txs_fee = 0.00, funding = 0, trailing = FALSE, quiet = TRUE)
bt3 <- backtest_strategy(signal, capital = 1000, stop_loss = 0, take_profit = 0, txs_fee = 0.00, funding = 0, trailing = FALSE, quiet = TRUE)

ggplot()+
  geom_line(data = bind_rows(bt$data), aes(date, cumsum(net_pnl), color = "Trailing stop"))+
  geom_line(data = bind_rows(bt2$data), aes(date, cumsum(net_pnl), color = "Fixed stop"))+
  geom_line(data = bind_rows(bt3$data), aes(date, cumsum(net_pnl), color = "No stop"))