This portfolio is built around the Low Volatility Anomaly — a well-documented phenomenon in empirical finance where assets with lower historical volatility tend to generate superior risk-adjusted returns compared to high-volatility assets, contradicting the traditional risk-return tradeoff of the CAPM.
Under the Capital Asset Pricing Model (CAPM), expected return is a linear function of market beta:
\[E(R_i) = R_f + \beta_i \cdot [E(R_m) - R_f]\]
However, Blitz & Van Vliet (2007) and Baker, Bradley & Wurgler (2011) empirically demonstrate that low-beta, low-volatility stocks persistently outperform their high-volatility counterparts on a risk-adjusted basis. This anomaly persists due to:
Our Alpha comes from three sources:
| Step | Task | Prompt Used |
|---|---|---|
| Strategy selection | Identify best strategy given skills and course context | “Help me choose a strategy for my portfolio project — I’m a Finance student in Taiwan, I know R and GMVP” |
| Asset universe | Select 8 ETFs aligned with low-vol philosophy | “What US ETFs best represent a low volatility defensive strategy? Max 10 assets” |
| Theoretical grounding | Identify academic references for the anomaly | “What is the theoretical justification for the low volatility anomaly?” |
| R code | Build GMVP optimizer + backtesting pipeline | “Write R code to compute GMVP weights and backtest vs SPY with Sharpe, MDD, Alpha, Beta” |
| Interpretation | Analyze backtesting results | “Interpret these Sharpe Ratio and Alpha results in the context of the low vol anomaly” |
Claude helped narrow the asset universe by applying three filters: 1. Liquidity filter: ETFs with AUM > $1B 2. Sector filter: Defensive sectors only (Utilities, Staples, Healthcare) 3. Correlation filter: Include uncorrelated assets (Gold, Bonds) to diversify
library(knitr)
library(kableExtra)
etf_list <- data.frame(
Ticker = c("USMV","SPLV","SPHD","XLU","XLP","XLV","IEF","GLD"),
Name = c("iShares MSCI USA Min Vol Factor ETF",
"Invesco S&P 500 Low Volatility ETF",
"Invesco S&P 500 High Div Low Vol ETF",
"Utilities Select Sector SPDR ETF",
"Consumer Staples Select Sector SPDR ETF",
"Health Care Select Sector SPDR ETF",
"iShares 7-10 Year Treasury Bond ETF",
"SPDR Gold Shares"),
Category = c("Min Volatility","Low Volatility","Div + Low Vol",
"Utilities","Consumer Staples","Healthcare",
"Treasury Bonds","Gold"),
Role = c("Core low-vol exposure","Core low-vol exposure",
"Income + stability","Defensive sector",
"Defensive sector","Defensive sector",
"Risk stabilizer","Inflation hedge"),
stringsAsFactors = FALSE
)
etf_list %>%
kable(caption = "Selected ETF Universe — Low Volatility Portfolio") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = TRUE)| Ticker | Name | Category | Role |
|---|---|---|---|
| USMV | iShares MSCI USA Min Vol Factor ETF | Min Volatility | Core low-vol exposure |
| SPLV | Invesco S&P 500 Low Volatility ETF | Low Volatility | Core low-vol exposure |
| SPHD | Invesco S&P 500 High Div Low Vol ETF | Div + Low Vol | Income + stability |
| XLU | Utilities Select Sector SPDR ETF | Utilities | Defensive sector |
| XLP | Consumer Staples Select Sector SPDR ETF | Consumer Staples | Defensive sector |
| XLV | Health Care Select Sector SPDR ETF | Healthcare | Defensive sector |
| IEF | iShares 7-10 Year Treasury Bond ETF | Treasury Bonds | Risk stabilizer |
| GLD | SPDR Gold Shares | Gold | Inflation hedge |
Benchmark: SPY (SPDR S&P 500 ETF Trust)
SPY is chosen because:
Our strategy’s Alpha is defined as excess return over SPY after controlling for market exposure, computed via a simple regression:
\[R_{portfolio,t} - R_f = \alpha + \beta \cdot (R_{SPY,t} - R_f) + \varepsilon_t\]
library(quantmod)
library(tidyverse)
library(lubridate)
library(PerformanceAnalytics)
library(kableExtra)tickers <- c("USMV","SPLV","SPHD","XLU","XLP","XLV","IEF","GLD","SPY")
start_date <- "2022-01-01"
end_date <- "2024-12-31"
# Download adjusted closing prices
getSymbols(tickers, src = "yahoo",
from = start_date, to = end_date,
auto.assign = TRUE)## [1] "USMV" "SPLV" "SPHD" "XLU" "XLP" "XLV" "IEF" "GLD" "SPY"
# Extract adjusted prices into one xts object
prices <- do.call(merge, lapply(tickers, function(t) Ad(get(t))))
colnames(prices) <- tickers
# Monthly returns
monthly_ret <- Return.calculate(to.monthly(prices, indexAt = "lastof",
OHLC = FALSE),
method = "log")
monthly_ret <- na.omit(monthly_ret)
head(monthly_ret, 4)## USMV SPLV SPHD XLU XLP
## 2022-02-28 -0.0311127035 -0.023222304 -0.007859218 -0.01924544 -0.01418471
## 2022-03-31 0.0543092592 0.052928787 0.050674071 0.09844416 0.01765665
## 2022-04-30 -0.0548482338 -0.024649939 -0.011992680 -0.04392704 0.02279806
## 2022-05-31 0.0004082599 -0.005276203 0.027083470 0.04217931 -0.04168639
## XLV IEF GLD SPY
## 2022-02-28 -0.009724504 -0.003045706 0.05941661 -0.029961534
## 2022-03-31 0.055795441 -0.041456736 0.01264529 0.036901184
## 2022-04-30 -0.050145236 -0.043202632 -0.02092027 -0.091862032
## 2022-05-31 0.014780119 0.006164810 -0.03315922 0.002254583
We compute the Global Minimum Variance Portfolio (GMVP) analytically:
\[\mathbf{w}_{GMVP} = \frac{\Sigma^{-1}\mathbf{1}}{\mathbf{1}^\top \Sigma^{-1}\mathbf{1}}\]
library(quadprog)
# In-sample: use full backtest period for weight estimation
R_etf <- monthly_ret[, tickers[1:8]] # exclude SPY from optimization
cov_mat <- cov(R_etf)
n <- ncol(R_etf)
# Constrained GMVP: minimize w'Sigma*w
# subject to: sum(w) = 1, w_i >= 5%, w_i <= 40%
Dmat <- 2 * cov_mat
dvec <- rep(0, n)
w_min <- 0.05 # minimum 5% per ETF
w_max <- 0.40 # maximum 40% per ETF
# Amat: [sum=1 | w>=w_min | -w>=-w_max]
Amat <- cbind(rep(1, n), diag(n), -diag(n))
bvec <- c(1, rep(w_min, n), rep(-w_max, n))
sol <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
w_gmvp <- sol$solution
names(w_gmvp) <- tickers[1:8]
# Display weights
weight_df <- data.frame(
ETF = names(w_gmvp),
Weight = round(w_gmvp, 4),
Weight_Pct = paste0(round(w_gmvp * 100, 2), "%")
)
kable(weight_df,
caption = "GMVP Optimal Weights (Long-Only Constrained)",
col.names = c("ETF","Weight (decimal)","Weight (%)"),
row.names = FALSE)| ETF | Weight (decimal) | Weight (%) |
|---|---|---|
| USMV | 0.050 | 5% |
| SPLV | 0.050 | 5% |
| SPHD | 0.050 | 5% |
| XLU | 0.050 | 5% |
| XLP | 0.050 | 5% |
| XLV | 0.083 | 8.3% |
| IEF | 0.400 | 40% |
| GLD | 0.267 | 26.7% |
ggplot(weight_df, aes(x = reorder(ETF, Weight), y = Weight * 100, fill = ETF)) +
geom_col(width = 0.6, show.legend = FALSE) +
geom_text(aes(label = Weight_Pct), hjust = -0.1, size = 3.5) +
coord_flip() +
scale_fill_brewer(palette = "Set2") +
labs(title = "GMVP Optimal Weights — Low Volatility ETF Portfolio (Long-Only)",
x = NULL, y = "Weight (%)") +
theme_minimal(base_size = 13) +
ylim(0, max(weight_df$Weight * 100) * 1.25)GMVP Optimal Portfolio Weights
# Portfolio monthly returns using GMVP weights (long-only)
port_ret <- xts(
as.numeric(as.matrix(R_etf) %*% w_gmvp),
order.by = index(R_etf)
)
colnames(port_ret) <- "GMVP_Portfolio"
# SPY monthly returns (benchmark)
spy_ret <- monthly_ret[, "SPY"]
# Combine
comparison <- merge(port_ret, spy_ret)
colnames(comparison) <- c("GMVP Portfolio", "SPY (Benchmark)")cum_ret <- cumprod(1 + comparison) - 1
cum_ret_df <- data.frame(
Date = index(cum_ret),
Portfolio = as.numeric(cum_ret[, 1]),
SPY = as.numeric(cum_ret[, 2])
)
ggplot(cum_ret_df, aes(x = Date)) +
geom_line(aes(y = Portfolio * 100, colour = "GMVP Portfolio"), linewidth = 1.2) +
geom_line(aes(y = SPY * 100, colour = "SPY Benchmark"), linewidth = 1.2,
linetype = "dashed") +
geom_hline(yintercept = 0, colour = "grey50", linetype = "dotted") +
scale_colour_manual(values = c("GMVP Portfolio" = "steelblue",
"SPY Benchmark" = "tomato")) +
labs(title = "Cumulative Return: GMVP Portfolio vs S&P 500 (SPY)",
subtitle = "Backtest period: January 2022 – December 2024",
x = NULL, y = "Cumulative Return (%)",
colour = NULL) +
theme_minimal(base_size = 13) +
theme(legend.position = "top")Cumulative Return: GMVP Portfolio vs SPY
chart.Drawdown(comparison,
main = "Drawdown — GMVP Portfolio vs SPY",
colorset = c("steelblue","tomato"),
legend.loc = "bottomleft")Drawdown Chart: GMVP Portfolio vs SPY
mdd_port <- maxDrawdown(port_ret)
mdd_spy <- maxDrawdown(spy_ret)
data.frame(
Portfolio = c("GMVP Portfolio","SPY Benchmark"),
Max_Drawdown = paste0(round(c(mdd_port, mdd_spy) * 100, 2), "%")
) %>%
kable(caption = "Maximum Drawdown (MDD)",
col.names = c("Portfolio","Maximum Drawdown")) %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| Portfolio | Maximum Drawdown |
|---|---|
| GMVP Portfolio | 12.2% |
| SPY Benchmark | 21.67% |
We estimate Alpha and Beta by regressing portfolio excess returns on SPY excess returns:
\[R_{port,t} - R_f = \alpha + \beta \cdot (R_{SPY,t} - R_f) + \varepsilon_t\]
excess_port <- port_ret - Rf_monthly
excess_spy <- spy_ret - Rf_monthly
ab_model <- lm(as.numeric(excess_port) ~ as.numeric(excess_spy))
summary(ab_model)##
## Call:
## lm(formula = as.numeric(excess_port) ~ as.numeric(excess_spy))
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.031054 -0.015761 -0.002775 0.014651 0.034891
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -0.0007628 0.0031984 -0.238 0.813
## as.numeric(excess_spy) 0.4027421 0.0639897 6.294 4.07e-07 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.01862 on 33 degrees of freedom
## Multiple R-squared: 0.5455, Adjusted R-squared: 0.5318
## F-statistic: 39.61 on 1 and 33 DF, p-value: 4.073e-07
alpha_ann <- coef(ab_model)[1] * 12 # annualise monthly alpha
beta_val <- coef(ab_model)[2]
r2 <- summary(ab_model)$r.squared
p_alpha <- summary(ab_model)$coefficients[1, 4]
p_beta <- summary(ab_model)$coefficients[2, 4]
data.frame(
Metric = c("Alpha (annualised)", "Beta", "R-squared",
"p-value (Alpha)", "p-value (Beta)"),
Value = round(c(alpha_ann, beta_val, r2, p_alpha, p_beta), 4)
) %>%
kable(caption = "Alpha & Beta — GMVP Portfolio vs SPY") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| Metric | Value |
|---|---|
| Alpha (annualised) | -0.0092 |
| Beta | 0.4027 |
| R-squared | 0.5455 |
| p-value (Alpha) | 0.8130 |
| p-value (Beta) | 0.0000 |
ann_ret_port <- Return.annualized(port_ret) * 100
ann_ret_spy <- Return.annualized(spy_ret) * 100
ann_sd_port <- StdDev.annualized(port_ret) * 100
ann_sd_spy <- StdDev.annualized(spy_ret) * 100
data.frame(
Metric = c("Annualised Return (%)", "Annualised Std Dev (%)",
"Sharpe Ratio", "Max Drawdown (%)",
"Alpha (annualised)", "Beta"),
GMVP_Portfolio = round(c(as.numeric(ann_ret_port),
as.numeric(ann_sd_port),
as.numeric(sharpe_port),
mdd_port * 100,
alpha_ann,
beta_val), 4),
SPY_Benchmark = round(c(as.numeric(ann_ret_spy),
as.numeric(ann_sd_spy),
as.numeric(sharpe_spy),
mdd_spy * 100,
0, 1), 4)
) %>%
kable(caption = "Complete Performance Summary — GMVP Portfolio vs SPY",
col.names = c("Metric","GMVP Portfolio","SPY Benchmark")) %>%
kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
row_spec(3, bold = TRUE, color = "white", background = "steelblue") %>%
row_spec(5, bold = TRUE, color = "white", background = "steelblue")| Metric | GMVP Portfolio | SPY Benchmark |
|---|---|---|
| Annualised Return (%) | 3.0012 | 9.6338 |
| Annualised Std Dev (%) | 9.4255 | 17.2857 |
| Sharpe Ratio | 0.3599 | 0.6188 |
| Max Drawdown (%) | 12.1964 | 21.6714 |
| Alpha (annualised) | -0.0092 | 0.0000 |
| Beta | 0.4027 | 1.0000 |
What Claude identified that traditional analysis might have missed:
Correlation-aware diversification: Beyond simply picking low-vol ETFs, Claude pointed out the importance of including uncorrelated assets (GLD, IEF) to push the portfolio further along the efficient frontier — something a naive sector-screen approach would have missed.
Bear market timing: Claude highlighted that 2022 was an ideal backtesting period because it includes a genuine bear market (S&P 500 fell ~18%), making the defensive nature of the portfolio testable under stress conditions.
GMVP vs Equal-Weight trade-off: Claude explained that GMVP weights may concentrate heavily in the lowest-variance single asset, which can hurt out-of-sample performance. A shrinkage or constraint (e.g. max weight 30%) could be considered in practice.
| Hypothesis | Result | Assessment |
|---|---|---|
| Portfolio Sharpe > SPY Sharpe | See table above | ✅ / ❌ based on results |
| Max Drawdown < SPY drawdown | See MDD table | ✅ / ❌ based on results |
| Positive Alpha vs SPY | See regression | ✅ / ❌ based on results |
| Beta < 1 (defensive) | See Beta table | ✅ Expected for low-vol ETFs |
Look-ahead bias: GMVP weights were computed using the full backtest period. In practice, weights should be re-estimated using a rolling window.
Transaction costs: No trading costs or rebalancing fees were modelled. Monthly rebalancing of 8 ETFs would create non-trivial costs.
2022–2024 period specificity: This period includes an unusual combination of high inflation, rising rates (2022), and a strong tech-driven recovery (2023–2024). Results may not generalise to other macro regimes.
Survivorship bias: All selected ETFs existed throughout the period — no ETF closures were modelled.
Strategy: Low Volatility ETF Portfolio | Benchmark: SPY | Period:
2022–2024
AI Tool: Claude (Anthropic) | Optimization: GMVP
(Analytical)
References: Blitz & Van Vliet (2007); Baker, Bradley &
Wurgler (2011)