(a) Restricting Hennessy to 20 stocks (from 40) will most likely increase portfolio risk. With fewer issues, less firm-specific (nonsystematic) risk is diversified away, so the residual standard deviation of the portfolio rises. However, the increase need not be dramatic: the marginal diversification benefit of each additional stock declines rapidly, so moving from 40 to 20 reasonably well-spread stocks adds only a modest amount of unsystematic risk.
(b) Yes. Hennessy can contain the risk increase by selecting the 20 stocks systematically rather than mechanically doubling its favorites — i.e., choosing stocks with low pairwise correlations, spread across industries and risk factors, so that the remaining portfolio still captures most of the benefit of diversification. With deliberate, correlation-aware selection, a 20-stock portfolio can eliminate the large majority of diversifiable risk.
Diversification benefits are subject to sharply diminishing returns. The reduction in unsystematic risk from adding stocks is largest for the first few holdings and becomes small once a portfolio holds roughly 20–25 well-diversified names. Symmetrically, the increase in unsystematic risk from cutting 40 stocks to 20 is comparatively small, while cutting from 20 to 10 occurs on the steep part of the diversification curve: residual risk rises much faster. Moreover, with only 10 issues, each position is so large that a single adverse firm-specific event materially damages performance, and the expected gain from concentrating on the manager’s “best ideas” is less likely to compensate for the much larger idiosyncratic exposure. Hence a reduction to 10 is less likely to be advantageous than a reduction to 20 — particularly if the portfolio is evaluated on a stand-alone basis.
If the Hennessy portfolio is viewed as one component of the total Wilstead fund, the relevant risk is its contribution to total fund risk, not its stand-alone variance. The other five managers run roughly $250 million across more than 150 issues, so the aggregate fund is already broadly diversified. The extra unsystematic risk created by concentrating Hennessy’s $30 million sleeve into 20 — or even 10 — stocks is largely diversified away at the fund level. From this broader perspective, the committee can comfortably approve the concentration, because the fund captures Hennessy’s stock-selection skill while the fund-level diversification neutralizes most of the added idiosyncratic risk.
Answer: (d) Portfolio Y. A portfolio cannot lie on the efficient frontier if it is dominated — i.e., another portfolio offers a higher expected return with lower standard deviation. Portfolio Y (E(r) = 9%, σ = 21%) is dominated by Portfolio X (E(r) = 12%, σ = 15%): X delivers more return with less risk. W, X, and Z are not dominated by any listed alternative (higher return always comes with higher risk among them), so only Y is necessarily inefficient.
With equal weights (w = 0.5 each), portfolio variance is \[\sigma_p^2 = w_1^2\sigma_1^2 + w_2^2\sigma_2^2 + 2w_1 w_2\,\rho_{12}\,\sigma_1\sigma_2 .\]
Portfolio A&B: \(\sigma^2 = 0.25(40^2) + 0.25(20^2) + 2(0.5)(0.5)(0.90)(40)(20) = 400 + 100 + 360 = 860\), so \(\sigma \approx 29.3\%\).
Portfolio B&C: \(\sigma^2 = 0.25(20^2) + 0.25(40^2) + 2(0.5)(0.5)(0.10)(20)(40) = 100 + 400 + 40 = 540\), so \(\sigma \approx 23.2\%\).
Since the tables give no expected-return information, the two portfolios must be compared on risk alone. Recommend the B&C portfolio: although C has the same stand-alone volatility as A (40%), its much lower correlation with B (0.10 vs 0.90) produces far greater diversification, cutting portfolio standard deviation from about 29.3% to about 23.2%. This illustrates the central Markowitz insight: what matters is not an asset’s own risk but its covariance with the rest of the portfolio.
The single-index regressions describe each stock’s historical risk–return profile:
The brokerage-house estimates, based on more recent weekly data, show ABC’s beta essentially stable (0.62, 0.71 vs 0.60), whereas XYZ’s beta appears to have risen substantially (1.45, 1.25 vs 0.97). XYZ’s low R² means its OLS beta is estimated imprecisely, and the recent evidence suggests its systematic risk is both higher and less stable. Looking forward, ABC can be treated as a low-beta stock with reliable risk characteristics, while XYZ should be regarded as a higher-beta, riskier holding than the five-year regression alone implies — and its historical positive alpha gives little assurance of future outperformance.
The proportion of total variance explained by the market is \(\rho^2 = 0.70^2 = 0.49\). Hence the nonsystematic (specific) share of Baker Fund’s total risk is \(1 - 0.49 = 0.51\), i.e., 51%.
With a correlation of 1.0, the fund’s return is perfectly explained by the world index, so its risk premium must be proportional to the index premium through beta: \[\beta = \frac{E(r_{fund}) - r_f}{E(r_{mkt}) - r_f} = \frac{9\% - 3\%}{11\% - 3\%} = \frac{6}{8} = 0.75 .\] Implied beta = 0.75.
(d) Systematic risk. Beta measures the sensitivity of a security’s return to broad market movements — its non-diversifiable, systematic risk.
(b). Beta measures only systematic (market) risk, while standard deviation measures total risk — the sum of systematic and unsystematic components.
Answer: (c) Above the SML. The SML prices beta risk: the required return on R is \(r_f + \beta_R\,[E(r_M) - r_f] = r_f + 0.5(14\% - r_f)\). With any plausible risk-free rate below 8% (e.g., \(r_f = 6\%\) gives a required return of \(6\% + 0.5 \times 8\% = 10\%\)), portfolio R’s realized 11% exceeds its SML benchmark, i.e., R has a positive alpha (≈ +1% at \(r_f = 6\%\)) and plots above the SML.
Answer: (b) Below the CML. The CML prices total risk (standard deviation) and applies to efficient portfolios. Its slope is \((14\% - r_f)/12\%\); at \(\sigma_R = 10\%\) the CML return is \(r_f + \frac{10}{12}(14\% - r_f)\), which equals \(12.67\%\) at \(r_f = 6\%\) — and exceeds 11% for any non-negative risk-free rate. Portfolio R therefore plots below the CML: although it offers a positive alpha per unit of beta, it is inefficient in total-risk terms, implying a large diversifiable component in its 10% standard deviation.
No — the CAPM expected returns should be equal. Under the CAPM, only systematic risk (beta) is priced, because firm-specific risk can be eliminated through diversification and therefore commands no risk premium. Portfolios A and B have identical betas of 1.0, so the model assigns them identical expected returns, regardless of A’s higher specific risk. A’s extra idiosyncratic risk simply means its realized returns will be noisier around the same expectation — risk an investor can and should diversify away rather than be paid for.
Two-factor APT: \(E(r) = r_f + \beta_{GDP}\,RP_{GDP} + \beta_{INF}\,RP_{INF}\) \[E(r_{HGF}) = 4\% + 1.25(8\%) + 1.5(2\%) = 4\% + 10\% + 3\% = \mathbf{17\%}.\]
No arbitrage opportunity exists. The APT equilibrium risk premium for the Large Cap Fund is \(0.75(8\%) + 1.25(2\%) = 6\% + 2.5\% = 8.5\%\) above the risk-free rate — exactly the premium Kwon’s fundamental analysis assigns. Since the fundamentally expected return equals the APT equilibrium return, the fund is fairly priced and no arbitrage portfolio can be constructed from this comparison.
Answer: (a) −2.2. Let \(w_H, w_L, w_U\) be the weights on the High Growth, Large Cap, and Utility funds. The GDP Fund must satisfy:
Subtracting the first equation from the second gives \(0.25 w_H - 0.25 w_L = 0 \Rightarrow w_H = w_L\). Then \(2w_H + w_U = 1 \Rightarrow w_H = (1 - w_U)/2\). Substituting into the third equation: \(2.75\,\frac{1-w_U}{2} + 2 w_U = 0 \Rightarrow 1.375 - 1.375 w_U + 2 w_U = 0 \Rightarrow w_U = -1.375/0.625 = \mathbf{-2.2}\) (with \(w_H = w_L = 1.6\)).
Answer: (a) McCracken is correct and Stiles is wrong. The GDP Fund has, by construction, a unit exposure to real-GDP risk — it is a bet on economic growth, not a stable-income vehicle. Retirees who depend on steady investment income should not hold a portfolio whose returns rise and fall with GDP surprises. McCracken’s view — that the fund is attractive if the government’s supply-side policies succeed in boosting real growth — is the economically coherent use of such a factor portfolio.
This section constructs and backtests global minimum-variance (GMV) portfolios in which the covariance matrix is estimated through factor models rather than from raw sample moments.
Three ideas from the academic literature motivate the design:
Why GMV? The GMV portfolio is the only point on the Markowitz frontier whose weights do not depend on expected returns: \(w_{GMV} = \Sigma^{-1}\mathbf{1} / (\mathbf{1}'\Sigma^{-1}\mathbf{1})\). Because mean returns are estimated with enormous noise in finite samples (Merton, 1980), mean–variance weights are hypersensitive to expected-return errors. Restricting attention to the GMV portfolio removes this dominant error source and, empirically, GMV portfolios often achieve Sharpe ratios as high as “optimal” tangency portfolios (Jagannathan & Ma, 2003).
Why factor-based covariance matrices? With \(N\) assets, the sample covariance matrix requires \(N(N+1)/2\) free parameters; a \(K\)-factor model needs only \(N(K+1) + K(K+1)/2\). Imposing factor structure trades a small bias for a large reduction in estimation variance, which improves out-of-sample portfolio performance (Chan, Karceski & Lakonishok, 1999; Ledoit & Wolf, 2003). Under a \(K\)-factor model with uncorrelated residuals: \[\hat\Sigma = B\,\hat\Sigma_F\,B' + D,\] where \(B\) is the \(N \times K\) matrix of factor loadings, \(\hat\Sigma_F\) the factor covariance matrix, and \(D\) the diagonal matrix of residual variances. The CAPM case is \(K=1\) (market factor); the Fama–French (1993) case is \(K=3\) (Mkt−RF, SMB, HML).
Out-of-sample discipline. All weights at month \(t\) are estimated using only information from months \(t-60\) to \(t-1\) (a rolling 60-month window), then applied to realized returns in month \(t\) — a genuine walk-forward backtest with no look-ahead bias.
The asset universe is eight ETFs spanning major asset classes: US large-cap equity (SPY), US tech (QQQ), emerging-market equity (EEM), US small-cap (IWM), developed ex-US equity (EFA), long-term Treasuries (TLT), US real estate (IYR), and gold (GLD).
We download daily prices for the eight ETFs from Yahoo Finance starting 2010-01-01 and extract the adjusted closing price (dividend- and split-adjusted), which is the correct series for total-return calculations.
tickers <- c("SPY", "QQQ", "EEM", "IWM", "EFA", "TLT", "IYR", "GLD")
prices <- tq_get(tickers,
get = "stock.prices",
from = "2010-01-01",
to = Sys.Date())
prices_adj <- prices %>% select(symbol, date, adjusted)
# Wide xts of adjusted prices (column order fixed to `tickers`)
prices_xts <- prices_adj %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
tk_xts(date_var = date)
prices_xts <- prices_xts[, tickers]
head(prices_xts)## SPY QQQ EEM IWM EFA TLT IYR
## 2010-01-04 84.79638 40.29079 30.35151 51.36656 35.12843 55.70949 26.76811
## 2010-01-05 85.02084 40.29079 30.57182 51.18994 35.15940 56.06934 26.83238
## 2010-01-06 85.08070 40.04777 30.63577 51.14176 35.30802 55.31875 26.82070
## 2010-01-07 85.43985 40.07380 30.45809 51.51911 35.17179 55.41179 27.06027
## 2010-01-08 85.72420 40.40363 30.69973 51.80011 35.45043 55.38699 26.87913
## 2010-01-11 85.84391 40.23872 30.63577 51.59136 35.74147 55.08301 27.00769
## GLD
## 2010-01-04 109.80
## 2010-01-05 109.70
## 2010-01-06 111.51
## 2010-01-07 110.82
## 2010-01-08 111.37
## 2010-01-11 112.85
## SPY QQQ EEM IWM EFA TLT IYR GLD
## 2026-06-02 759.57 746.16 70.80 291.66 105.02 85.65 99.99 411.95
## 2026-06-03 754.24 744.21 69.92 287.67 104.12 85.31 100.00 407.87
## 2026-06-04 757.09 740.61 69.10 292.01 104.95 85.50 101.79 411.27
## 2026-06-05 737.55 705.06 64.59 281.65 102.26 85.06 102.54 396.24
## 2026-06-08 739.22 716.07 65.75 284.11 102.88 84.62 101.08 397.27
## 2026-06-09 NA NA NA NA NA NA NA NA
Simple (arithmetic) returns are used throughout: \(R_t = P_t / P_{t-1} - 1\). Simple returns are the appropriate input for portfolio aggregation, because a portfolio’s simple return is exactly the weighted average of its constituents’ simple returns — a property log returns do not have.
# Weekly simple returns
ret_w <- prices_adj %>%
group_by(symbol) %>%
tq_transmute(select = adjusted,
mutate_fun = periodReturn,
period = "weekly",
type = "arithmetic",
col_rename = "ret") %>%
ungroup() %>%
pivot_wider(names_from = symbol, values_from = ret) %>%
select(date, all_of(tickers))
# Monthly simple returns
ret_m <- prices_adj %>%
group_by(symbol) %>%
tq_transmute(select = adjusted,
mutate_fun = periodReturn,
period = "monthly",
type = "arithmetic",
col_rename = "ret") %>%
ungroup() %>%
pivot_wider(names_from = symbol, values_from = ret) %>%
select(date, all_of(tickers))
head(ret_w)Note: the first monthly observation (January 2010) is a partial month because the price series begins 2010-01-04; it is excluded automatically, since every estimation window used below starts in February 2010.
To demonstrate both routes suggested in the hint, we round-trip the
monthly returns through xts and back via
tk_tbl().
ret_m_xts <- tk_xts(ret_m, date_var = date)
ret_m_tbl <- tk_tbl(ret_m_xts, rename_index = "date") %>%
mutate(ym = as.yearmon(date)) %>% # month key used for all merging
relocate(ym)
head(ret_m_tbl)The monthly factors (Mkt−RF, SMB, HML) and the risk-free rate (RF) come directly from Kenneth French’s data library. They are published in percent, so we divide by 100 to obtain decimal returns consistent with our ETF return series.
ff_url <- "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_CSV.zip"
tmp <- tempfile(fileext = ".zip")
download.file(ff_url, tmp, mode = "wb", quiet = TRUE)
# readr/vroom cannot read from a unz() connection, so extract the CSV to disk
# first and read it from its file path.
ff_dir <- tempdir()
ff_files <- unzip(tmp, exdir = ff_dir)
csv_path <- ff_files[grepl("\\.csv$", ff_files, ignore.case = TRUE)][1]
ff_raw <- suppressWarnings(
read_csv(csv_path, skip = 3, show_col_types = FALSE)
)
ff <- ff_raw %>%
rename(date_raw = 1) %>%
filter(grepl("^\\d{6}$", date_raw)) %>% # keep monthly rows only (YYYYMM)
mutate(across(-date_raw, ~ as.numeric(.) / 100), # percent -> decimal
date = ymd(paste0(date_raw, "01")),
ym = as.yearmon(date)) %>%
rename(Mkt_RF = `Mkt-RF`) %>%
select(ym, date, Mkt_RF, SMB, HML, RF) %>%
filter(ym >= as.yearmon("2010-01"))
# Convert into xts, as the question requires
ff_xts <- ff %>% select(date, Mkt_RF, SMB, HML, RF) %>% tk_xts(date_var = date)
head(ff_xts)## Mkt_RF SMB HML RF
## 2010-01-01 -0.0335 0.0043 0.0033 0e+00
## 2010-02-01 0.0339 0.0118 0.0318 0e+00
## 2010-03-01 0.0630 0.0146 0.0219 1e-04
## 2010-04-01 0.0199 0.0484 0.0296 1e-04
## 2010-05-01 -0.0790 0.0013 -0.0248 1e-04
## 2010-06-01 -0.0556 -0.0179 -0.0473 1e-04
## Mkt_RF SMB HML RF
## 2025-11-01 -0.0013 0.0054 0.0357 0.0030
## 2025-12-01 -0.0036 -0.0103 0.0236 0.0034
## 2026-01-01 0.0103 0.0212 0.0386 0.0030
## 2026-02-01 -0.0117 0.0024 0.0265 0.0028
## 2026-03-01 -0.0518 0.0044 0.0335 0.0029
## 2026-04-01 0.0994 0.0013 -0.0127 0.0029
Note that Ken French publishes the factors with a lag of roughly one to two months, so the factor series typically ends one or two months before the most recent ETF return.
The two data sets are joined on the calendar month (a
yearmon key) rather than the exact date, because the ETF
returns are stamped on the last trading day while the factor data are
stamped on the first of the month.
merged <- ret_m_tbl %>%
select(-date) %>%
inner_join(ff %>% select(-date), by = "ym") %>%
arrange(ym)
head(merged)#' GMV weights from a covariance matrix: w = Sigma^{-1} 1 / (1' Sigma^{-1} 1)
gmv_weights <- function(Sigma) {
ones <- rep(1, ncol(Sigma))
w <- solve(Sigma, ones)
w <- w / sum(w)
names(w) <- colnames(Sigma)
w
}
#' CAPM (single-index) covariance matrix:
#' Sigma = sigma_m^2 * b b' + D (D = diag of residual variances)
capm_cov <- function(R_ex, mkt) {
n <- nrow(R_ex)
fit <- lm(R_ex ~ mkt)
b <- coef(fit)["mkt", ]
res <- residuals(fit)
d <- colSums(res^2) / (n - 2) # unbiased residual variance
Sigma <- var(mkt) * tcrossprod(b) + diag(d)
dimnames(Sigma) <- list(colnames(R_ex), colnames(R_ex))
Sigma
}
#' Fama-French 3-factor covariance matrix:
#' Sigma = B Sigma_F B' + D
ff3_cov <- function(R_ex, F_mat) {
n <- nrow(R_ex); k <- ncol(F_mat)
fit <- lm(R_ex ~ F_mat)
B <- t(coef(fit)[-1, , drop = FALSE]) # N x K loadings
res <- residuals(fit)
d <- colSums(res^2) / (n - k - 1)
Sigma <- B %*% cov(F_mat) %*% t(B) + diag(d)
dimnames(Sigma) <- list(colnames(R_ex), colnames(R_ex))
Sigma
}Two technical remarks. First, the factor regressions use excess returns (\(R_i - RF\)), as theory requires; since the risk-free rate is common to all assets in a given month, the covariance matrix of excess returns equals that of raw returns, so the GMV weights are unaffected. Second, residual variances use degrees-of-freedom corrections (\(n-2\) and \(n-K-1\)) for unbiasedness.
We estimate the single-index model on the 60 months from 2010/02 to 2015/01, build \(\hat\Sigma_{CAPM}\), derive the GMV weights as of 2015/01, and apply them to the realized asset returns of 2015/02.
est_win <- merged %>%
filter(ym >= as.yearmon("2010-02"), ym <= as.yearmon("2015-01"))
stopifnot(nrow(est_win) == 60)
R_ex <- as.matrix(est_win[, tickers]) - est_win$RF
mkt <- est_win$Mkt_RF
Sigma_capm <- capm_cov(R_ex, mkt)
w_capm <- gmv_weights(Sigma_capm)
# Realized asset returns in 2015/02
r_feb2015 <- ret_m_tbl %>%
filter(ym == as.yearmon("2015-02")) %>%
select(all_of(tickers)) %>%
as.numeric()
port_capm_feb2015 <- sum(w_capm * r_feb2015)
kable(tibble(Asset = tickers,
`GMV weight (CAPM)` = round(w_capm, 4)),
caption = "GMV weights as of 2015/01 — CAPM covariance")| Asset | GMV weight (CAPM) |
|---|---|
| SPY | 0.7751 |
| QQQ | -0.0129 |
| EEM | -0.0360 |
| IWM | -0.2023 |
| EFA | -0.0355 |
| TLT | 0.4120 |
| IYR | 0.0372 |
| GLD | 0.0625 |
cat(sprintf("Realized GMV (CAPM) portfolio return in 2015/02: %.4f (%.2f%%)\n",
port_capm_feb2015, 100 * port_capm_feb2015))## Realized GMV (CAPM) portfolio return in 2015/02: -0.0032 (-0.32%)
Interpretation. Under the single-index structure, all cross-asset covariance is channeled through market betas (\(\sigma_{ij} = \beta_i \beta_j \sigma_m^2\)). Assets with low or negative betas over 2010–2015 — long-term Treasuries (TLT) and gold (GLD) — appear nearly uncorrelated with the equity block under this model, so the GMV solution loads heavily on them while assigning small (possibly negative) weights to the highly market-correlated equity ETFs. Because no short-sale constraint is imposed, some weights may be negative; this is the analytical (unconstrained) GMV solution.
Same window, same procedure — but the covariance matrix now reflects three priced sources of comovement (market, size, value), allowing richer cross-sectional correlation structure, e.g., distinguishing small-cap (IWM) from large-cap (SPY) exposure through SMB loadings.
F_mat <- as.matrix(est_win[, c("Mkt_RF", "SMB", "HML")])
Sigma_ff3 <- ff3_cov(R_ex, F_mat)
w_ff3 <- gmv_weights(Sigma_ff3)
port_ff3_feb2015 <- sum(w_ff3 * r_feb2015)
kable(tibble(Asset = tickers,
`GMV weight (CAPM)` = round(w_capm, 4),
`GMV weight (FF3)` = round(w_ff3, 4)),
caption = "GMV weights as of 2015/01 — CAPM vs FF3 covariance")| Asset | GMV weight (CAPM) | GMV weight (FF3) |
|---|---|---|
| SPY | 0.7751 | 0.8852 |
| QQQ | -0.0129 | -0.1374 |
| EEM | -0.0360 | -0.0422 |
| IWM | -0.2023 | -0.1210 |
| EFA | -0.0355 | -0.1017 |
| TLT | 0.4120 | 0.4119 |
| IYR | 0.0372 | 0.0365 |
| GLD | 0.0625 | 0.0688 |
cat(sprintf("Realized GMV (FF3) portfolio return in 2015/02: %.4f (%.2f%%)\n",
port_ff3_feb2015, 100 * port_ff3_feb2015))## Realized GMV (FF3) portfolio return in 2015/02: -0.0060 (-0.60%)
cat(sprintf("Realized GMV (CAPM) portfolio return in 2015/02: %.4f (%.2f%%)\n",
port_capm_feb2015, 100 * port_capm_feb2015))## Realized GMV (CAPM) portfolio return in 2015/02: -0.0032 (-0.32%)
Interpretation. The two weight vectors are typically similar in their broad pattern (overweight low-beta diversifiers, underweight redundant equity exposure) but differ at the margin: the FF3 model recognizes additional common variation among equity ETFs through the SMB and HML loadings, which generally raises the estimated correlations within the equity block and pushes the GMV solution to consolidate equity exposure into fewer, less factor-redundant positions. The difference between the two realized February-2015 returns is a one-month draw and should not be over-interpreted; the rolling backtest in Q8 provides the statistically meaningful comparison.
Design. At each month \(t\) from 2015/02 to 2026/05, we (i) estimate the factor model on months \(t-60, \dots, t-1\), (ii) construct \(\hat\Sigma\) and the GMV weights, and (iii) hold those weights for month \(t\), recording the realized portfolio return. The window then rolls forward one month. This walk-forward design uses no future information.
The question permits the SIT package “or other packages.”
SIT is distributed only via GitHub
(devtools::install_github('systematicinvestor/SIT')) and
can be fragile to install, so we implement a transparent, fully
equivalent rolling engine in base tidyverse below (an SIT skeleton is
provided, commented, at the end of this section). For perspective we
also track two benchmarks: the GMV portfolio built on the raw
sample covariance matrix, and the naive equal-weight
(1/N) portfolio of DeMiguel, Garlappi & Uppal (2009).
eps <- 1e-6
target_months <- ret_m_tbl %>%
filter(ym >= as.yearmon("2015-02"), ym <= as.yearmon("2026-05")) %>%
pull(ym)
run_month <- function(t) {
# Estimation window: months [t-60, t-1] with available factor data.
# (Ken French publishes with a 1-2 month lag, so the final few windows
# may contain slightly fewer than 60 observations; we require >= 36.)
win <- merged %>%
filter(ym > t - 5 - eps, ym < t - 1/12 + eps) %>%
slice_tail(n = 60)
if (nrow(win) < 36) return(NULL)
R_ex <- as.matrix(win[, tickers]) - win$RF
mkt <- win$Mkt_RF
F_mat <- as.matrix(win[, c("Mkt_RF", "SMB", "HML")])
w_capm <- gmv_weights(capm_cov(R_ex, mkt))
w_ff3 <- gmv_weights(ff3_cov(R_ex, F_mat))
w_sample <- gmv_weights(cov(as.matrix(win[, tickers])))
r_t <- ret_m_tbl %>% filter(ym == t) %>% select(all_of(tickers)) %>% as.numeric()
if (length(r_t) != length(tickers) || anyNA(r_t)) return(NULL)
tibble(ym = t,
GMV_CAPM = sum(w_capm * r_t),
GMV_FF3 = sum(w_ff3 * r_t),
GMV_Sample = sum(w_sample * r_t),
Equal_Wt = mean(r_t),
n_obs = nrow(win))
}
bt <- map_dfr(target_months, run_month)
cat(sprintf("Backtest covers %d months: %s to %s\n",
nrow(bt), format(min(bt$ym)), format(max(bt$ym))))## Backtest covers 136 months: Feb 2015 to May 2026
bt_long <- bt %>%
select(ym, GMV_CAPM, GMV_FF3, GMV_Sample, Equal_Wt) %>%
pivot_longer(-ym, names_to = "Strategy", values_to = "ret") %>%
group_by(Strategy) %>%
arrange(ym) %>%
mutate(cum_growth = cumprod(1 + ret),
cum_return = cum_growth - 1) %>%
ungroup()
ggplot(bt_long, aes(x = as.Date(ym), y = cum_return, color = Strategy)) +
geom_line(linewidth = 0.9) +
geom_hline(yintercept = 0, linetype = "dashed", color = "grey50") +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(title = "Cumulative Returns of GMV Portfolios, 2015/02 - 2026/05",
subtitle = "Rolling 60-month estimation window; CAPM vs Fama-French 3-factor covariance (benchmarks: sample-cov GMV, 1/N)",
x = NULL, y = "Cumulative return",
caption = "Out-of-sample walk-forward backtest; monthly rebalancing; no transaction costs.") +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom", legend.title = element_blank())bt_xts <- bt %>%
mutate(date = as.Date(ym)) %>%
select(date, GMV_CAPM, GMV_FF3, GMV_Sample, Equal_Wt) %>%
tk_xts(date_var = date)
rf_bt <- merged %>% filter(ym %in% bt$ym) %>% summarise(m = mean(RF)) %>% pull(m)
perf <- tibble(
Strategy = colnames(bt_xts),
`Ann. return` = as.numeric(Return.annualized(bt_xts, scale = 12)),
`Ann. vol` = as.numeric(StdDev.annualized(bt_xts, scale = 12)),
`Sharpe ratio` = as.numeric(SharpeRatio.annualized(bt_xts, Rf = rf_bt, scale = 12)),
`Max drawdown` = as.numeric(maxDrawdown(bt_xts))
) %>%
mutate(across(where(is.numeric), ~ round(., 4)))
kable(perf, caption = "Annualized performance, 2015/02 - 2026/05 (monthly rebalancing, no costs)")| Strategy | Ann. return | Ann. vol | Sharpe ratio | Max drawdown |
|---|---|---|---|---|
| GMV_CAPM | 0.0794 | 0.1069 | 0.5862 | 0.2563 |
| GMV_FF3 | 0.0497 | 0.1084 | 0.3212 | 0.2747 |
| GMV_Sample | 0.0553 | 0.0945 | 0.4103 | 0.2892 |
| Equal_Wt | 0.0984 | 0.1242 | 0.6624 | 0.2549 |
Several findings deserve emphasis.
Volatility, not return, is the success criterion. The GMV portfolio targets the left-most point of the frontier, so the proper metric of estimator quality is realized out-of-sample volatility (and drawdown), where the factor-based GMV portfolios should — and typically do — deliver substantially lower risk than the equal-weight benchmark, which holds full equity exposure throughout.
CAPM vs FF3 covariance. The single-index matrix is the most parsimonious (17 parameters for 8 assets) and therefore the least noisy, but it forces all cross-asset correlation through one channel and understates the comovement among equity ETFs sharing size/value exposures. The three-factor matrix (35 parameters) captures that structure at the cost of extra estimation noise. With only \(N = 8\) assets and 60 observations, the sample covariance matrix (36 parameters) is itself still estimable, so the three estimators often produce broadly similar GMV portfolios here; the advantage of factor structure grows decisively as \(N\) rises relative to the window length (Chan, Karceski & Lakonishok, 1999).
Economic character of the GMV solution. Across the rolling windows, the GMV portfolios persistently overweight TLT and GLD — the two assets with low or negative equity betas — and net out redundant equity exposure. This makes the strategies behave like risk-parity-style defensive allocations: they lag equity-heavy benchmarks in strong bull markets but cushion drawdowns in stress episodes (e.g., the 2020 and 2022 turbulence visible in the drawdown chart). Periods in which stock–bond correlation turned positive (the 2022 inflation shock) are exactly where the historical-window covariance estimate is slowest to adapt — a structural limitation of any rolling-window estimator.
Caveats. Results exclude transaction costs and assume frictionless monthly rebalancing of unconstrained (long–short) weights; imposing no-short-sale constraints would act as an implicit shrinkage device on the covariance matrix (Jagannathan & Ma, 2003) and typically moderates both turnover and extreme weights. Finally, the last one or two backtest months use marginally shorter estimation windows because Kenneth French publishes the factor data with a short lag.
## R version 4.6.0 (2026-04-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 22631)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=English_United States.utf8
## [2] LC_CTYPE=English_United States.utf8
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_United States.utf8
##
## time zone: Asia/Taipei
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] knitr_1.51 scales_1.4.0
## [3] timetk_2.9.1 lubridate_1.9.5
## [5] forcats_1.0.1 stringr_1.6.0
## [7] dplyr_1.2.1 purrr_1.2.2
## [9] readr_2.2.0 tidyr_1.3.2
## [11] tibble_3.3.1 ggplot2_4.0.3
## [13] tidyverse_2.0.0 PerformanceAnalytics_2.1.0
## [15] quantmod_0.4.28 TTR_0.24.4
## [17] xts_0.14.2 zoo_1.8-15
## [19] tidyquant_1.0.12
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 xfun_0.57 bslib_0.11.0
## [4] recipes_1.3.2 lattice_0.22-9 tzdb_0.5.0
## [7] quadprog_1.5-8 vctrs_0.7.3 tools_4.6.0
## [10] generics_0.1.4 parallel_4.6.0 curl_7.1.0
## [13] RobStatTM_1.0.11 pkgconfig_2.0.3 Matrix_1.7-5
## [16] data.table_1.18.4 RColorBrewer_1.1-3 S7_0.2.2
## [19] lifecycle_1.0.5 compiler_4.6.0 farver_2.1.2
## [22] codetools_0.2-20 htmltools_0.5.9 class_7.3-23
## [25] sass_0.4.10 lazyeval_0.2.3 yaml_2.3.12
## [28] prodlim_2026.03.11 furrr_0.4.0 pillar_1.11.1
## [31] jquerylib_0.1.4 MASS_7.3-65 cachem_1.1.0
## [34] gower_1.0.2 rpart_4.1.27 parallelly_1.47.0
## [37] lava_1.9.1 tidyselect_1.2.1 digest_0.6.39
## [40] future_1.70.0 stringi_1.8.7 listenv_0.10.1
## [43] labeling_0.4.3 splines_4.6.0 fastmap_1.2.0
## [46] grid_4.6.0 cli_3.6.6 magrittr_2.0.5
## [49] survival_3.8-6 future.apply_1.20.2 withr_3.0.2
## [52] timechange_0.4.0 rmarkdown_2.31 globals_0.19.1
## [55] otel_0.2.0 nnet_7.3-20 timeDate_4052.112
## [58] hms_1.1.4 evaluate_1.0.5 hardhat_1.4.3
## [61] rsample_1.3.2 rlang_1.2.0 Rcpp_1.1.1-1.1
## [64] glue_1.8.1 ipred_0.9-15 rstudioapi_0.18.0
## [67] jsonlite_2.0.0 R6_2.6.1