0. Install & Load Required Packages

required_packages <- c(
  "tidyquant", "PortfolioAnalytics", "PerformanceAnalytics",
  "ROI", "ROI.plugin.quadprog", "ROI.plugin.glpk",
  "tidyverse", "xts", "zoo", "lubridate"
)

for (pkg in required_packages) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    install.packages(pkg, repos = "https://cloud.r-project.org")
  }
}

library(tidyquant)
library(PortfolioAnalytics)
library(PerformanceAnalytics)
library(ROI)
library(ROI.plugin.quadprog)
library(tidyverse)
library(xts)
library(lubridate)

1. Define Universe

tickers <- c("AAPL", "MSFT", "NVDA", "V", "UNH",
             "COST", "MA", "LLY", "GOOGL", "BRK-B")

benchmark_ticker <- "SPY"

start_date <- Sys.Date() - years(3)
end_date   <- Sys.Date()

cat("Downloading data from", format(start_date), "to", format(end_date), "\n")
## Downloading data from 2023-05-26 to 2026-05-26

2. Download Price Data

raw_prices <- tq_get(
  tickers,
  from = start_date,
  to   = end_date,
  get  = "stock.prices"
)

benchmark_prices <- tq_get(
  benchmark_ticker,
  from = start_date,
  to   = end_date,
  get  = "stock.prices"
)

3. Compute Daily Returns

Fix: Replaced tk_xts() (requires timetk) with base xts::xts() — no extra package needed.

# --- Portfolio returns (long → wide → xts) ---
returns_long <- raw_prices %>%
  group_by(symbol) %>%
  tq_transmute(
    select     = adjusted,
    mutate_fun = periodReturn,
    period     = "daily",
    col_rename = "return"
  ) %>%
  ungroup()

returns_wide <- returns_long %>%
  pivot_wider(names_from = symbol, values_from = return) %>%
  drop_na() %>%
  arrange(date)

# Convert to xts using base xts() — no timetk required
returns_xts <- xts(
  returns_wide[ , -1],           # drop the date column
  order.by = as.Date(returns_wide$date)
)

# --- Benchmark returns ---
benchmark_wide <- benchmark_prices %>%
  tq_transmute(
    select     = adjusted,
    mutate_fun = periodReturn,
    period     = "daily",
    col_rename = "SPY"
  ) %>%
  arrange(date)

benchmark_returns <- xts(
  benchmark_wide[ , "SPY", drop = FALSE],
  order.by = as.Date(benchmark_wide$date)
)

cat("Data loaded:", nrow(returns_xts), "trading days,",
    ncol(returns_xts), "assets\n")
## Data loaded: 750 trading days, 10 assets

4. Portfolio Optimization — Maximum Sharpe Ratio

Constraint: each weight in \[0.02, 0.20\] — no single asset exceeds 20%

port_spec <- portfolio.spec(assets = colnames(returns_xts))

port_spec <- add.constraint(port_spec, type = "full_investment")
port_spec <- add.constraint(port_spec, type = "long_only")
port_spec <- add.constraint(
  port_spec,
  type = "box",
  min  = 0.02,
  max  = 0.20
)

port_spec <- add.objective(port_spec, type = "return", name = "mean")
port_spec <- add.objective(port_spec, type = "risk",   name = "StdDev")

set.seed(42)
opt_result <- optimize.portfolio(
  R               = returns_xts,
  portfolio       = port_spec,
  optimize_method = "ROI",
  maxSR           = TRUE
)

optimized_weights <- extractWeights(opt_result)

weight_df <- data.frame(
  Ticker     = names(optimized_weights),
  Weight_Pct = round(optimized_weights * 100, 2)
) %>% arrange(desc(Weight_Pct))

knitr::kable(
  weight_df,
  col.names = c("Ticker", "Weight (%)"),
  caption   = "Optimized Portfolio Weights — Maximum Sharpe Ratio"
)
Optimized Portfolio Weights — Maximum Sharpe Ratio
Ticker Weight (%)
COST COST 20.0
GOOGL GOOGL 20.0
BRK-B BRK-B 20.0
NVDA NVDA 15.9
LLY LLY 14.1
AAPL AAPL 2.0
MSFT MSFT 2.0
V V 2.0
UNH UNH 2.0
MA MA 2.0

5. Backtest — Portfolio Returns

portfolio_returns <- Return.portfolio(
  returns_xts,
  weights      = optimized_weights,
  rebalance_on = "quarters"
)
colnames(portfolio_returns) <- "Quality_Momentum"

benchmark_aligned <- benchmark_returns[index(portfolio_returns)]

combined <- merge.xts(portfolio_returns, benchmark_aligned, join = "inner")
colnames(combined) <- c("Quality_Momentum", "SPY")

6. Performance Metrics

risk_free_rate <- 0.05 / 252   # ~5% annual US T-bill, converted to daily

6.1 Annualized Returns

ann_returns <- table.AnnualizedReturns(combined, Rf = risk_free_rate)
knitr::kable(round(ann_returns, 4), caption = "Annualized Performance Summary")
Annualized Performance Summary
Quality_Momentum SPY
Annualized Return 0.3938 0.2285
Annualized Std Dev 0.1679 0.1513
Annualized Sharpe (Rf=5%) 1.9406 1.1141

6.2 Cumulative Returns

cum_returns <- cumprod(1 + combined) - 1
cum_final   <- tail(cum_returns, 1)

cum_df <- data.frame(
  Portfolio             = c("Quality_Momentum", "SPY"),
  Cumulative_Return_Pct = c(
    round(as.numeric(cum_final[, "Quality_Momentum"]) * 100, 2),
    round(as.numeric(cum_final[, "SPY"]) * 100, 2)
  )
)
knitr::kable(cum_df, caption = "Total Cumulative Return Over Backtest Period")
Total Cumulative Return Over Backtest Period
Portfolio Cumulative_Return_Pct
Quality_Momentum 168.61
SPY 84.48

6.3 Sharpe Ratio

sharpe <- SharpeRatio.annualized(combined, Rf = risk_free_rate)
knitr::kable(round(sharpe, 4), caption = "Annualized Sharpe Ratio")
Annualized Sharpe Ratio
Quality_Momentum SPY
Annualized Sharpe Ratio (Rf=5%, p=95%): 1.7643 1.105

6.4 Maximum Drawdown (MDD)

mdd <- maxDrawdown(combined)

mdd_df <- data.frame(
  Portfolio        = c("Quality_Momentum", "SPY"),
  Max_Drawdown_Pct = c(
    round(mdd["Quality_Momentum"] * 100, 2),
    round(mdd["SPY"] * 100, 2)
  )
)
knitr::kable(mdd_df, caption = "Maximum Drawdown (%)")
Maximum Drawdown (%)
Portfolio Max_Drawdown_Pct
Quality_Momentum NA
SPY NA

6.5 Alpha & Beta vs SPY

capm_alpha <- CAPM.alpha(
  Ra = combined[, "Quality_Momentum"],
  Rb = combined[, "SPY"],
  Rf = risk_free_rate
)
beta_val <- CAPM.beta(
  Ra = combined[, "Quality_Momentum"],
  Rb = combined[, "SPY"],
  Rf = risk_free_rate
)

ab_df <- data.frame(
  Metric = c("Alpha (daily)", "Alpha (annualized %)", "Beta"),
  Value  = c(
    round(as.numeric(capm_alpha), 6),
    round(as.numeric(capm_alpha) * 252 * 100, 2),
    round(as.numeric(beta_val), 4)
  )
)
knitr::kable(ab_df, caption = "CAPM Alpha & Beta vs SPY")
CAPM Alpha & Beta vs SPY
Metric Value
Alpha (daily) 0.000562
Alpha (annualized %) 14.160000
Beta 0.925000

6.6 Information Ratio & Calmar Ratio

ir     <- InformationRatio(Ra = combined[, "Quality_Momentum"], Rb = combined[, "SPY"])
calmar <- CalmarRatio(combined)

ratio_df <- data.frame(
  Metric = c("Information Ratio", "Calmar Ratio — Quality_Momentum", "Calmar Ratio — SPY"),
  Value  = c(
    round(as.numeric(ir), 4),
    round(as.numeric(calmar["Quality_Momentum"]), 4),
    round(as.numeric(calmar["SPY"]), 4)
  )
)
knitr::kable(ratio_df, caption = "Information Ratio & Calmar Ratio")
Information Ratio & Calmar Ratio
Metric Value
Information Ratio 1.7688
Calmar Ratio — Quality_Momentum NA
Calmar Ratio — SPY NA

6.7 Full Statistics Table

stats_table <- table.Stats(combined)
knitr::kable(round(stats_table, 4), caption = "Full Descriptive Statistics")
Full Descriptive Statistics
Quality_Momentum SPY
Observations 750.0000 750.0000
NAs 0.0000 0.0000
Minimum -0.0575 -0.0585
Quartile 1 -0.0044 -0.0032
Median 0.0016 0.0011
Arithmetic Mean 0.0014 0.0009
Geometric Mean 0.0013 0.0008
Quartile 3 0.0072 0.0059
Maximum 0.0869 0.1050
SE Mean 0.0004 0.0003
LCL Mean (0.95) 0.0006 0.0002
UCL Mean (0.95) 0.0021 0.0015
Variance 0.0001 0.0001
Stdev 0.0106 0.0095
Skewness 0.3464 0.9486
Kurtosis 7.3096 22.0072

7. Visualizations

7.1 Performance Summary Chart

charts.PerformanceSummary(
  combined,
  Rf         = risk_free_rate,
  main       = "Quality + Momentum Portfolio vs S&P 500 (3-Year Backtest)",
  colorset   = c("#1f77b4", "#ff7f0e"),
  legend.loc = "topleft"
)
Cumulative Return, Daily Return, and Drawdown vs SPY

Cumulative Return, Daily Return, and Drawdown vs SPY

7.2 Rolling 12-Month Sharpe Ratio

chart.RollingPerformance(
  combined,
  FUN        = "SharpeRatio.annualized",
  width      = 252,
  Rf         = risk_free_rate,
  main       = "Rolling 12-Month Sharpe Ratio",
  colorset   = c("#1f77b4", "#ff7f0e"),
  legend.loc = "topleft"
)
Rolling 252-Day Sharpe Ratio

Rolling 252-Day Sharpe Ratio

7.3 Drawdown Chart

chart.Drawdown(
  combined,
  main       = "Drawdown Comparison: Portfolio vs SPY",
  colorset   = c("#1f77b4", "#ff7f0e"),
  legend.loc = "bottomleft"
)
Drawdown Comparison: Portfolio vs SPY

Drawdown Comparison: Portfolio vs SPY

7.4 Optimized Portfolio Weights

barplot(
  sort(optimized_weights, decreasing = TRUE) * 100,
  main = "Optimized Portfolio Weights (%)",
  ylab = "Weight (%)",
  xlab = "Ticker",
  col  = "#1f77b4",
  las  = 2,
  ylim = c(0, 25)
)
abline(h = 20, col = "red", lty = 2, lwd = 1.5)
text(x = 1, y = 21.5, labels = "20% Max Constraint", col = "red", cex = 0.8)
Portfolio Weights with 20% Maximum Constraint

Portfolio Weights with 20% Maximum Constraint


8. Export Results to CSV

monthly_returns <- apply.monthly(combined, Return.cumulative)
write.csv(as.data.frame(monthly_returns), file = "monthly_returns.csv")
write.csv(weight_df, file = "optimized_weights.csv", row.names = FALSE)

cat("Files saved:\n")
## Files saved:
cat("  monthly_returns.csv\n")
##   monthly_returns.csv
cat("  optimized_weights.csv\n")
##   optimized_weights.csv
cat("\nBacktest complete!\n")
## 
## Backtest complete!

Session Info

sessionInfo()
## R version 4.5.1 (2025-06-13)
## Platform: aarch64-apple-darwin20
## Running under: macOS Sonoma 14.3.1
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: Asia/Taipei
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] lubridate_1.9.4            forcats_1.0.1             
##  [3] stringr_1.5.2              dplyr_1.1.4               
##  [5] purrr_1.1.0                readr_2.1.5               
##  [7] tidyr_1.3.1                tibble_3.3.0              
##  [9] ggplot2_4.0.2              tidyverse_2.0.0           
## [11] ROI.plugin.quadprog_1.0-1  ROI_1.0-2                 
## [13] PortfolioAnalytics_2.1.2   foreach_1.5.2             
## [15] PerformanceAnalytics_2.1.0 quantmod_0.4.28           
## [17] TTR_0.24.4                 xts_0.14.1                
## [19] zoo_1.8-14                 tidyquant_1.0.12          
## 
## loaded via a namespace (and not attached):
##  [1] rlang_1.1.6               magrittr_2.0.4           
##  [3] ROI.plugin.glpk_1.0-0     furrr_0.3.1              
##  [5] compiler_4.5.1            vctrs_0.6.5              
##  [7] lhs_1.2.1                 quadprog_1.5-8           
##  [9] tune_2.0.1                pkgconfig_2.0.3          
## [11] fastmap_1.2.0             backports_1.5.0          
## [13] rmarkdown_2.29            prodlim_2025.04.28       
## [15] tzdb_0.5.0                xfun_0.53                
## [17] cachem_1.1.0              jsonlite_2.0.0           
## [19] recipes_1.3.1             parallel_4.5.1           
## [21] R6_2.6.1                  bslib_0.9.0              
## [23] stringi_1.8.7             rsample_1.3.1            
## [25] RColorBrewer_1.1-3        parallelly_1.45.1        
## [27] rpart_4.1.24              jquerylib_0.1.4          
## [29] numDeriv_2016.8-1.1       Rcpp_1.1.0               
## [31] dials_1.4.2               iterators_1.0.14         
## [33] knitr_1.50                future.apply_1.20.0      
## [35] Matrix_1.7-3              splines_4.5.1            
## [37] nnet_7.3-20               timechange_0.3.0         
## [39] tidyselect_1.2.1          rstudioapi_0.17.1        
## [41] yaml_2.3.10               timeDate_4051.111        
## [43] codetools_0.2-20          curl_7.0.0               
## [45] ROI.plugin.symphony_1.0-0 listenv_0.9.1            
## [47] lattice_0.22-7            withr_3.0.2              
## [49] S7_0.2.0                  evaluate_1.0.5           
## [51] timetk_2.9.1              future_1.67.0            
## [53] survival_3.8-3            pillar_1.11.1            
## [55] checkmate_2.3.3           generics_0.1.4           
## [57] hms_1.1.3                 scales_1.4.0             
## [59] globals_0.18.0            class_7.3-23             
## [61] glue_1.8.0                slam_0.1-55              
## [63] mco_1.17                  GenSA_1.1.15             
## [65] lazyeval_0.2.2            tools_4.5.1              
## [67] data.table_1.17.8         gower_1.0.2              
## [69] registry_0.5-1            grid_4.5.1               
## [71] yardstick_1.3.2           RobStatTM_1.0.11         
## [73] ipred_0.9-15              cli_3.6.5                
## [75] DiceDesign_1.10           workflows_1.3.0          
## [77] parsnip_1.4.1             pso_1.0.4                
## [79] lava_1.8.2                Rsymphony_0.1-33         
## [81] gtable_0.3.6              GPfit_1.0-9              
## [83] sass_0.4.10               digest_0.6.37            
## [85] farver_2.1.2              htmltools_0.5.8.1        
## [87] Rglpk_0.6-5.1             lifecycle_1.0.4          
## [89] hardhat_1.4.2             MASS_7.3-65