1 Introduction

This report constructs and evaluates a Quality Momentum Portfolio using a fixed 3-year backtest window. The portfolio consists of 10 high-quality, momentum-driven US equities optimized for maximum Sharpe ratio, benchmarked against the S&P 500 (SPY).


2 Setup: Packages and Parameters

# Install missing packages if needed
if (!require(ROI.plugin.quadprog)) install.packages("ROI.plugin.quadprog")
if (!require(tidyverse))           install.packages("tidyverse")

library(tidyquant)
library(PortfolioAnalytics)
library(ROI)
library(ROI.plugin.quadprog)
library(tidyverse)
# Portfolio tickers and benchmark
tickers          <- c("NVDA", "MSFT", "AVGO", "GE", "PWR",
                      "LMT",  "LLY",  "ISRG", "VST", "NEE")
benchmark_ticker <- "SPY"

# Fixed 3-year lookback window
start_date <- as.Date(Sys.Date()) - 3 * 365.25

cat("Backtest start date:", format(start_date), "\n")
## Backtest start date: 2023-05-26
cat("Tickers:", paste(tickers, collapse = ", "), "\n")
## Tickers: NVDA, MSFT, AVGO, GE, PWR, LMT, LLY, ISRG, VST, NEE

3 Data Download

# Download historical prices from Yahoo Finance
prices           <- tq_get(tickers,          from = start_date, get = "stock.prices")
benchmark_prices <- tq_get(benchmark_ticker, from = start_date, get = "stock.prices")
# Daily returns — wide format → xts
returns_df <- prices %>%
  group_by(symbol) %>%
  tq_transmute(select     = adjusted,
               mutate_fun = periodReturn,
               period     = "daily",
               col_rename = "Ra") %>%
  pivot_wider(names_from = symbol, values_from = Ra)

returns_multi <- xts(returns_df[ , -1], order.by = returns_df$date)

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

returns_benchmark           <- xts(bench_df$Benchmark, order.by = bench_df$date)
colnames(returns_benchmark) <- "Benchmark"

4 Portfolio Optimization

The portfolio is optimized for maximum Sharpe ratio subject to:

  • Fully invested (weights sum to 1)
  • Long-only (no short selling)
  • Maximum 20% allocation per stock
port_spec <- portfolio.spec(assets = tickers) %>%
  add.constraint(type = "full_investment") %>%
  add.constraint(type = "long_only") %>%
  add.constraint(type = "box", min = 0, max = 0.20) %>%
  add.objective(type = "return", name = "mean") %>%
  add.objective(type = "risk",   name = "StdDev")

opt_weights  <- optimize.portfolio(R               = returns_multi,
                                   portfolio       = port_spec,
                                   optimize_method = "ROI",
                                   max_sharpe      = TRUE)
final_weights <- extractWeights(opt_weights)

4.1 Optimized Weights

weights_df <- data.frame(
  Ticker = names(final_weights),
  Weight = round(final_weights, 4),
  Weight_Pct = paste0(round(final_weights * 100, 2), "%")
)

knitr::kable(weights_df,
             col.names = c("Ticker", "Weight", "Weight (%)"),
             align     = "lrr",
             caption   = "Optimized Portfolio Weights (Max Sharpe Ratio)")
Optimized Portfolio Weights (Max Sharpe Ratio)
Ticker Weight Weight (%)
NVDA NVDA 0.2 20%
MSFT MSFT 0.0 0%
AVGO AVGO 0.2 20%
GE GE 0.2 20%
PWR PWR 0.2 20%
LMT LMT 0.0 0%
LLY LLY 0.0 0%
ISRG ISRG 0.0 0%
VST VST 0.2 20%
NEE NEE 0.0 0%
ggplot(weights_df, aes(x = reorder(Ticker, Weight), y = Weight * 100, fill = Ticker)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  labs(title = "Optimized Portfolio Weights",
       x     = NULL,
       y     = "Weight (%)") +
  theme_minimal(base_size = 13)


5 Portfolio Returns

portfolio_returns           <- Return.portfolio(returns_multi, weights = final_weights)
colnames(portfolio_returns) <- "Quality_Momentum_Portfolio"

# Merge with benchmark and drop NAs
combined_returns <- na.omit(merge.xts(portfolio_returns, returns_benchmark))

6 Performance Analysis

6.1 Annualized Performance Metrics

ann_metrics <- table.AnnualizedReturns(combined_returns)
knitr::kable(round(ann_metrics, 4),
             caption = "Annualized Return, Volatility, and Sharpe Ratio")
Annualized Return, Volatility, and Sharpe Ratio
Quality_Momentum_Portfolio Benchmark
Annualized Return 0.7264 0.2285
Annualized Std Dev 0.3667 0.1513
Annualized Sharpe (Rf=0%) 1.9808 1.5098

6.2 Maximum Drawdown

mdd <- as.numeric(maxDrawdown(combined_returns))
names(mdd) <- colnames(combined_returns)
knitr::kable(data.frame(Series       = names(mdd),
                        Max_Drawdown = paste0(round(mdd * 100, 2), "%")),
             col.names = c("Series", "Maximum Drawdown"),
             caption   = "Maximum Drawdown")
Maximum Drawdown
Series Maximum Drawdown
Quality_Momentum_Portfolio 38.96%
Benchmark 18.76%

6.3 Alpha and Beta (CAPM)

beta_val  <- CAPM.beta(portfolio_returns,  returns_benchmark)
alpha_val <- CAPM.alpha(portfolio_returns, returns_benchmark)

capm_df <- data.frame(
  Metric = c("Beta", "Daily Alpha", "Annualized Alpha (approx.)"),
  Value  = c(round(beta_val, 4),
             round(alpha_val, 6),
             round(alpha_val * 252, 4))
)

knitr::kable(capm_df, caption = "CAPM Alpha and Beta vs. S&P 500 (SPY)")
CAPM Alpha and Beta vs. S&P 500 (SPY)
Metric Value
Beta 1.729100
Daily Alpha 0.000949
Annualized Alpha (approx.) 0.239100

7 Performance Chart

charts.PerformanceSummary(
  combined_returns,
  main        = "Quality Momentum Portfolio vs S&P 500 (3-Year Backtest)",
  colorset    = c("steelblue", "tomato"),
  legend.loc  = "topleft"
)


8 Summary

ann       <- as.data.frame(table.AnnualizedReturns(combined_returns))
mdd2_raw  <- as.numeric(maxDrawdown(combined_returns))
names(mdd2_raw) <- colnames(combined_returns)
mdd2      <- mdd2_raw

summary_df <- data.frame(
  Metric = c("Annualized Return", "Annualized Std Dev", "Sharpe Ratio (Rf=0%)", "Max Drawdown",
             "Beta (vs SPY)", "Daily Alpha"),
  Portfolio = c(
    paste0(round(ann["Annualized Return",   "Quality_Momentum_Portfolio"] * 100, 2), "%"),
    paste0(round(ann["Annualized Std Dev",  "Quality_Momentum_Portfolio"] * 100, 2), "%"),
    round(ann["Annualized Sharpe Ratio (Rf=0%)", "Quality_Momentum_Portfolio"], 3),
    paste0(round(mdd2["Quality_Momentum_Portfolio"] * 100, 2), "%"),
    round(beta_val, 3),
    round(alpha_val, 6)
  ),
  Benchmark = c(
    paste0(round(ann["Annualized Return",   "Benchmark"] * 100, 2), "%"),
    paste0(round(ann["Annualized Std Dev",  "Benchmark"] * 100, 2), "%"),
    round(ann["Annualized Sharpe Ratio (Rf=0%)", "Benchmark"], 3),
    paste0(round(mdd2["Benchmark"] * 100, 2), "%"),
    "1.000",
    "—"
  )
)

knitr::kable(summary_df,
             col.names = c("Metric", "Quality Momentum Portfolio", "S&P 500 (SPY)"),
             caption   = "Performance Summary: Portfolio vs Benchmark")
Performance Summary: Portfolio vs Benchmark
Metric Quality Momentum Portfolio S&P 500 (SPY)
Annualized Return 72.64% 22.85%
Annualized Std Dev 36.67% 15.13%
Sharpe Ratio (Rf=0%) NA NA
Max Drawdown 38.96% 18.76%
Beta (vs SPY) 1.729 1.000
Daily Alpha 0.000949