1 Part I: Investment Thesis & Logic

1.1 Strategy Description

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.

1.1.1 Theoretical Foundation

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:

  • Leverage constraints: Institutional investors cannot always use leverage, so they bid up high-risk assets seeking returns.
  • Benchmark-hugging behavior: Fund managers tilt toward high-beta stocks to beat market benchmarks, leaving low-volatility stocks underpriced.
  • Investor preference for “lottery-like” stocks: Retail investors overpay for high-volatility, high-upside assets.

1.1.2 Source of Alpha

Our Alpha comes from three sources:

  1. Volatility premium: Holding systematically lower-volatility assets generates a Sharpe Ratio superior to the market benchmark (SPY).
  2. Defensive sector tilt: Overweighting Utilities, Healthcare, and Consumer Staples — historically outperforming in bear markets (e.g. 2022 drawdown).
  3. Diversification via Gold & Bonds: Adding GLD and IEF reduces correlation with equity risk factors, improving portfolio efficiency on the mean-variance frontier.

1.2 AI Collaboration Process

1.2.1 How Claude Was Used

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


2 Part II: Portfolio Construction

2.1 Selected ETFs

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)
Selected ETF Universe — Low Volatility Portfolio
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

2.2 Benchmark Selection

Benchmark: SPY (SPDR S&P 500 ETF Trust)

SPY is chosen because:

  • It represents the U.S. broad market — the natural reference for U.S.-focused ETFs.
  • It captures the market beta (β = 1) against which we measure our low-vol tilt.
  • It is the most widely used institutional benchmark, making comparisons meaningful.

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\]


2.3 Data Download & Return Computation

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

2.4 GMVP Weight Optimization

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)
GMVP Optimal Weights (Long-Only Constrained)
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

GMVP Optimal Portfolio Weights


3 Part III: Backtesting & Performance Analysis

3.1 Portfolio Returns Construction

# 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)")

3.2 1. Cumulative Return

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

Cumulative Return: GMVP Portfolio vs SPY

3.3 2. Sharpe Ratio

Rf_monthly <- 0  # Risk-free rate = 0 for simplicity

sharpe_port <- SharpeRatio.annualized(port_ret, Rf = Rf_monthly)
sharpe_spy  <- SharpeRatio.annualized(spy_ret,  Rf = Rf_monthly)

data.frame(
  Portfolio = c("GMVP Portfolio", "SPY Benchmark"),
  Annualised_Sharpe = round(c(as.numeric(sharpe_port),
                               as.numeric(sharpe_spy)), 4)
) %>%
  kable(caption = "Annualised Sharpe Ratio (Rf = 0)",
        col.names = c("Portfolio","Annualised Sharpe Ratio")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = FALSE)
Annualised Sharpe Ratio (Rf = 0)
Portfolio Annualised Sharpe Ratio
GMVP Portfolio 0.3599
SPY Benchmark 0.6188

3.4 3. Maximum Drawdown (MDD)

chart.Drawdown(comparison,
               main  = "Drawdown — GMVP Portfolio vs SPY",
               colorset = c("steelblue","tomato"),
               legend.loc = "bottomleft")
Drawdown Chart: GMVP Portfolio vs SPY

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)
Maximum Drawdown (MDD)
Portfolio Maximum Drawdown
GMVP Portfolio 12.2%
SPY Benchmark 21.67%

3.5 4. Alpha & Beta

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)
Alpha & Beta — GMVP Portfolio vs SPY
Metric Value
Alpha (annualised) -0.0092
Beta 0.4027
R-squared 0.5455
p-value (Alpha) 0.8130
p-value (Beta) 0.0000

3.6 Full Performance Summary

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")
Complete Performance Summary — GMVP Portfolio vs SPY
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

4 Part IV: Critical Reflection

4.1 Insights from AI Collaboration

What Claude identified that traditional analysis might have missed:

  1. 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.

  2. 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.

  3. 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.

4.2 Alignment with Initial Hypothesis

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

4.3 Limitations & Potential Discrepancies

  1. Look-ahead bias: GMVP weights were computed using the full backtest period. In practice, weights should be re-estimated using a rolling window.

  2. Transaction costs: No trading costs or rebalancing fees were modelled. Monthly rebalancing of 8 ETFs would create non-trivial costs.

  3. 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.

  4. 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)