Testing Asset Allocation Strategies

This vignette illustrates some ways to use the AssetAllocation package to backtest different asset allocation strategies.

Rationale for the package

There are several alternatives to backtest systematic/quantitative investment strategies in R. The aim of this package is to provide a simplified way to backtest simple asset allocation rules. That is, with a few lines of code, the user can create and backtest simple static or dynamic (tactical) asset allocation strategies.

The package comes with a set of pre-loaded static and tactical strategies, which are in the asset_allocations object. However, the user can easily create their own strategies, either by choosing specific allocations in a static asset allocation, or by creating their own custom function that implements a dynamic strategy.

Basic definitions

Within the context of the package, an asset allocation strategy is an object of the type list which contains the following elements:

  1. name: an object of type character with the name of the strategy

  2. tickers: a vector of type character containing the tickers of the assets to be used. These must either correspond to the column names in the user-provided data to be used to backtest the strategy, or to tickers in Yahoo Finance.

  3. default_weights: a vector of type numeric containing the default weights to invest in each asset in decimals. The sum of the weights should be less than or equal to one. Any amount not invested in the assets is automatically assumed to be invested in the risk-free rate. If the rebalance function is risk_parity, this field should contain the risk budgets (in decimals, the sum should equal one).

  4. rebalance_frequency: an object of type character which determines the rebalancing frequency. Options are “days”, “weeks”, “months”, “quarters”, and “years”.

  5. portfolio_rule_fn: an object of type character containing the name of the rebalancing function that determines allocations for the next period. A valid rebalancing function takes as inputs a strategy, a rebalancing date, an xts matrix of prices, an xts matrix of returns, and an xts vector of returns on a risk-free asset. The function returns a vector of type numeric with the same number of elements as the object strat$tickers. Some specific cases that come with the package are:

    • static asset allocation strategies: "constant_weights"

    • Ivy Portfolio: "tactical_ivy"

    • Dual Momentum: "tactical_DualMomentum"

    • Robust Asset Allocation: "tactical_RAA"

    • Adaptive Asset Allocation: "tactical_AAA"

    • Minimum variance: "min_variance"

    • Risk parity: "risk_parity"

A few comments:

Basic workflow

To use the package, the user follows basically two steps:

  1. Create a strategy with the elements described above (or choose one of the pre-loaded strategies)

  2. Backtest the strategy by creating a new object using the function backtest_allocation and some data. The backtest_allocation function expects a strategy with the elements described above, as well as an xts matrix of prices, an xts matrix of returns, and an optional xts vector of returns on a risk-free asset. The user can also provide an optional starting date. Importantly, the tickers in the strategy should correspond to valid columns of the price and return objects.

Pre-loaded strategies

As defined above, an asset allocation strategy is a portfolio comprised of a set of assets, with portfolios weights determined by a specific rule, rebalanced at some frequency. The package comes with several pre-loaded asset allocation strategies, which generally come from published sources. These are in the object asset_allocations.

Data

All of the pre-loaded asset allocation strategies are defined in terms of exchange-traded funds, data for which are available in the ETFs data set. Users can type ?ETFs to obtain more information. The purpose of the pre-loaded strategies and data is to demonstrate how to use the package. Users can test their own strategies using their own data, or they can also specify their own assets and have the package retrieve data automatically from Yahoo Finance.

Testing asset allocations

We load the package and inspect the available pre-loaded static (i.e., constant-weight) asset allocations:

library(AssetAllocation)
#> Registered S3 method overwritten by 'quantmod':
#>   method            from
#>   as.zoo.data.frame zoo
library(PerformanceAnalytics)
#> Loading required package: xts
#> Loading required package: zoo
#> 
#> Attaching package: 'zoo'
#> The following objects are masked from 'package:base':
#> 
#>     as.Date, as.Date.numeric
#> 
#> Attaching package: 'PerformanceAnalytics'
#> The following object is masked from 'package:graphics':
#> 
#>     legend
names(asset_allocations$static)
#>  [1] "us_60_40"         "golden_butterfly" "rob_arnott"       "globalAA"        
#>  [5] "permanent"        "desert"           "larry"            "big_rocks"       
#>  [9] "sandwich"         "balanced_tax"     "balanced"         "income_gr"       
#> [13] "income_gr_tax"    "con_income"       "con_income_tax"   "all_weather"

Example 1: Ray Dalio’s All Weather Portfolio

One of the pre-loaded static asset allocations is Ray Dalio’s All Weather Portfolio. The strategy invests 30% in U.S. stocks (represented by the SPY ETF), 40% in long-term U.S. Treasuries (TLT), 15% in intermediate-term U.S. Treasuries (IEF), 7.5% in gold (GLD), and 7.5% in commodities (DBC).

asset_allocations$static$all_weather
#> $name
#> [1] "All Weather Portfolio"
#> 
#> $tickers
#> [1] "SPY" "TLT" "IEF" "GLD" "DBC"
#> 
#> $default_weights
#> [1] 0.300 0.400 0.150 0.075 0.075
#> 
#> $rebalance_frequency
#> [1] "month"
#> 
#> $portfolio_rule_fn
#> [1] "constant_weights"

To backtest this strategy with the data in the ETFs object, we simply do:

# define strategy 
all_weather <- asset_allocations$static$all_weather

# backtest strategy
bt_all_weather <- backtest_allocation(all_weather, ETFs$Prices, ETFs$Returns, ETFs$risk_free)

The output from backtest_allocation contains the daily returns of the strategy in the $returns object. A convenient way to visualize the results is by using the charts.PerformanceSummary function from the PerformanceAnalytics package:

# plot cumulative returns
charts.PerformanceSummary(bt_all_weather$returns, 
                          main = all_weather$strat$name)

A basic set of performance statistics is provided in $table_performance:

# table with performance metrics
bt_all_weather$table_performance
#>                               All.Weather.Portfolio
#> Annualized Return                            0.0707
#> Annualized Std Dev                           0.0749
#> Annualized Sharpe (Rf=0.79%)                 0.8338
#> daily downside risk                          0.0033
#> Annualised downside risk                     0.0528
#> Downside potential                           0.0016
#> Omega                                        1.1618
#> Sortino ratio                                0.0755
#> Upside potential                             0.0018
#> Upside potential ratio                       0.6946
#> Omega-sharpe ratio                           0.1618
#> Semi Deviation                               0.0034
#> Gain Deviation                               0.0031
#> Loss Deviation                               0.0036
#> Downside Deviation (MAR=210%)                0.0093
#> Downside Deviation (Rf=0.79%)                0.0033
#> Downside Deviation (0%)                      0.0033
#> Maximum Drawdown                             0.1640
#> Historical VaR (95%)                        -0.0074
#> Historical ES (95%)                         -0.0109
#> Modified VaR (95%)                          -0.0073
#> Modified ES (95%)                           -0.0137

The allocations over time are stored in $weights. Of course, for static, buy-and-hold asset allocations, the portfolio weights always remains the same:

chart.StackedBar(bt_all_weather$rebalance_weights,
                 date.format = "%Y",
                 main = paste0("Allocations, ", all_weather$name))

As should be clear from the graph above, the weights that are stored in $weights are the weights on the rebalancing dates. Even for a static, buy-and-hold strategy, the actual weights between rebalancing dates will fluctuate.

The other pre-loaded static asset allocations may be tested analogously.

Example 2: Creating and testing a custom static asset allocation

In this example, we create a custom strategy from scratch. The strategy invests equally in momentum (MTUM), value (VLUE), low volatility (USMV) and quality (QUAL) ETFs. We first set up this custom strategy as follows:

factors_EW  <- list(name = "EW Factors",
                      tickers = c("MTUM", "VLUE", "USMV", "QUAL"),
                      default_weights = c(0.25, 0.25, 0.25, 0.25),
                      rebalance_frequency = "month",
                      portfolio_rule_fn = "constant_weights")

Next, we can automatically download data from Yahoo Finance using the get_data_from_tickers function:

factor_ETFs_data <- get_data_from_tickers(factors_EW$tickers,
                                      starting_date = "2013-08-01")

Finally, we backtest the strategy and show the results:

# backtest the strategy
bt_factors_EW <- backtest_allocation(factors_EW,factor_ETFs_data$P, factor_ETFs_data$R)

# plot returns
charts.PerformanceSummary(bt_factors_EW$returns,
                          main = bt_factors_EW$strat$name,
                               )


# table with performance metrics
bt_factors_EW$table_performance
#>                               EW.Factors
#> Annualized Return                 0.1233
#> Annualized Std Dev                0.1731
#> Annualized Sharpe (Rf=0%)         0.7125
#> daily downside risk               0.0078
#> Annualised downside risk          0.1246
#> Downside potential                0.0032
#> Omega                             1.1652
#> Sortino ratio                     0.0664
#> Upside potential                  0.0037
#> Upside potential ratio            0.5665
#> Omega-sharpe ratio                0.1652
#> Semi Deviation                    0.0081
#> Gain Deviation                    0.0077
#> Loss Deviation                    0.0094
#> Downside Deviation (MAR=210%)     0.0125
#> Downside Deviation (Rf=0%)        0.0078
#> Downside Deviation (0%)           0.0078
#> Maximum Drawdown                  0.3499
#> Historical VaR (95%)             -0.0161
#> Historical ES (95%)              -0.0266
#> Modified VaR (95%)               -0.0151
#> Modified ES (95%)                -0.0168

Example 3: Testing tactical asset allocation strategies

In this example, we test and compare four pre-loaded tactical asset allocation strategies: the Ivy Portfolio, the Robust Asset Allocation strategy, the Dual Momentum strategy, and the Adaptive Asset Allocation strategy. A brief description of each strategy (as well as appropriate references) is provided in the corresponding rebalancing functions.

# define strategies
ivy <- asset_allocations$tactical$ivy
raa <- asset_allocations$tactical$raa
dual_mo <- asset_allocations$tactical$dual_mo
aaa <- asset_allocations$tactical$aaa

# run backtests
bt_ivy <- backtest_allocation(ivy, ETFs$Prices,ETFs$Returns, ETFs$risk_free)
bt_raa <- backtest_allocation(raa, ETFs$Prices,ETFs$Returns, ETFs$risk_free)
bt_dual_mo <- backtest_allocation(dual_mo, ETFs$Prices,ETFs$Returns, ETFs$risk_free)
bt_aaa <- backtest_allocation(aaa, ETFs$Prices,ETFs$Returns, ETFs$risk_free)

ret_strats <- merge.xts(bt_ivy$returns, bt_raa$returns, bt_dual_mo$returns, bt_aaa$returns)

# find index from which all strats are available
min_ind <- which.max(!is.na(rowSums(ret_strats)))

charts.PerformanceSummary(ret_strats[min_ind:nrow(ret_strats)])


cbind(bt_ivy$table_performance,
      bt_raa$table_performance,
      bt_dual_mo$table_performance,
      bt_aaa$table_performance)
#>                                   Ivy RAA.Balanced Antonacci.s.Dual.Momentum
#> Annualized Return              0.0475       0.0454                    0.0494
#> Annualized Std Dev             0.0777       0.0601                    0.0896
#> Annualized Sharpe (Rf=0.53%)   0.5416       0.6259                    0.4908
#> daily downside risk            0.0035       0.0028                    0.0041
#> Annualised downside risk       0.0562       0.0441                    0.0655
#> Downside potential             0.0015       0.0012                    0.0016
#> Omega                          1.1185       1.1279                    1.1144
#> Sortino ratio                  0.0496       0.0554                    0.0453
#> Upside potential               0.0017       0.0014                    0.0018
#> Upside potential ratio         0.6193       0.6066                    0.6221
#> Omega-sharpe ratio             0.1185       0.1279                    0.1144
#> Semi Deviation                 0.0036       0.0028                    0.0042
#> Gain Deviation                 0.0034       0.0024                    0.0039
#> Loss Deviation                 0.0042       0.0032                    0.0052
#> Downside Deviation (MAR=210%)  0.0094       0.0090                    0.0098
#> Downside Deviation (Rf=0.53%)  0.0035       0.0028                    0.0041
#> Downside Deviation (0%)        0.0035       0.0028                    0.0041
#> Maximum Drawdown               0.1333       0.1103                    0.1864
#> Historical VaR (95%)          -0.0073      -0.0058                   -0.0085
#> Historical ES (95%)           -0.0126      -0.0096                   -0.0141
#> Modified VaR (95%)            -0.0078      -0.0065                   -0.0088
#> Modified ES (95%)             -0.0155      -0.0129                   -0.0206
#>                               Adaptive.Asset.Allocation
#> Annualized Return                                0.0838
#> Annualized Std Dev                               0.0903
#> Annualized Sharpe (Rf=0.53%)                     0.8624
#> daily downside risk                              0.0041
#> Annualised downside risk                         0.0649
#> Downside potential                               0.0018
#> Omega                                            1.1745
#> Sortino ratio                                    0.0766
#> Upside potential                                 0.0021
#> Upside potential ratio                           0.6611
#> Omega-sharpe ratio                               0.1745
#> Semi Deviation                                   0.0042
#> Gain Deviation                                   0.0037
#> Loss Deviation                                   0.0046
#> Downside Deviation (MAR=210%)                    0.0097
#> Downside Deviation (Rf=0.53%)                    0.0041
#> Downside Deviation (0%)                          0.0041
#> Maximum Drawdown                                 0.1435
#> Historical VaR (95%)                            -0.0092
#> Historical ES (95%)                             -0.0144
#> Modified VaR (95%)                              -0.0095
#> Modified ES (95%)                               -0.0177

Visualizing allocations for all strategies:

chart.StackedBar(bt_ivy$rebalance_weights,
                 date.format = "%Y",
                 main = paste0("Allocations, ", bt_ivy$strat$name))


chart.StackedBar(bt_raa$rebalance_weights,
                 date.format = "%Y",
                 main = paste0("Allocations, ", bt_raa$strat$name))


chart.StackedBar(bt_dual_mo$rebalance_weights,
                 date.format = "%Y",
                 main = paste0("Allocations, ", bt_dual_mo$strat$name))


chart.StackedBar(bt_aaa$rebalance_weights,
                 date.format = "%Y",
                 main = paste0("Allocations, ", bt_aaa$strat$name))

Example 4: Minimum variance portfolio

In this example, we create a strategy that uses the minimum variance portfolio rule using U.S. stocks and bonds. At each rebalancing date, this strategy uses optimization to determine the weights that yield the minimum variance possible.

# Minimum variance portfolio
us_mvp  <- list(name = "US MinVar",
               tickers = c("VTI",
                           "BND"),
               default_weights = c(0.5,
                                   0.5),
               rebalance_frequency = "month",
               portfolio_rule_fn = min_variance)

bt_us_mvp <- backtest_allocation(us_mvp,
                                ETFs$Prices,
                                ETFs$Returns,
                                ETFs$risk_free)

charts.PerformanceSummary(bt_us_mvp$returns)


bt_us_mvp$table_performance
#>                               US.MinVar
#> Annualized Return                0.0347
#> Annualized Std Dev               0.0464
#> Annualized Sharpe (Rf=0.52%)     0.6323
#> daily downside risk              0.0021
#> Annualised downside risk         0.0335
#> Downside potential               0.0008
#> Omega                            1.1551
#> Sortino ratio                    0.0564
#> Upside potential                 0.0009
#> Upside potential ratio           0.5341
#> Omega-sharpe ratio               0.1551
#> Semi Deviation                   0.0022
#> Gain Deviation                   0.0022
#> Loss Deviation                   0.0027
#> Downside Deviation (MAR=210%)    0.0086
#> Downside Deviation (Rf=0.52%)    0.0021
#> Downside Deviation (0%)          0.0021
#> Maximum Drawdown                 0.1249
#> Historical VaR (95%)            -0.0035
#> Historical ES (95%)             -0.0063
#> Modified VaR (95%)                   NA
#> Modified ES (95%)                    NA

As expected, this strategy would invest heavily in bonds:

chart.StackedBar(bt_us_mvp$rebalance_weights,
                 date.format = "%Y",
                 main = paste0("Allocations, ", us_mvp$name))

Example 5: Risk Parity

Finally, in this example, we test a risk parity portfolio inspired on the RPAR ETF. As described in the prospectus, this ETF targets the following risk allocations:

Risk parity is implemented in the risk_parity rebalancing function, which considers as risk budgets the values in the default_weights element of the strategy.1 Our “clone” RPAR strategy uses the following ETFs:

The risk budgets are set to 25% for each of the four categories above, and equally per asset within each category:

rp  <- list(name = "US Risk Parity",
             tickers = c("TIP",
                         "VTI", "EFA", "EEM",
                         "DBC", "GLD",
                         "IEF"),
             default_weights = c(0.25,
                                 0.25/3, 0.25/3, 0.25/3,
                                 0.25/2, 0.25/2,
                                 0.25),
             rebalance_frequency = "month",
             portfolio_rule_fn = "risk_parity")
             
bt_rp <- backtest_allocation(rp,
                                 ETFs$Prices,
                                 ETFs$Returns,
                                 ETFs$risk_free)

As expected, the volatility is quite low, since 50% of the risk budget is allocated to fixed income:

charts.PerformanceSummary(bt_rp$returns)

bt_rp$table_performance
#>                               US.Risk.Parity
#> Annualized Return                     0.0447
#> Annualized Std Dev                    0.0532
#> Annualized Sharpe (Rf=0.79%)          0.6875
#> daily downside risk                   0.0023
#> Annualised downside risk              0.0371
#> Downside potential                    0.0011
#> Omega                                 1.1356
#> Sortino ratio                         0.0633
#> Upside potential                      0.0012
#> Upside potential ratio                0.7082
#> Omega-sharpe ratio                    0.1356
#> Semi Deviation                        0.0024
#> Gain Deviation                        0.0023
#> Loss Deviation                        0.0025
#> Downside Deviation (MAR=210%)         0.0088
#> Downside Deviation (Rf=0.79%)         0.0023
#> Downside Deviation (0%)               0.0023
#> Maximum Drawdown                      0.1550
#> Historical VaR (95%)                 -0.0050
#> Historical ES (95%)                  -0.0078
#> Modified VaR (95%)                   -0.0048
#> Modified ES (95%)                    -0.0073

We can check the correlation with the actual RPAR ETF:

rpar <- get_data_from_tickers("RPAR")
rp_compare <- merge.xts(bt_rp$returns, rpar$R, join = "right")
rp_compare <- na.omit(rp_compare)
cor(rp_compare)
#>                US.Risk.Parity     RPAR
#> US.Risk.Parity       1.000000 0.888787
#> RPAR                 0.888787 1.000000

Despite the high correlation, RPAR has about twice the volatility of our strategy. The reason is that the ETF can rely on leverage through futures:

table.AnnualizedReturns(rp_compare)
#>                           US.Risk.Parity   RPAR
#> Annualized Return                 0.0430 0.0905
#> Annualized Std Dev                0.0631 0.1225
#> Annualized Sharpe (Rf=0%)         0.6814 0.7394

We can rescale the clone to have the same ex-post volatility for an apples-to-apples comparison:

rp_rescale_factor <- table.AnnualizedReturns(rp_compare)[2,2]/table.AnnualizedReturns(rp_compare)[2,1]

rp_compare[, 1] <- rp_compare[, 1] * rp_rescale_factor

charts.PerformanceSummary(rp_compare)

Final thoughts - creating your own strategies

Creating your own static asset allocation strategies is straightforward. Just add the tickers and set portfolio_rule_fn = "constant_weights". To create your own dynamic/tactical rebalancing functions, you can look at the provided functions to get some ideas. One important thing to keep in mind is to ensure that the rebalancing function only uses data until each rebalancing date, in order to avoid look-ahead bias. Take a look at the tactical rebalancing functions in the package to see one way to achieve this.


  1. The risk_parity function itself uses functions from the riskParityPortfolio package.↩︎