Introduction to the martingalebot package in R

The martingalebot package provides functions to download cryptocurrency price data from Binance and to perform backtesting and parameter optimization for a single pair martingale trading strategy as implemented by single pair dca bots on 3commas, Pionex, TradeSanta, Mizar, OKX, Bitget and others.

Downloading price data

There are three different functions to download data from Binance: get_binance_klines(), get_binance_klines_from_csv(), get_binance_prices_from_csv. The function get_binance_klines() can download candlestick data directly. The user can specify the trading pair, the start and end time and the time frame for the candles. For example, to download hourly candles from ETHUSDT from the first of January to the first of March 2023, one could specify:

get_binance_klines(symbol = 'ETHUSDT',
                   start_time = '2023-01-01',
                   end_time = '2023-03-01',
                   interval = '1h')
#>                 open_time    open    high     low   close          close_time
#>    1: 2023-01-01 00:00:00 1196.18 1197.43 1193.60 1196.13 2023-01-01 00:59:59
#>    2: 2023-01-01 01:00:00 1196.13 1196.70 1192.72 1194.09 2023-01-01 01:59:59
#>    3: 2023-01-01 02:00:00 1194.09 1196.37 1193.84 1196.02 2023-01-01 02:59:59
#>    4: 2023-01-01 03:00:00 1196.01 1196.74 1194.11 1195.40 2023-01-01 03:59:59
#>    5: 2023-01-01 04:00:00 1195.41 1195.41 1191.71 1194.04 2023-01-01 04:59:59
#>   ---                                                                        
#> 1413: 2023-02-28 20:00:00 1642.06 1643.37 1622.46 1628.01 2023-02-28 20:59:59
#> 1414: 2023-02-28 21:00:00 1628.01 1633.35 1621.54 1627.57 2023-02-28 21:59:59
#> 1415: 2023-02-28 22:00:00 1627.57 1627.58 1596.66 1605.19 2023-02-28 22:59:59
#> 1416: 2023-02-28 23:00:00 1605.19 1613.75 1598.23 1612.45 2023-02-28 23:59:59
#> 1417: 2023-03-01 00:00:00 1612.46 1612.46 1603.00 1605.23 2023-03-01 00:59:59

An advantage of get_binance_klines() is that it can download price data up to the current time. A disadvantage is that the lowest time frame for the candles is 1 minute.

The function get_binance_klines_from_csv() downloads candlestick data via csv files from https://data.binance.vision/. The advantage of this method is that it is faster for large amounts of data and that that the lowest time frame for the candles is 1 second. A disadvantage is that it can only download price data up to 1-2 days ago as the csv files on https://data.binance.vision are only updated once per day.

The function get_binance_prices_from_csv() also downloads price data via csv files from https://data.binance.vision/ and thus shares the same advantages and disadvantages, but it downloads aggregated trades instead of candlestick data. This allows for an even lower time resolution as it returns all traded prices of a coin over time. Knowing the exact price at each point in time is particularly helpful for backtesting martingale bots with trailing buy and sell orders. The function get_binance_prices_from_csv() returns a data frame with only two columns. See, for example:

get_binance_prices_from_csv('LTCBTC',
                            start_time = '2023-01-01',
                            end_time = '2023-02-01', progressbar = F)
#>                        time    price
#>      1: 2023-01-01 00:00:07 0.004243
#>      2: 2023-01-01 00:00:58 0.004244
#>      3: 2023-01-01 00:01:31 0.004244
#>      4: 2023-01-01 00:01:32 0.004243
#>      5: 2023-01-01 00:01:32 0.004243
#>     ---                             
#> 445947: 2023-02-01 23:59:19 0.004250
#> 445948: 2023-02-01 23:59:24 0.004249
#> 445949: 2023-02-01 23:59:30 0.004248
#> 445950: 2023-02-01 23:59:30 0.004247
#> 445951: 2023-02-01 23:59:35 0.004247

Since this function returns very large amounts of data for frequently traded pairs such as BTCUSDT, it is, by default, parallelized and shows a progress bar. Currently, the functions backtest and grid_search are implemented in such a way that they expect the price data to be in the format as returned by this function.

Performing a backtest

To perform a backtest of a martingale bot, we first download price data for a specific time period and trading pair with get_binance_prices_from_csv() and then apply backtest to it. The tested martingale bot can be set up with the following parameters:

If we don’t specify any of these arguments, the default parameter settings will be used. To show the default settings, type args(backtest) or go to the help file with ?backtest.

dat <- get_binance_prices_from_csv('PYRUSDT',
                                   start_time = '2022-12-01',
                                   end_time = '2023-03-01', 
                                   progressbar = F)
backtest(data = dat)
#> # A tibble: 1 × 9
#>   profit n_trades max_draw_down requir…¹ cover…² down_…³ max_t…⁴ perce…⁵ n_sto…⁶
#>    <dbl>    <int>         <dbl>    <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <int>
#> 1   16.8       87          10.5     503.    19.2    12.5    9.88    3.01       0
#> # … with abbreviated variable names ¹​required_capital, ²​covered_deviation,
#> #   ³​down_tolerance, ⁴​max_time, ⁵​percent_inactive, ⁶​n_stoploss

The backtest function returns the following measures:

If the argument plot is TRUE, an interactive plot showing the changes in capital and price of the traded cryptocurrency over time is produced. Buys, sells and stop-losses are displayed as red, green and blue dots, respectively.

backtest(data = dat, plot = T)
DezJanFebMär100105110115DezJanFebMär3.03.54.04.5
Bot profit: 16.8 %; Max draw down: 10.5 % Safety orders: 8, Price scale: 2.4, Volume scale: 1.5, Take profit: 2.4, Step scale: 1, Stoploss: 0, Start ASAP: TRUECapitalPrice

Deal start conditions

By default, new trades are started as soon as possible. If the price data set contains a logical vector deal_start and the argument start_asap is set to FALSE, new deals are only started where the logical vector deal_start in data is TRUE. We can add the Relative Strength Index (RSI) as a deal start condition to the data by using the function add_rsi. We can specify the time frame for the candles, the number of candles that are considered and the cutoff for creating the logical vector deal_start. In the following example, new deals are only started if the hourly RSI is below 30. You can see in the plot that there are no buys (red dots) at peaks of the price curve anymore. However, the performance is slightly worse because there are now less trades in total.

dat2 <- add_rsi(dat, time_period = "1 hour", n = 7, cutoff = 30)
backtest(data = dat2, start_asap = F, plot = T)
DezJanFebMär100.0102.5105.0DezJanFebMär3.03.54.04.5
Bot profit: 7 %; Max draw down: 5.5 % Safety orders: 8, Price scale: 2.4, Volume scale: 1.5, Take profit: 2.4, Step scale: 1, Stoploss: 0, Start ASAP: FALSECapitalPrice

Parameter optimization

To find the best parameter set for a given time period, we can perform a grid search using the function grid_search. This function takes possible values of martingale bot parameters, runs the function backtest with each possible combination of these values and returns the results as a date frame. Each row of this data frame contains the result of one possible combination of parameters. Since doing a grid search can be computationally expensive, the grid_search function is parallelized by default.

By default, grid_search uses a broad range of parameters. For example, for n_safety_orders, values between 6 and 16 in steps of 2 are tested (see args(grid_search)for default ranges of parameters). However, we could also use, for, examples, values between 4 and 6, by explicitly specifying it:

res <- grid_search(data = dat, n_safety_orders = 4:6, progressbar = F)
res
#> # A tibble: 532 × 19
#>    profit n_trades max_draw_down requi…¹ cover…² down_…³ max_t…⁴ perce…⁵ n_sto…⁶
#>     <dbl>    <int>         <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <int>
#>  1   37.7       88          14.0    119.    15      8.01    24.0    33.8       0
#>  2   37.7       88          14.0    119.    15      8.01    24.0    33.8       0
#>  3   37.7       88          14.0    119.    15      8.01    24.0    33.8       0
#>  4   37.7       88          14.0    119.    15      8.01    24.0    33.8       0
#>  5   35.2      163          16.3     70     18      8       24.0    29.4       0
#>  6   35.2      163          16.3     70     18      8       24.0    29.4       0
#>  7   35.2      163          16.3     70     18      8       24.0    29.4       0
#>  8   35.2      163          16.3     70     18      8       24.0    29.4       0
#>  9   34.8      226          14.1    109.    15.6    8.50    24.0    32.5       0
#> 10   34.8      226          14.1    109.    15.6    8.50    24.0    32.5       0
#> # … with 522 more rows, 10 more variables: base_order_volume <dbl>,
#> #   first_safety_order_volume <dbl>, n_safety_orders <int>, pricescale <dbl>,
#> #   volumescale <dbl>, take_profit <dbl>, stepscale <dbl>, start_asap <lgl>,
#> #   stoploss <dbl>, compound <lgl>, and abbreviated variable names
#> #   ¹​required_capital, ²​covered_deviation, ³​down_tolerance, ⁴​max_time,
#> #   ⁵​percent_inactive, ⁶​n_stoploss

The rows of the returned data frame are ordered by the column profit. In the first row, we see the set of parameters that led to highest profit. In order to plot the best performing parameter set, we can pass the values of the first row in res as arguments to backtest by using the function do.call.

do.call(backtest, c(as.list(res[1,]), list(data = dat, plot = T)))
DezJanFebMär90100110120130140DezJanFebMär3.03.54.04.5
Bot profit: 37.7 %; Max draw down: 14 % Safety orders: 5, Price scale: 3, Volume scale: 1.4, Take profit: 2, Step scale: 1, Stoploss: 0, Start ASAP: TRUECapitalPrice

Alternatively, we could do this more elegantly with the tidyverse package.

library(tidyverse)
grid_search(data = dat, n_safety_orders = 4:6, progressbar = F) %>% 
  slice(1) %>% 
  pmap_df(backtest, data = dat, plot = T)

Instead of picking the most profitable parameter constellation, we could also pick the one with the best compromise between profit and max_draw_down by replacing the command slice(1) with slice_max(profit - max_draw_down).

It should be noted that the grid_search function also has the following arguments that allow to restrict the search space:

This can be handy because we might only want to search for optimal parameter combinations within a set of parameters that have minimum “down tolerance” and thus have certain robustness against sudden price drops. In this case, it would be a waste of computation time if we tested all possible combinations of parameters.

Instead of performing a grid search, we can also search for the best parameter combination with optimization algorithms. For example, we could use a technique called Simulated Annealing:

library(optimization)

#Define the optimization function:
optim_fun <- function(x) {
      y <- backtest(n_safety_orders = x[1], pricescale = x[2],
                    volumescale = x[3], take_profit = x[4], stepscale = x[5],
                    data = dat)
      #Note that we return minus the profit because the optimization algorithm
      #expects a value that we want to reduce as much as possible
      - y$profit
}

#Define lower and upper bound and start values of parameters
lower <- c(6, 1, 1, 1, 0.8)
upper <- c(16, 3.5, 2, 3.5, 1.1)
start <- c(8, 2.4, 1.5, 2.4, 1)

#Perform optimization
res <- optim_sa(optim_fun, start = start, lower = lower, upper = upper,
                control = list(nlimit = 200))$par

#Plot the best parameter combination found by the optimization algorithm:
backtest(n_safety_orders = round(res[1]), pricescale = res[2],
         volumescale = res[3], take_profit = res[4], stepscale = res[5],
         data = dat, plot = T)
DezJanFebMär90120150180DezJanFebMär3.03.54.04.5
Bot profit: 99.7 %; Max draw down: 17.9 % Safety orders: 9, Price scale: 1, Volume scale: 1.8, Take profit: 1.9, Step scale: 0.8, Stoploss: 0, Start ASAP: TRUECapitalPrice

Instead of optimizing the profit, we could also optimize another criterion. For example, we could seek for the best compromise between profit, max_draw_down, time_inactive, and down_tolerance by defining the optimization function like this:

optim_fun <- function(x) {
      y <- backtest(n_safety_orders = x[1], pricescale = x[2],
                    volumescale = x[3], take_profit = x[4], stepscale = x[5],
                    data = dat)
      with(y, max_draw_down + time_inactive - down_tolerance - profit)
}

Here, all measures are weighted equally. However, we could also give them different weights by multiplying them with different numbers or we could apply various transformation functions to them.

We could also perform an optimization using the genetic algorithm from package GA. Here’s an example:

library(GA)

#Define the optimization function:
optim_fun <- function(x) {
      y <- backtest(n_safety_orders = x[1], pricescale = x[2],
                    volumescale = x[3], take_profit = x[4], stepscale = x[5],
                    data = dat)
      #Note that this algorithm expects a value that we want to increase as much
      #as possible. Therefore, we return the positive profit here:
      y$profit
}

#Define lower and upper bound of parameters
lower <- c(6, 1, 1, 1, 0.8)
upper <- c(16, 3.5, 2, 3.5, 1.1)

#Perform optimization
res <- ga(type = 'real-valued', optim_fun, lower = lower,
          upper = upper, maxiter = 200)@solution

#Plot the best parameter combination found by the optimization algorithm:
backtest(n_safety_orders = round(res[1]), pricescale = res[2],
         volumescale = res[3], take_profit = res[4], stepscale = res[5],
         data = dat, plot = T)
DezJanFebMär80120160200240DezJanFebMär3.03.54.04.5
Bot profit: 140.5 %; Max draw down: 18.6 % Safety orders: 7, Price scale: 1.2, Volume scale: 1.8, Take profit: 2, Step scale: 0.8, Stoploss: 0, Start ASAP: TRUECapitalPrice

Cross-validation

In the previous examples, we used the same data for training and testing the algorithm. However, this most likely resulted in over-fitting and over-optimistic performance estimation. A better strategy would be to strictly separate testing and learning by using cross-validation.

We first download a longer time period of price data so that we have more data for training and testing:

dat <- get_binance_prices_from_csv("ATOMUSDT", 
                                   start_time = '2022-01-01',
                                   end_time = '2023-03-03', progressbar = F)

Next, we split our data into many different test and training time periods. We can use the function create_timeslices to create start and end times of the different splits. It has the following 4 arguments.

For example, if we want to use 4 months for training, 4 months for testing and create training and testing periods every month, we could specify:

slices <- create_timeslices(train_months = 4, test_months = 4,
                            shift_months = 1, data = dat)
slices
#> # A tibble: 7 × 5
#>   period start_train         end_train           start_test         
#>    <dbl> <dttm>              <dttm>              <dttm>             
#> 1      1 2022-01-01 00:00:02 2022-05-02 19:00:02 2022-05-02 19:00:02
#> 2      2 2022-01-31 10:30:02 2022-06-02 05:30:02 2022-06-02 05:30:02
#> 3      3 2022-03-02 21:00:02 2022-07-02 16:00:02 2022-07-02 16:00:02
#> 4      4 2022-04-02 08:30:02 2022-08-02 02:30:02 2022-08-02 02:30:02
#> 5      5 2022-05-02 19:00:02 2022-09-01 13:00:02 2022-09-01 13:00:02
#> 6      6 2022-06-02 05:30:02 2022-10-01 23:30:02 2022-10-01 23:30:02
#> 7      7 2022-07-02 16:00:02 2022-11-01 09:00:02 2022-11-01 09:00:02
#> # … with 1 more variable: end_test <dttm>

Note that these time periods are partially overlapping. If we want to have non-overlapping time periods, we could specify shift_months = 4.

We can now perform cross-validation by iterating over the rows of slices. At each iteration, we perform a grid search for the best parameter combination using the training data and then apply this parameter combination to the test data. For simplicity, we only return the final performance in the test data.

library(tidyverse)
slices %>% 
  group_by(start_test, end_test) %>% 
  summarise({
    #Get test and training data of the present row / iteration
    train_data <- filter(dat, between(time, start_train, end_train))
    test_data <- filter(dat, between(time, start_test, end_test))
    #Find the best parameter combination in the training data
    best <- grid_search(data = train_data, progressbar = F)[1,]
    #Apply this parameter combination to the test data
    pmap_df(best, backtest, data = test_data)
  })
#> # A tibble: 7 × 11
#> # Groups:   start_test [7]
#>   start_test          end_test             profit n_trades max_draw_down requi…¹
#>   <dttm>              <dttm>                <dbl>    <int>         <dbl>   <dbl>
#> 1 2022-05-02 19:00:02 2022-09-01 13:00:02 -30.3          5          68.0    406.
#> 2 2022-06-02 05:30:02 2022-10-01 23:30:02  21.8        192          30.2   4685.
#> 3 2022-07-02 16:00:02 2022-11-01 09:00:02  26.4        232          11.0   1402.
#> 4 2022-08-02 02:30:02 2022-12-01 19:30:02  -2.91       335          32.6   2763.
#> 5 2022-09-01 13:00:02 2023-01-01 06:00:02  -7.95        80          32.4   4461.
#> 6 2022-10-01 23:30:02 2023-01-31 16:30:02  10.4         28          39.0    170 
#> 7 2022-11-01 09:00:02 2023-03-03 03:00:02  -0.951       15          39.8    170 
#> # … with 5 more variables: covered_deviation <dbl>, down_tolerance <dbl>,
#> #   max_time <dbl>, percent_inactive <dbl>, n_stoploss <int>, and abbreviated
#> #   variable name ¹​required_capital

We can see that only 3 of the 7 tested time periods were in profit. This is because we only maximized profitability during training, which likely led to the selection of “aggressive” or risky strategies that work well in the training set but poorly in the test set due to little robustness against sudden price drops. This is illustrated by the the relatively small price down tolerance, which varied between 8.2 and 10.9 % for the selected parameter combinations (see column down_tolerance in the above table). A potential solution to this problem is therefore to restrict the search space to those parameter combinations that have a minimum price down tolerance of, for example, 12 %. We can do this by using the argument min_down_tolerance of the grid_search function:

library(tidyverse)
slices %>% 
  group_by(start_test, end_test) %>% 
  summarise({
    train_data <- filter(dat, between(time, start_train, end_train))
    test_data <- filter(dat, between(time, start_test, end_test))
    best <- grid_search(data = train_data, min_down_tolerance = 12,
                        progressbar = F)[1,]
    pmap_df(best, backtest, data = test_data)
  })
#> # A tibble: 7 × 11
#> # Groups:   start_test [7]
#>   start_test          end_test            profit n_tra…¹ max_d…² requi…³ cover…⁴
#>   <dttm>              <dttm>               <dbl>   <int>   <dbl>   <dbl>   <dbl>
#> 1 2022-05-02 19:00:02 2022-09-01 13:00:02 -29.0        5   67.4     602.    20.1
#> 2 2022-06-02 05:30:02 2022-10-01 23:30:02  14.5      240   28.8    5430.    14.6
#> 3 2022-07-02 16:00:02 2022-11-01 09:00:02  14.5      236    8.01   5430.    14.6
#> 4 2022-08-02 02:30:02 2022-12-01 19:30:02  21.0      534    8.38    709.    17.6
#> 5 2022-09-01 13:00:02 2023-01-01 06:00:02  15.4      174   10.0    5430.    14.6
#> 6 2022-10-01 23:30:02 2023-01-31 16:30:02  14.9       43   36.6     170     28.8
#> 7 2022-11-01 09:00:02 2023-03-03 03:00:02   3.18      35   36.7     170     28.8
#> # … with 4 more variables: down_tolerance <dbl>, max_time <dbl>,
#> #   percent_inactive <dbl>, n_stoploss <int>, and abbreviated variable names
#> #   ¹​n_trades, ²​max_draw_down, ³​required_capital, ⁴​covered_deviation

Except for the first time period, all time periods are now in profit. However, this more conservative strategy came with the price of slightly lower profits in the second and third time periods.

Alternatively, we could also select the most profitable parameter combination only among those combinations that had little draw down and did not result in “red bags” for extended periods of time. For example, to select the most profitable parameter combination among those combinations that had no more than 30% draw down and that were no longer than 3% of the time fully invested in the training period, we could do:

library(tidyverse)
slices %>% 
  group_by(start_test, end_test) %>% 
  summarise({
    train_data <- filter(dat, between(time, start_train, end_train))
    test_data <- filter(dat, between(time, start_test, end_test))
    best <- grid_search(data = train_data, progressbar = F) %>% 
      filter(max_draw_down < 30 & percent_inactive < 3) %>% 
      slice(1)
    pmap_df(best, backtest, data = test_data)
  })
#> # A tibble: 7 × 11
#> # Groups:   start_test [7]
#>   start_test          end_test            profit n_tra…¹ max_d…² requi…³ cover…⁴
#>   <dttm>              <dttm>               <dbl>   <int>   <dbl>   <dbl>   <dbl>
#> 1 2022-05-02 19:00:02 2022-09-01 13:00:02 -27.6        5   66.6    1375.    20.8
#> 2 2022-06-02 05:30:02 2022-10-01 23:30:02  17.7      546   10.5    4461.    14.3
#> 3 2022-07-02 16:00:02 2022-11-01 09:00:02  18.7      361   10.2    4461.    13.4
#> 4 2022-08-02 02:30:02 2022-12-01 19:30:02  20.5      158    5.80   4461.    19.5
#> 5 2022-09-01 13:00:02 2023-01-01 06:00:02 -17.7      256   31.8    5430.    16  
#> 6 2022-10-01 23:30:02 2023-01-31 16:30:02  11.7       69   32.1     170     41.6
#> 7 2022-11-01 09:00:02 2023-03-03 03:00:02   4.23     119   34.0     130     21.6
#> # … with 4 more variables: down_tolerance <dbl>, max_time <dbl>,
#> #   percent_inactive <dbl>, n_stoploss <int>, and abbreviated variable names
#> #   ¹​n_trades, ²​max_draw_down, ³​required_capital, ⁴​covered_deviation

Another option would be to select the parameter combination that maximizes a combination of measures, such as profit - max_draw_down - percent_inactive .

library(tidyverse)
slices %>% 
  group_by(start_test, end_test) %>% 
  summarise({
    train_data <- filter(dat, between(time, start_train, end_train))
    test_data <- filter(dat, between(time, start_test, end_test))
    best <- grid_search(data = train_data, progressbar = F) %>% 
      slice_max(profit - max_draw_down - percent_inactive)
    pmap_df(best, backtest, data = test_data)
  })
#> # A tibble: 28 × 11
#> # Groups:   start_test, end_test [7]
#>    start_test          end_test            profit n_trades max_draw_down requi…¹
#>    <dttm>              <dttm>               <dbl>    <int>         <dbl>   <dbl>
#>  1 2022-05-02 19:00:02 2022-09-01 13:00:02 -21.0         5          64.4   2763.
#>  2 2022-05-02 19:00:02 2022-09-01 13:00:02  -4.52      162          36.2   2763.
#>  3 2022-05-02 19:00:02 2022-09-01 13:00:02 -22.3       155          43.3   2763.
#>  4 2022-05-02 19:00:02 2022-09-01 13:00:02  -5.78      127          49.9   2763.
#>  5 2022-06-02 05:30:02 2022-10-01 23:30:02  21.8       192          30.2   4685.
#>  6 2022-06-02 05:30:02 2022-10-01 23:30:02  -6.17      239          26.2   4685.
#>  7 2022-06-02 05:30:02 2022-10-01 23:30:02 -16.7       216          31.6   4685.
#>  8 2022-06-02 05:30:02 2022-10-01 23:30:02  21.8       192          30.2   4685.
#>  9 2022-07-02 16:00:02 2022-11-01 09:00:02  20.1       358          10.8   1375.
#> 10 2022-07-02 16:00:02 2022-11-01 09:00:02  20.1       358          10.8   1375.
#> # … with 18 more rows, 5 more variables: covered_deviation <dbl>,
#> #   down_tolerance <dbl>, max_time <dbl>, percent_inactive <dbl>,
#> #   n_stoploss <int>, and abbreviated variable name ¹​required_capital

Instead of performing a grid search, we could also search for the best parameter combination using an optimiziation algorithm, such as simulated annealing:

library(tidyverse)
slices %>% 
  group_by(start_test, end_test) %>% 
  summarise({
    train_data <- filter(dat, between(time, start_train, end_train))
    test_data <- filter(dat, between(time, start_test, end_test))
    
    #Define the optimization function:
    optim_fun <- function(x) {
      y <- backtest(n_safety_orders = x[1], pricescale = x[2],
                      volumescale = x[3], take_profit = x[4], stepscale = x[5],
                      data = train_data)
      #Note that we have to change signs here as compared to above
      with(y, -profit + max_draw_down + percent_inactive)
    }

    #Define lower and upper bound and start values of parameters
    lower <- c(6, 1, 1, 1, 0.8)
    upper <- c(16, 3.5, 2, 3.5, 1.1)
    start <- c(8, 2.4, 1.5, 2.4, 1)

    #Perform optimization
    res <- optim_sa(optim_fun, start = start, lower = lower, upper = upper,
                    control = list(nlimit = 200))$par

    #Apply best parameter combination to test data
    backtest(n_safety_orders = round(res[1]), pricescale = res[2],
             volumescale = res[3], take_profit = res[4], stepscale = res[5],
             data = test_data)
  })
#> # A tibble: 7 × 11
#> # Groups:   start_test [7]
#>   start_test          end_test             profit n_trades max_draw_down requi…¹
#>   <dttm>              <dttm>                <dbl>    <int>         <dbl>   <dbl>
#> 1 2022-05-02 19:00:02 2022-09-01 13:00:02 -34.2         23        69.7      862.
#> 2 2022-06-02 05:30:02 2022-10-01 23:30:02   0.590      166         0.212  68091.
#> 3 2022-07-02 16:00:02 2022-11-01 09:00:02   7.53       339         3.35    2876.
#> 4 2022-08-02 02:30:02 2022-12-01 19:30:02  21.0        304         7.17   22464.
#> 5 2022-09-01 13:00:02 2023-01-01 06:00:02   5.16       105         6.27    3173.
#> 6 2022-10-01 23:30:02 2023-01-31 16:30:02   4.66        39        42.8      490.
#> 7 2022-11-01 09:00:02 2023-03-03 03:00:02   8.17        43        34.3      109.
#> # … with 5 more variables: covered_deviation <dbl>, down_tolerance <dbl>,
#> #   max_time <dbl>, percent_inactive <dbl>, n_stoploss <int>, and abbreviated
#> #   variable name ¹​required_capital

Similarly, we could apply the genetic algorithm from package GA:

library(tidyverse)
slices %>% 
  group_by(start_test, end_test) %>% 
  summarise({
    train_data <- filter(dat, between(time, start_train, end_train))
    test_data <- filter(dat, between(time, start_test, end_test))
    
    #Define the optimization function:
    optim_fun <- function(x) {
      y <- backtest(n_safety_orders = x[1], pricescale = x[2],
                      volumescale = x[3], take_profit = x[4], stepscale = x[5],
                      data = train_data)
      with(y, profit - max_draw_down - percent_inactive)
    }

    #Define lower and upper bound of parameters
    lower <- c(6, 1, 1, 1, 0.8)
    upper <- c(16, 3.5, 2, 3.5, 1.1)

    #Perform optimization
    res <- ga(type = 'real-valued', optim_fun, lower = lower,
              upper = upper, maxiter = 200, monitor = F)@solution

    #Apply best parameter combination to test data
    backtest(n_safety_orders = round(res[1]), pricescale = res[2],
             volumescale = res[3], take_profit = res[4], stepscale = res[5],
             data = test_data)
  })
#> # A tibble: 7 × 11
#> # Groups:   start_test [7]
#>   start_test          end_test              profit n_trades max_draw_d…¹ requi…²
#>   <dttm>              <dttm>                 <dbl>    <int>        <dbl>   <dbl>
#> 1 2022-05-02 19:00:02 2022-09-01 13:00:02  0.00460        6        0.106  56847.
#> 2 2022-06-02 05:30:02 2022-10-01 23:30:02 15.3          215       28.2     9781.
#> 3 2022-07-02 16:00:02 2022-11-01 09:00:02 15.2          286        8.06    6744.
#> 4 2022-08-02 02:30:02 2022-12-01 19:30:02 -2.88         255       31.7    28078.
#> 5 2022-09-01 13:00:02 2023-01-01 06:00:02 34.1          148        9.19    4840.
#> 6 2022-10-01 23:30:02 2023-01-31 16:30:02  0.364         40       43.9      574.
#> 7 2022-11-01 09:00:02 2023-03-03 03:00:02  7.91          34       33.5      160.
#> # … with 5 more variables: covered_deviation <dbl>, down_tolerance <dbl>,
#> #   max_time <dbl>, percent_inactive <dbl>, n_stoploss <int>, and abbreviated
#> #   variable names ¹​max_draw_down, ²​required_capital