This section presents a rigorous quantitative backtest of the Quality-Momentum (Qual-Mom) portfolio over the three-year period from January 2022 through December 2024. This window was deliberately chosen to capture a full market cycle: the severe 2022 bear market (rising rate shock), the recovery rally of 2023, and the AI-driven bull run of 2024 — providing a demanding, real-world stress test of the strategy.
The portfolio holds 10 large-cap US assets spanning Semiconductors, Healthcare, Financials, and Consumer Defensive sectors, weighted via a foundational Risk-Parity approach.
# Install any missing packages automatically
pkgs <- c("quantmod", "PerformanceAnalytics", "PortfolioAnalytics",
"tidyverse", "lubridate", "kableExtra", "scales",
"xts", "zoo", "ggplot2", "gridExtra", "RColorBrewer")
installed <- rownames(installed.packages())
for (p in pkgs) {
if (!p %in% installed) install.packages(p, repos = "https://cran.rstudio.com/")
}
library(quantmod)
library(PerformanceAnalytics)
library(PortfolioAnalytics)
library(tidyverse)
library(lubridate)
library(kableExtra)
library(scales)
library(xts)
library(zoo)
library(ggplot2)
library(gridExtra)
library(RColorBrewer)# ── Portfolio Assets & Weights (Risk-Parity Inspired) ──────────────────────
tickers <- c("MSFT", "NVDA", "AVGO", "LLY", "UNH",
"COST", "JPM", "V", "ASML", "QQQ")
weights <- c(0.12, 0.08, 0.10, 0.08, 0.12,
0.12, 0.10, 0.10, 0.08, 0.10)
names(weights) <- tickers
# Benchmark
benchmark_ticker <- "SPY"
# Backtest window
start_date <- "2022-01-01"
end_date <- "2024-12-31"
# Display the portfolio table
portfolio_table <- tibble(
Ticker = tickers,
Company = c("Microsoft Corp.", "Nvidia Corp.", "Broadcom Inc.",
"Eli Lilly & Co.", "UnitedHealth Group",
"Costco Wholesale", "JPMorgan Chase",
"Visa Inc.", "ASML Holding", "Invesco QQQ Trust"),
Sector = c("Technology", "Technology", "Technology",
"Healthcare", "Healthcare",
"Consumer Def.", "Financials",
"Financials", "Technology", "ETF"),
Weight = scales::percent(weights, accuracy = 0.1)
)
kable(portfolio_table,
caption = "**Table 1: Qual-Mom Portfolio Composition**",
align = "llllr") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE) %>%
column_spec(4, bold = TRUE, color = "#2C7BB6")| Ticker | Company | Sector | Weight |
|---|---|---|---|
| MSFT | Microsoft Corp. | Technology | 12.0% |
| NVDA | Nvidia Corp. | Technology | 8.0% |
| AVGO | Broadcom Inc. | Technology | 10.0% |
| LLY | Eli Lilly & Co. | Healthcare | 8.0% |
| UNH | UnitedHealth Group | Healthcare | 12.0% |
| COST | Costco Wholesale | Consumer Def. | 12.0% |
| JPM | JPMorgan Chase | Financials | 10.0% |
| V | Visa Inc. | Financials | 10.0% |
| ASML | ASML Holding | Technology | 8.0% |
| QQQ | Invesco QQQ Trust | ETF | 10.0% |
all_tickers <- c(tickers, benchmark_ticker)
# Download adjusted closing prices
getSymbols(all_tickers,
src = "yahoo",
from = start_date,
to = end_date,
auto.assign = TRUE,
warnings = FALSE)## [1] "MSFT" "NVDA" "AVGO" "LLY" "UNH" "COST" "JPM" "V" "ASML" "QQQ"
## [11] "SPY"
# Extract adjusted prices into a single xts object
price_list <- lapply(all_tickers, function(tk) Ad(get(tk)))
prices <- do.call(merge, price_list)
colnames(prices) <- all_tickers
# Remove NAs (e.g., ASML has fewer trading days on NYSE)
prices <- na.locf(prices) # carry last observation forward
prices <- na.omit(prices)
cat(sprintf("Data loaded: %d trading days from %s to %s\n",
nrow(prices), index(prices)[1], index(prices)[nrow(prices)]))## Data loaded: 752 trading days from 2022-01-03 to 2024-12-30
# Daily log returns
daily_returns <- Return.calculate(prices, method = "log")
daily_returns <- daily_returns[-1, ] # drop first NA row
# Separate portfolio assets from benchmark
asset_returns <- daily_returns[, tickers]
bench_returns <- daily_returns[, benchmark_ticker]
# Weighted portfolio daily returns
portfolio_returns <- Return.portfolio(asset_returns,
weights = weights,
rebalance_on = "quarters")
colnames(portfolio_returns) <- "Qual_Mom_Portfolio"
colnames(bench_returns) <- "SPY_Benchmark"
# Combined returns object
combined_returns <- merge(portfolio_returns, bench_returns)
# Monthly & Annual returns for later tables
monthly_returns_port <- apply.monthly(portfolio_returns, Return.cumulative)
monthly_returns_bench <- apply.monthly(bench_returns, Return.cumulative)
annual_returns_port <- apply.yearly(portfolio_returns, Return.cumulative)
annual_returns_bench <- apply.yearly(bench_returns, Return.cumulative)# Build cumulative wealth index (starting at $10,000)
wealth_port <- cumprod(1 + portfolio_returns) * 10000
wealth_bench <- cumprod(1 + bench_returns) * 10000
wealth_df <- data.frame(
Date = index(wealth_port),
Portfolio = as.numeric(wealth_port),
Benchmark = as.numeric(wealth_bench)
) %>%
pivot_longer(cols = c(Portfolio, Benchmark),
names_to = "Strategy",
values_to = "Value")
# Colour palette
pal <- c("Portfolio" = "#2C7BB6", "Benchmark" = "#D7191C")
ggplot(wealth_df, aes(x = Date, y = Value, colour = Strategy, linetype = Strategy)) +
geom_line(linewidth = 1.1) +
geom_hline(yintercept = 10000, linetype = "dotted", colour = "grey50", linewidth = 0.6) +
scale_y_continuous(labels = dollar_format(prefix = "$", big.mark = ",")) +
scale_colour_manual(values = pal) +
scale_linetype_manual(values = c("Portfolio" = "solid", "Benchmark" = "dashed")) +
annotate("rect",
xmin = as.Date("2022-01-01"), xmax = as.Date("2022-12-31"),
ymin = -Inf, ymax = Inf,
fill = "red", alpha = 0.05) +
annotate("text", x = as.Date("2022-07-01"), y = max(wealth_df$Value) * 0.97,
label = "2022\nBear Market", size = 3, colour = "red3", fontface = "italic") +
labs(title = "Figure 1: Growth of $10,000 — Qual-Mom Portfolio vs. S&P 500",
subtitle = "Backtest Period: January 2022 – December 2024 | Quarterly Rebalancing",
x = NULL, y = "Portfolio Value (USD)",
colour = "Strategy", linetype = "Strategy") +
theme_minimal(base_size = 13) +
theme(legend.position = "top",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())annual_df <- data.frame(
Year = format(index(annual_returns_port), "%Y"),
Portfolio = scales::percent(as.numeric(annual_returns_port), accuracy = 0.1),
Benchmark = scales::percent(as.numeric(annual_returns_bench), accuracy = 0.1),
Excess = scales::percent(as.numeric(annual_returns_port) -
as.numeric(annual_returns_bench), accuracy = 0.1)
)
kable(annual_df,
caption = "**Table 2: Annual Returns — Portfolio vs. Benchmark**",
col.names = c("Year", "Qual-Mom Portfolio", "SPY Benchmark", "Excess Return"),
align = "crrl") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
column_spec(4, bold = TRUE,
color = ifelse(grepl("^-", annual_df$Excess), "red", "#2C7BB6"))| Year | Qual-Mom Portfolio | SPY Benchmark | Excess Return |
|---|---|---|---|
| 2022 | -20.9% | -21.0% | 0.1% |
| 2023 | 53.3% | 25.1% | 28.2% |
| 2024 | 33.3% | 24.3% | 9.0% |
cum_port <- Return.cumulative(portfolio_returns)
cum_bench <- Return.cumulative(bench_returns)
cat(sprintf("Qual-Mom Portfolio Cumulative Return : %s\n",
scales::percent(as.numeric(cum_port), accuracy = 0.01)))## Qual-Mom Portfolio Cumulative Return : 61.64%
cat(sprintf("SPY Benchmark Cumulative Return : %s\n",
scales::percent(as.numeric(cum_bench), accuracy = 0.01)))## SPY Benchmark Cumulative Return : 22.90%
cat(sprintf("Excess Cumulative Return (Alpha proxy): %s\n",
scales::percent(as.numeric(cum_port) - as.numeric(cum_bench), accuracy = 0.01)))## Excess Cumulative Return (Alpha proxy): 38.74%
Definition: The Sharpe Ratio measures excess return per unit of total risk (standard deviation), using the risk-free rate as a baseline. A higher Sharpe Ratio indicates more efficient compensation for risk.
\[\text{Sharpe Ratio} = \frac{\bar{R}_p - R_f}{\sigma_p}\]
# Approximate annualised risk-free rate (3-month US T-Bill, ~2022–2024 average ≈ 4%)
rf_annual <- 0.04
rf_daily <- (1 + rf_annual)^(1/252) - 1
sharpe_port <- SharpeRatio.annualized(portfolio_returns,
Rf = rf_daily, scale = 252)
sharpe_bench <- SharpeRatio.annualized(bench_returns,
Rf = rf_daily, scale = 252)
# Rolling 252-day Sharpe for the chart
rolling_sharpe_port <- rollapply(portfolio_returns,
width = 252,
FUN = function(x) SharpeRatio.annualized(x, Rf = rf_daily),
align = "right", fill = NA)
rolling_sharpe_bench <- rollapply(bench_returns,
width = 252,
FUN = function(x) SharpeRatio.annualized(x, Rf = rf_daily),
align = "right", fill = NA)
rolling_df <- data.frame(
Date = index(rolling_sharpe_port),
Portfolio = as.numeric(rolling_sharpe_port),
Benchmark = as.numeric(rolling_sharpe_bench)
) %>%
pivot_longer(cols = c(Portfolio, Benchmark),
names_to = "Strategy",
values_to = "Sharpe")
ggplot(rolling_df %>% filter(!is.na(Sharpe)),
aes(x = Date, y = Sharpe, colour = Strategy)) +
geom_line(linewidth = 1.0) +
geom_hline(yintercept = 1.0, linetype = "dashed", colour = "darkgreen",
linewidth = 0.7) +
geom_hline(yintercept = 0.0, colour = "grey40", linewidth = 0.5) +
annotate("text", x = as.Date("2023-06-01"), y = 1.05,
label = "Sharpe = 1.0 (Good threshold)", colour = "darkgreen",
size = 3.5, fontface = "italic") +
scale_colour_manual(values = pal) +
labs(title = "Figure 2: Rolling 252-Day Annualised Sharpe Ratio",
subtitle = paste0("Risk-Free Rate = ", scales::percent(rf_annual),
" (US T-Bill proxy) | Window = 1 Year"),
x = NULL, y = "Annualised Sharpe Ratio",
colour = "Strategy") +
theme_minimal(base_size = 13) +
theme(legend.position = "top",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())sharpe_summary <- tibble(
Metric = "Annualised Sharpe Ratio",
Portfolio = round(as.numeric(sharpe_port), 3),
Benchmark = round(as.numeric(sharpe_bench), 3),
Delta = round(as.numeric(sharpe_port) - as.numeric(sharpe_bench), 3)
)
kable(sharpe_summary,
caption = "**Table 3: Annualised Sharpe Ratio Summary**",
col.names = c("Metric", "Qual-Mom Portfolio", "SPY Benchmark", "Δ (Portfolio − Benchmark)"),
align = "lrrl") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Metric | Qual-Mom Portfolio | SPY Benchmark | Δ (Portfolio − Benchmark) |
|---|---|---|---|
| Annualised Sharpe Ratio | 0.612 | 0.174 | 0.438 |
Interpretation: A Sharpe Ratio > 1.0 is generally considered good; > 2.0 is excellent. The Qual-Mom portfolio’s Sharpe ratio exceeds the benchmark, indicating that the factor strategy delivered superior risk-adjusted returns.
Definition: Maximum Drawdown (MDD) measures the largest peak-to-trough decline in portfolio value over the backtest period. It is a critical measure of downside risk and strategy resilience.
\[\text{MDD} = \frac{\text{Trough Value} - \text{Peak Value}}{\text{Peak Value}}\]
# Drawdown series
dd_port <- Drawdowns(portfolio_returns)
dd_bench <- Drawdowns(bench_returns)
dd_df <- data.frame(
Date = index(dd_port),
Portfolio = as.numeric(dd_port),
Benchmark = as.numeric(dd_bench)
) %>%
pivot_longer(cols = c(Portfolio, Benchmark),
names_to = "Strategy",
values_to = "Drawdown")
ggplot(dd_df, aes(x = Date, y = Drawdown * 100, fill = Strategy)) +
geom_area(alpha = 0.45, position = "identity") +
geom_line(aes(colour = Strategy), linewidth = 0.8) +
scale_fill_manual(values = pal) +
scale_colour_manual(values = pal) +
scale_y_continuous(labels = function(x) paste0(x, "%")) +
labs(title = "Figure 3: Drawdown Profile — Qual-Mom Portfolio vs. S&P 500",
subtitle = "Shaded area shows the depth of peak-to-trough losses",
x = NULL, y = "Drawdown (%)",
fill = "Strategy", colour = "Strategy") +
theme_minimal(base_size = 13) +
theme(legend.position = "top",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())# Max drawdown and recovery stats
mdd_port <- maxDrawdown(portfolio_returns)
mdd_bench <- maxDrawdown(bench_returns)
# Calmar ratio = annualised return / |MDD|
ann_ret_port <- Return.annualized(portfolio_returns, scale = 252)
ann_ret_bench <- Return.annualized(bench_returns, scale = 252)
calmar_port <- as.numeric(ann_ret_port) / abs(as.numeric(mdd_port))
calmar_bench <- as.numeric(ann_ret_bench) / abs(as.numeric(mdd_bench))
mdd_summary <- tibble(
Metric = c("Maximum Drawdown", "Annualised Return", "Calmar Ratio"),
Portfolio = c(scales::percent(as.numeric(mdd_port), accuracy = 0.01),
scales::percent(as.numeric(ann_ret_port), accuracy = 0.01),
round(calmar_port, 3)),
Benchmark = c(scales::percent(as.numeric(mdd_bench), accuracy = 0.01),
scales::percent(as.numeric(ann_ret_bench), accuracy = 0.01),
round(calmar_bench, 3))
)
kable(mdd_summary,
caption = "**Table 4: Drawdown & Risk Metrics Summary**",
col.names = c("Metric", "Qual-Mom Portfolio", "SPY Benchmark"),
align = "lrr") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
row_spec(1, bold = TRUE, background = "#FFF3CD")| Metric | Qual-Mom Portfolio | SPY Benchmark |
|---|---|---|
| Maximum Drawdown | 30.42% | 26.22% |
| Annualised Return | 17.48% | 7.16% |
| Calmar Ratio | 0.575 | 0.273 |
Calmar Ratio = Annualised Return ÷ |MDD|. A higher Calmar Ratio means the portfolio earns more return for each unit of maximum drawdown risk incurred.
Definition:
- Beta (β): Measures the portfolio’s sensitivity to market movements. β = 1 means the portfolio moves in line with the market; β > 1 amplifies market moves.
- Alpha (α): The intercept of the CAPM regression — the return generated independently of market exposure. A statistically significant positive alpha is evidence of genuine skill.
\[R_p - R_f = \alpha + \beta \cdot (R_m - R_f) + \varepsilon\]
# Excess returns
excess_port <- portfolio_returns - rf_daily
excess_bench <- bench_returns - rf_daily
# CAPM OLS regression
capm_model <- lm(as.numeric(excess_port) ~ as.numeric(excess_bench))
summary_capm <- summary(capm_model)
print(summary_capm)##
## Call:
## lm(formula = as.numeric(excess_port) ~ as.numeric(excess_bench))
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.0261616 -0.0027882 -0.0001143 0.0029620 0.0280244
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.0003701 0.0001752 2.112 0.035 *
## as.numeric(excess_bench) 1.1279372 0.0158774 71.040 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.004802 on 749 degrees of freedom
## Multiple R-squared: 0.8708, Adjusted R-squared: 0.8706
## F-statistic: 5047 on 1 and 749 DF, p-value: < 2.2e-16
alpha_daily <- coef(capm_model)[1]
beta_val <- coef(capm_model)[2]
alpha_annual <- (1 + alpha_daily)^252 - 1 # annualise
r_squared <- summary_capm$r.squared
p_alpha <- summary_capm$coefficients[1, 4]
p_beta <- summary_capm$coefficients[2, 4]capm_summary <- tibble(
Metric = c("Alpha (α) — Daily",
"Alpha (α) — Annualised",
"Beta (β)",
"R-Squared",
"p-value (α)",
"p-value (β)"),
Value = c(sprintf("%.6f", alpha_daily),
scales::percent(alpha_annual, accuracy = 0.01),
round(beta_val, 3),
round(r_squared, 4),
sprintf("%.4f", p_alpha),
sprintf("%.4f", p_beta))
)
kable(capm_summary,
caption = "**Table 5: CAPM Regression — Alpha & Beta Estimates**",
col.names = c("Metric", "Estimate"),
align = "lr") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
row_spec(c(1,2), bold = TRUE, background = "#D4EDDA") %>%
row_spec(3, bold = TRUE, background = "#D1ECF1")| Metric | Estimate |
|---|---|
| Alpha (α) — Daily | 0.000370 |
| Alpha (α) — Annualised | 9.77% |
| Beta (β) | 1.128 |
| R-Squared | 0.8708 |
| p-value (α) | 0.0350 |
| p-value (β) | 0.0000 |
capm_df <- data.frame(
Market = as.numeric(excess_bench),
Portfolio = as.numeric(excess_port)
)
ggplot(capm_df, aes(x = Market, y = Portfolio)) +
geom_point(alpha = 0.25, colour = "#2C7BB6", size = 0.9) +
geom_smooth(method = "lm", colour = "#D7191C", linewidth = 1.2, se = TRUE, fill = "#F8CECC") +
geom_hline(yintercept = 0, colour = "grey50", linewidth = 0.5) +
geom_vline(xintercept = 0, colour = "grey50", linewidth = 0.5) +
annotate("text",
x = min(capm_df$Market, na.rm = TRUE) * 0.85,
y = max(capm_df$Portfolio, na.rm = TRUE) * 0.92,
label = sprintf("α = %.4f\nβ = %.3f\nR² = %.3f",
alpha_daily, beta_val, r_squared),
hjust = 0, size = 4, colour = "black",
fontface = "bold",
family = "mono") +
scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) +
scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
labs(title = "Figure 4: CAPM Regression — Qual-Mom Portfolio vs. S&P 500",
subtitle = "Each point = one trading day's excess return | Red line = OLS fit",
x = "SPY Excess Return (Market)",
y = "Portfolio Excess Return") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())Interpretation of Beta: A beta of 1.13 indicates the portfolio is more aggressive than the market. Each 1% market move amplifies to approximately 1.13% in the portfolio.
# ── Collect all key metrics ────────────────────────────────────────────────
all_metrics <- rbind(
table.AnnualizedReturns(combined_returns, Rf = rf_daily, scale = 252),
maxDrawdown(combined_returns),
SharpeRatio.annualized(combined_returns, Rf = rf_daily, scale = 252)
)
rownames(all_metrics) <- c("Annualised Return",
"Annualised Std Dev",
"Annualised Sharpe",
"Maximum Drawdown",
"Sharpe Ratio (check)")
# Clean up duplicate
all_metrics <- all_metrics[1:4, ]
kable(round(all_metrics, 4),
caption = "**Table 6: Comprehensive Performance Dashboard**",
col.names = c("Qual-Mom Portfolio", "SPY Benchmark")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "bordered"),
full_width = FALSE) %>%
row_spec(0, bold = TRUE, background = "#2C7BB6", color = "white")| Qual-Mom Portfolio | SPY Benchmark | |
|---|---|---|
| Annualised Return | 0.1748 | 0.0716 |
| Annualised Std Dev | 0.2119 | 0.1753 |
| Annualised Sharpe | 0.6120 | 0.1735 |
| Maximum Drawdown | 0.3042 | 0.2622 |
# Monthly returns heatmap (portfolio only)
monthly_ret <- apply.monthly(portfolio_returns,
FUN = function(x) prod(1 + x) - 1)
month_df <- data.frame(
Date = as.Date(index(monthly_ret)),
Return = as.numeric(monthly_ret)
) %>%
mutate(Year = year(Date),
Month = month(Date, label = TRUE, abbr = TRUE))
ggplot(month_df, aes(x = Month, y = factor(Year), fill = Return)) +
geom_tile(colour = "white", linewidth = 0.8) +
geom_text(aes(label = scales::percent(Return, accuracy = 0.1)),
size = 3.5, fontface = "bold",
colour = ifelse(month_df$Return < -0.05, "white", "black")) +
scale_fill_gradientn(
colours = c("#D7191C", "#FDAE61", "#FFFFBF", "#A6D96A", "#1A9641"),
values = scales::rescale(c(-0.15, -0.05, 0, 0.05, 0.15)),
labels = scales::percent_format(accuracy = 1),
name = "Monthly\nReturn"
) +
labs(title = "Figure 5: Monthly Return Heatmap — Qual-Mom Portfolio",
subtitle = "Green = positive months | Red = negative months",
x = "Month", y = "Year") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold"),
panel.grid = element_blank(),
axis.ticks = element_blank())# Build a clean final summary for the report
final_table <- tibble(
Metric = c("Cumulative Return",
"Annualised Return",
"Annualised Volatility",
"Sharpe Ratio",
"Maximum Drawdown (MDD)",
"Calmar Ratio",
"Alpha (α, annualised)",
"Beta (β)"),
Portfolio = c(scales::percent(as.numeric(Return.cumulative(portfolio_returns)), accuracy = 0.01),
scales::percent(as.numeric(Return.annualized(portfolio_returns, scale=252)), accuracy = 0.01),
scales::percent(as.numeric(StdDev.annualized(portfolio_returns, scale=252)), accuracy = 0.01),
round(as.numeric(SharpeRatio.annualized(portfolio_returns, Rf=rf_daily, scale=252)), 3),
scales::percent(as.numeric(maxDrawdown(portfolio_returns)), accuracy = 0.01),
round(calmar_port, 3),
scales::percent(alpha_annual, accuracy = 0.01),
round(beta_val, 3)),
Benchmark_SPY = c(scales::percent(as.numeric(Return.cumulative(bench_returns)), accuracy = 0.01),
scales::percent(as.numeric(Return.annualized(bench_returns, scale=252)), accuracy = 0.01),
scales::percent(as.numeric(StdDev.annualized(bench_returns, scale=252)), accuracy = 0.01),
round(as.numeric(SharpeRatio.annualized(bench_returns, Rf=rf_daily, scale=252)), 3),
scales::percent(as.numeric(maxDrawdown(bench_returns)), accuracy = 0.01),
round(calmar_bench, 3),
"—",
"1.000")
)
kable(final_table,
caption = "**Table 7: Final Performance Summary — Qual-Mom vs. S&P 500 (2022–2024)**",
col.names = c("Metric", "Qual-Mom Portfolio", "SPY Benchmark"),
align = "lrr") %>%
kable_styling(bootstrap_options = c("striped", "hover", "bordered"),
full_width = FALSE) %>%
row_spec(0, bold = TRUE, background = "#2C7BB6", color = "white") %>%
row_spec(c(1,4,5,7), bold = TRUE)| Metric | Qual-Mom Portfolio | SPY Benchmark |
|---|---|---|
| Cumulative Return | 61.64% | 22.90% |
| Annualised Return | 17.48% | 7.16% |
| Annualised Volatility | 21.19% | 17.53% |
| Sharpe Ratio | 0.612 | 0.174 |
| Maximum Drawdown (MDD) | 30.42% | 26.22% |
| Calmar Ratio | 0.575 | 0.273 |
| Alpha (α, annualised) | 9.77% | — |
| Beta (β) | 1.128 | 1.000 |
| Finding | Evidence |
|---|---|
| Superior Cumulative Return | The Qual-Mom portfolio delivered a higher cumulative return than SPY over the full backtest window, consistent with the strategy thesis. |
| Efficient Risk Compensation | The Sharpe Ratio confirms that the excess return was not achieved by taking on disproportionate risk. |
| Resilience in Bear Market | The 2022 drawdown profile tests whether the Quality filter (stable cash flows) provided meaningful downside protection versus the pure market. |
| Statistically Measurable Alpha | The CAPM regression isolates a positive daily alpha intercept; its annualised value quantifies the strategy’s market-independent value-add. |
| Beta Near 1 | A beta close to 1.0 confirms the portfolio maintains broad market exposure while the Quality + Momentum tilts add incremental alpha. |
Report generated with R 4.5.1 | PerformanceAnalytics 2.0.8 | Data source: Yahoo Finance via quantmod