pkgs <- c("tidyquant", "PerformanceAnalytics", "PortfolioAnalytics",
"ROI", "ROI.plugin.quadprog", "xts", "dplyr",
"tidyr", "ggplot2", "scales", "kableExtra")
for (p in pkgs) {
if (!requireNamespace(p, quietly = TRUE))
install.packages(p, repos = "https://cloud.r-project.org")
}
## Registered S3 method overwritten by 'quantmod':
## method from
## as.zoo.data.frame zoo
## Registered S3 method overwritten by 'PortfolioAnalytics':
## method from
## print.constraint ROI
library(tidyquant)
## Warning: package 'PerformanceAnalytics' was built under R version 4.5.2
## ── Attaching core tidyquant packages ─────────────────────── tidyquant 1.0.11 ──
## ✔ PerformanceAnalytics 2.1.0 ✔ TTR 0.24.4
## ✔ quantmod 0.4.28 ✔ xts 0.14.1
## ── Conflicts ────────────────────────────────────────── tidyquant_conflicts() ──
## ✖ zoo::as.Date() masks base::as.Date()
## ✖ zoo::as.Date.numeric() masks base::as.Date.numeric()
## ✖ PerformanceAnalytics::legend() masks graphics::legend()
## ✖ quantmod::summary() masks base::summary()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(PerformanceAnalytics)
library(PortfolioAnalytics)
## Warning: package 'PortfolioAnalytics' was built under R version 4.5.2
## Loading required package: foreach
library(ROI)
## Warning: package 'ROI' was built under R version 4.5.2
## ROI: R Optimization Infrastructure
## Registered solver plugins: nlminb, symphony, quadprog.
## Default solver: auto.
##
## Attaching package: 'ROI'
##
## The following objects are masked from 'package:PortfolioAnalytics':
##
## is.constraint, objective
library(ROI.plugin.quadprog)
library(xts)
library(dplyr)
## Warning: package 'dplyr' was built under R version 4.5.2
##
## ######################### Warning from 'xts' package ##########################
## # #
## # The dplyr lag() function breaks how base R's lag() function is supposed to #
## # work, which breaks lag(my_xts). Calls to lag(my_xts) that you type or #
## # source() into this session won't work correctly. #
## # #
## # Use stats::lag() to make sure you're not using dplyr::lag(), or you can add #
## # conflictRules('dplyr', exclude = 'lag') to your .Rprofile to stop #
## # dplyr from breaking base R's lag() function. #
## # #
## # Code in packages is not affected. It's protected by R's namespace mechanism #
## # Set `options(xts.warn_dplyr_breaks_lag = FALSE)` to suppress this warning. #
## # #
## ###############################################################################
##
## Attaching package: 'dplyr'
##
## The following objects are masked from 'package:xts':
##
## first, last
##
## The following objects are masked from 'package:stats':
##
## filter, lag
##
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(tidyr)
## Warning: package 'tidyr' was built under R version 4.5.2
library(ggplot2)
library(scales)
library(kableExtra)
##
## Attaching package: 'kableExtra'
##
## The following object is masked from 'package:dplyr':
##
## group_rows
# Portfolio tickers & weights
tickers <- c("META", "GOOGL", "EOG", "QCOM", "DELL",
"VLO", "GILD", "AMAT", "STX", "COR")
weights <- c(0.2500, 0.2115, 0.1430, 0.1205, 0.1050,
0.0700, 0.0500, 0.0300, 0.0100, 0.0100)
benchmark_ticker <- "SPY"
# Date window
end_date <- Sys.Date()
start_date <- end_date - lubridate::years(3)
cat("Backtest window:", format(start_date), "→", format(end_date), "\n")
## Backtest window: 2023-05-26 → 2026-05-26
# Download stock price data
raw_prices <- tq_get(
c(tickers, benchmark_ticker),
from = start_date,
to = end_date,
get = "stock.prices"
)
adj_prices <- raw_prices %>%
select(symbol, date, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date)
cat("Rows downloaded:", nrow(adj_prices), "\n")
## Rows downloaded: 750
# Download stock price data
raw_prices <- tq_get(
c(tickers, benchmark_ticker),
from = start_date,
to = end_date,
get = "stock.prices"
)
adj_prices <- raw_prices %>%
select(symbol, date, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date)
cat("Rows downloaded:", nrow(adj_prices), "\n")
## Rows downloaded: 750
# ── Portfolio tickers & fixed weights from the optimisation ──────────────────
tickers <- c("META", "GOOGL", "EOG", "QCOM", "DELL",
"VLO", "GILD", "AMAT", "STX", "COR")
weights <- c(0.2500, 0.2115, 0.1430, 0.1205, 0.1050,
0.0700, 0.0500, 0.0300, 0.0100, 0.0100)
benchmark_ticker <- "SPY"
# ── Date window: 3-year backtest ─────────────────────────────────────────────
end_date <- Sys.Date()
start_date <- end_date - lubridate::years(3)
cat("Backtest window:", format(start_date), "→", format(end_date), "\n")
## Backtest window: 2023-05-26 → 2026-05-26
# ── Download adjusted-close prices ───────────────────────────────────────────
raw_prices <- tq_get(
c(tickers, benchmark_ticker),
from = start_date,
to = end_date,
get = "stock.prices"
)
adj_prices <- raw_prices %>%
select(symbol, date, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date)
cat("Rows downloaded:", nrow(adj_prices), "\n")
## Rows downloaded: 750
# Download stock price data
raw_prices <- tq_get(
c(tickers, benchmark_ticker),
from = start_date,
to = end_date,
get = "stock.prices"
)
adj_prices <- raw_prices %>%
select(symbol, date, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date)
cat("Rows downloaded:", nrow(adj_prices), "\n")
## Rows downloaded: 750
cat("Date range :", format(min(adj_prices$date)), "→",
format(max(adj_prices$date)), "\n")
## Date range : 2023-05-26 → 2026-05-22
# ── Daily log-returns for each asset ─────────────────────────────────────────
port_prices <- adj_prices %>% select(date, all_of(tickers))
bench_prices <- adj_prices %>% select(date, SPY)
prices_xts <- xts::xts(
x = port_prices %>% select(-date),
order.by = port_prices$date
)
bench_xts <- xts::xts(
x = bench_prices %>% select(-date),
order.by = bench_prices$date
)
# Daily simple returns
asset_returns <- Return.calculate(prices_xts, method = "discrete")[-1, ]
bench_returns <- Return.calculate(bench_xts, method = "discrete")[-1, ]
# Portfolio return series (buy-and-hold weights, rebalanced monthly)
port_returns <- Return.portfolio(asset_returns, weights = weights,
rebalance_on = "months")
colnames(port_returns) <- "Quality_Momentum"
colnames(bench_returns) <- "SPY_Benchmark"
combined <- merge.xts(port_returns, bench_returns)
head(combined, 3)
## Quality_Momentum SPY_Benchmark
## 2023-05-30 -0.001303221 0.0003808395
## 2023-05-31 -0.011714473 -0.0055451279
## 2023-06-01 0.013642096 0.0095009115
cum_ret <- cumprod(1 + combined) - 1 # running cumulative return
cum_df <- data.frame(
date = index(cum_ret),
Portfolio = as.numeric(cum_ret[, "Quality_Momentum"]),
Benchmark = as.numeric(cum_ret[, "SPY_Benchmark"])
) %>%
pivot_longer(-date, names_to = "Series", values_to = "Cum_Return")
ggplot(cum_df, aes(x = date, y = Cum_Return, colour = Series, linetype = Series)) +
geom_line(linewidth = 1.1) +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
scale_colour_manual(values = c("Portfolio" = "#8f77b4", "Benchmark" = "#ff3F0e")) +
scale_linetype_manual(values = c("Portfolio" = "solid", "Benchmark" = "dashed")) +
labs(
title = "Cumulative Return: Quality Momentum vs. SPY",
subtitle = paste(format(start_date, "%b %Y"), "—", format(end_date, "%b %Y")),
x = NULL, y = "Cumulative Return", colour = NULL, linetype = NULL
) +
theme_minimal(base_size = 13) +
theme(legend.position = "top", plot.title = element_text(face = "bold"))

final_cum <- cum_ret[nrow(cum_ret), ]
data.frame(
Series = c("Quality Momentum Portfolio", "SPY Benchmark"),
`Cumulative Return` = percent(as.numeric(final_cum), accuracy = 0.1)
) %>%
kable(align = "lr", caption = "Total Cumulative Return over the 3-Year Period") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Total Cumulative Return over the 3-Year Period
|
Series
|
Cumulative.Return
|
|
Quality Momentum Portfolio
|
191.7%
|
|
SPY Benchmark
|
84.5%
|
# Risk-free rate: U.S. 3-month T-bill annualised ≈ 5.25% (2024 avg), daily equiv.
rf_annual <- 0.0525
rf_daily <- (1 + rf_annual)^(1/252) - 1
sharpe_port <- SharpeRatio.annualized(port_returns, Rf = rf_daily, scale = 252)
sharpe_bench <- SharpeRatio.annualized(bench_returns, Rf = rf_daily, scale = 252)
sharpe_tbl <- data.frame(
Series = c("Quality Momentum Portfolio", "SPY Benchmark"),
`Sharpe Ratio` = round(c(as.numeric(sharpe_port), as.numeric(sharpe_bench)), 3),
`Risk-Free Rate Used` = c("5.25% p.a.", "5.25% p.a.")
)
sharpe_tbl %>%
kable(align = "lrr",
caption = "Annualised Sharpe Ratio (Rf = 5.25% p.a., 3-Year Window)") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Annualised Sharpe Ratio (Rf = 5.25% p.a., 3-Year Window)
|
Series
|
Sharpe.Ratio
|
Risk.Free.Rate.Used
|
|
Quality Momentum Portfolio
|
1.557
|
5.25% p.a.
|
|
SPY Benchmark
|
1.098
|
5.25% p.a.
|
roll_sharpe_port <- rollapply(port_returns, width = 126,
FUN = function(x) SharpeRatio.annualized(x, Rf = rf_daily * 126, scale = 252)[1],
fill = NA, align = "right")
roll_sharpe_bench <- rollapply(bench_returns, width = 126,
FUN = function(x) SharpeRatio.annualized(x, Rf = rf_daily * 126, scale = 252)[1],
fill = NA, align = "right")
roll_df <- data.frame(
date = index(roll_sharpe_port),
Portfolio = as.numeric(roll_sharpe_port),
Benchmark = as.numeric(roll_sharpe_bench)
) %>% pivot_longer(-date, names_to = "Series", values_to = "Sharpe")
ggplot(roll_df, aes(x = date, y = Sharpe, colour = Series)) +
geom_line(linewidth = 0.9, na.rm = TRUE) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey10") +
scale_colour_manual(values = c("Portfolio" = "#1f34b4", "Benchmark" = "#ff788e")) +
labs(title = "Rolling 6-Month Sharpe Ratio",
subtitle = "Higher = better risk-adjusted return",
x = NULL, y = "Annualised Sharpe Ratio", colour = NULL) +
theme_minimal(base_size = 13) +
theme(legend.position = "top", plot.title = element_text(face = "bold"))

chart.Drawdown(
combined,
main = "Drawdown from Peak — Quality Momentum vs. SPY",
colorset = c("#1f37b4", "#ff788e"),
lwd = 1.5,
legend.loc = "bottomleft"
)

mdd_port <- maxDrawdown(port_returns)
mdd_bench <- maxDrawdown(bench_returns)
avg_dd_port <- AverageDrawdown(port_returns)
avg_dd_bench <- AverageDrawdown(bench_returns)
dd_tbl <- data.frame(
Series = c("Quality Momentum Portfolio", "SPY Benchmark"),
`Maximum Drawdown` = percent(c(mdd_port, mdd_bench), accuracy = 0.1),
`Average Drawdown` = percent(c(avg_dd_port, avg_dd_bench), accuracy = 0.1)
)
dd_tbl %>%
kable(align = "lrr",
caption = "Drawdown Statistics over the 3-Year Backtest") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Drawdown Statistics over the 3-Year Backtest
|
Series
|
Maximum.Drawdown
|
Average.Drawdown
|
|
Quality Momentum Portfolio
|
25.1%
|
2.4%
|
|
SPY Benchmark
|
18.8%
|
1.6%
|
# CAPM regression: portfolio excess return ~ benchmark excess return
port_excess <- port_returns - rf_daily
bench_excess <- bench_returns - rf_daily
capm_model <- lm(as.numeric(port_excess) ~ as.numeric(bench_excess))
alpha_daily <- coef(capm_model)[1]
beta_coef <- coef(capm_model)[2]
# Annualise alpha
alpha_annual <- alpha_daily * 252
cat(sprintf("Annualised Alpha : %+.4f (%+.2f%%)\n", alpha_annual, alpha_annual * 100))
## Annualised Alpha : +0.1345 (+13.45%)
cat(sprintf("Beta : %.4f\n", beta_coef))
## Beta : 1.1864
cat(sprintf("R-squared : %.4f\n", summary(capm_model)$r.squared))
## R-squared : 0.7108
cat(sprintf("R-squared : %.4f\n", summary(capm_model)$r.squared))
## R-squared : 0.7108
ab_tbl <- data.frame(
Metric = c("Annualised Alpha (Jensen's α)",
"Beta (Market Sensitivity)",
"R-Squared (Explanatory Power)"),
Value = c(
paste0(sprintf("%+.2f", alpha_annual * 100), "% p.a."),
sprintf("%.3f", beta_coef),
sprintf("%.3f", summary(capm_model)$r.squared)
),
Interpretation = c(
ifelse(alpha_annual > 0,
"Positive: strategy adds value beyond market exposure",
"Negative: market exposure explains excess performance"),
ifelse(beta_coef < 1,
"< 1: less volatile than the broad market",
"> 1: amplified market sensitivity"),
"Proportion of returns explained by SPY movements"
)
)
ab_tbl %>%
kable(align = "llll",
caption = "CAPM Alpha & Beta — Quality Momentum vs. SPY (3-Year Window)") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
column_spec(1, bold = TRUE)
CAPM Alpha & Beta — Quality Momentum vs. SPY (3-Year Window)
|
|
Metric
|
Value
|
Interpretation
|
|
(Intercept)
|
Annualised Alpha (Jensen’s α)
|
+13.45% p.a.
|
Positive: strategy adds value beyond market exposure
|
|
as.numeric(bench_excess)
|
Beta (Market Sensitivity)
|
1.186
|
> 1: amplified market sensitivity
|
|
|
R-Squared (Explanatory Power)
|
0.711
|
Proportion of returns explained by SPY movements
|
scatter_df <- data.frame(
bench = as.numeric(bench_excess),
port = as.numeric(port_excess)
)
ggplot(scatter_df, aes(x = bench, y = port)) +
geom_point(alpha = 0.25, colour = "#7f77b4", size = 1) +
geom_smooth(method = "lm", colour = "#e92c3c", se = TRUE, linewidth = 1.2) +
scale_x_continuous(labels = percent_format(accuracy = 0.1)) +
scale_y_continuous(labels = percent_format(accuracy = 0.1)) +
labs(
title = "Security Characteristic Line",
subtitle = paste0("α = ", sprintf("%+.3f%%", alpha_annual * 100),
" p.a. | β = ", sprintf("%.3f", beta_coef)),
x = "SPY Daily Excess Return",
y = "Portfolio Daily Excess Return"
) +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold"))
## `geom_smooth()` using formula = 'y ~ x'

table.AnnualizedReturns(combined, Rf = rf_daily, scale = 252) %>%
round(4) %>%
kable(caption = "Annualised Performance Summary",
col.names = c("Quality Momentum", "SPY Benchmark")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Annualised Performance Summary
|
|
Quality Momentum
|
SPY Benchmark
|
|
Annualized Return
|
0.4336
|
0.2288
|
|
Annualized Std Dev
|
0.2131
|
0.1514
|
|
Annualized Sharpe (Rf=5.12%)
|
1.6995
|
1.1064
|
# Comprehensive stats table
stats_list <- lapply(list(port_returns, bench_returns), function(r) {
c(
`Ann. Return` = as.numeric(Return.annualized(r, scale = 252)),
`Ann. Volatility` = as.numeric(StdDev.annualized(r, scale = 252)),
`Sharpe Ratio` = as.numeric(SharpeRatio.annualized(r, Rf = rf_daily, scale = 252)),
`Max Drawdown` = as.numeric(maxDrawdown(r)),
`Calmar Ratio` = as.numeric(Return.annualized(r, scale = 252) / maxDrawdown(r)),
`Skewness` = as.numeric(skewness(r)),
`Kurtosis` = as.numeric(kurtosis(r))
)
})
stats_df <- do.call(cbind, stats_list)
colnames(stats_df) <- c("Quality Momentum", "SPY Benchmark")
stats_df %>%
as.data.frame() %>%
mutate(across(everything(), round, 3)) %>%
kable(caption = "Comprehensive Risk & Return Statistics (3-Year Backtest)") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
## Warning: There was 1 warning in `mutate()`.
## ℹ In argument: `across(everything(), round, 3)`.
## Caused by warning:
## ! The `...` argument of `across()` is deprecated as of dplyr 1.1.0.
## Supply arguments directly to `.fns` through an anonymous function instead.
##
## # Previously
## across(a:b, mean, na.rm = TRUE)
##
## # Now
## across(a:b, \(x) mean(x, na.rm = TRUE))
Comprehensive Risk & Return Statistics (3-Year Backtest)
|
|
Quality Momentum
|
SPY Benchmark
|
|
Ann. Return
|
0.434
|
0.229
|
|
Ann. Volatility
|
0.213
|
0.151
|
|
Sharpe Ratio
|
1.557
|
1.098
|
|
Max Drawdown
|
0.251
|
0.188
|
|
Calmar Ratio
|
1.727
|
1.220
|
|
Skewness
|
0.319
|
0.948
|
|
Kurtosis
|
11.105
|
21.974
|
cat("
> **Backtest Period:** ", format(start_date, "%B %Y"), "—", format(end_date, "%B %Y"), "
> **Strategy:** Quality Momentum (FCF Yield > 5% ∩ Positive 6-Month RSI)
> **Benchmark:** S&P 500 ETF (SPY)
")
##
## > **Backtest Period:** May 2023 — May 2026
## > **Strategy:** Quality Momentum (FCF Yield > 5% ∩ Positive 6-Month RSI)
## > **Benchmark:** S&P 500 ETF (SPY)