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).
# 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
## Tickers: NVDA, MSFT, AVGO, GE, PWR, LMT, LLY, ISRG, VST, NEE
# 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"The portfolio is optimized for maximum Sharpe ratio subject to:
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)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)")| 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)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))ann_metrics <- table.AnnualizedReturns(combined_returns)
knitr::kable(round(ann_metrics, 4),
caption = "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 |
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")| Series | Maximum Drawdown |
|---|---|
| Quality_Momentum_Portfolio | 38.96% |
| Benchmark | 18.76% |
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)")| Metric | Value |
|---|---|
| Beta | 1.729100 |
| Daily Alpha | 0.000949 |
| Annualized Alpha (approx.) | 0.239100 |
charts.PerformanceSummary(
combined_returns,
main = "Quality Momentum Portfolio vs S&P 500 (3-Year Backtest)",
colorset = c("steelblue", "tomato"),
legend.loc = "topleft"
)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")| 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 | — |