Context: Hennessy & Associates manages a $30 million equity portfolio for the Wilstead Pension Fund. Jones proposes limiting Hennessy’s portfolio from 40 to 20 stocks by doubling commitment to each remaining issue.
Q1a. Will the limitation to 20 stocks likely increase or decrease the risk of the portfolio? Explain.
Limiting the portfolio to 20 stocks will increase risk. Diversification benefits arise from holding uncorrelated (or low-correlated) assets — the more securities held, the greater the reduction in unsystematic (firm-specific) risk. Going from 40 to 20 stocks roughly doubles the average weight per stock, concentrating the portfolio and leaving more idiosyncratic risk un-diversified.
Formula reminder:
\[\sigma^2_p = \sum_{i=1}^{n} w_i^2 \sigma_i^2 + \sum_{i \neq j} w_i w_j \sigma_{ij}\]
With equal weights \(w_i = 1/n\), the variance of a portfolio of \(n\) assets each with variance \(\sigma^2\) and pairwise covariance \(\bar{\sigma}_{ij}\) simplifies to:
\[\sigma^2_p = \frac{1}{n}\sigma^2 + \frac{n-1}{n}\bar{\sigma}_{ij}\]
As \(n\) decreases from 40 to 20, the first (diversifiable) term grows, increasing total portfolio variance.
Q1b. Is there any way Hennessy could reduce the number of issues from 40 to 20 without significantly affecting risk?
Yes — if Hennessy selects the 20 stocks so that they maintain low pairwise correlations with each other and represent diverse sectors and factor exposures, the reduction in diversification can be partially offset. Additionally, the portfolio’s beta (systematic risk) could remain similar to the 40-stock version. The key is to drop stocks that are highly correlated with remaining holdings, so the marginal diversification loss is minimal.
Q2. If the reduction to 20 could be expected to be advantageous, explain why reduction to 10 might be less likely to be advantageous.
There are diminishing marginal returns to diversification. Moving from 1 to 10 stocks reduces unsystematic risk dramatically; moving from 10 to 20 adds less; moving from 20 to 40 adds even less. The relationship follows approximately:
\[\sigma^2_p \approx \bar{\sigma}_{ij} + \frac{\sigma^2 - \bar{\sigma}_{ij}}{n}\]
The diversification benefit of adding each new stock shrinks as \(n\) grows. However, reducing from 20 to 10 crosses a threshold where idiosyncratic risk rises steeply. With only 10 stocks, each issue represents a 10% weight, and event risk (earnings surprises, regulatory actions) for any single holding has a large impact. Therefore, concentrating further from 20 to 10 is more likely to be detrimental to the risk-adjusted return.
Q3. If the broader Wilstead fund perspective is considered (rather than Hennessy’s portfolio in isolation), how might this affect the decision?
From the total fund perspective, Hennessy’s portfolio is one of six equity sub-portfolios. The other five managers collectively hold 150+ stocks worth $250 million. The Hennessy portfolio’s contribution to total fund risk depends on its covariance with the rest of the fund, not just its standalone variance.
Key insight: If Hennessy’s 20–40 stocks have low correlation with the stocks held by other managers, each stock in Hennessy’s portfolio provides meaningful diversification to the total fund, even if Hennessy’s standalone portfolio becomes more concentrated.
This could actually make the committee more willing to allow a reduction to 20 (or even 10) stocks in Hennessy’s sleeve, since the total fund remains well-diversified across 160+ issues. The standalone risk of Hennessy’s portfolio matters less when viewed in the context of the entire fund.
Q4. Which portfolio cannot lie on the efficient frontier as described by Markowitz?
| Portfolio | Expected Return (%) | Standard Deviation (%) |
|---|---|---|
| W | 15 | 36 |
| X | 12 | 15 |
| Z | 5 | 7 |
| Y | 9 | 21 |
Answer: Portfolio W
On the efficient frontier, no portfolio can offer a lower return than another portfolio with equal or lower standard deviation. Portfolio X offers a higher return (12%) with lower risk (15%) than Portfolio Y (9% return, 21% risk). But more importantly, Portfolio W offers 15% return with 36% standard deviation. We can check the reward-to-variability (Sharpe) ratios (assuming \(r_f \approx 0\)):
Portfolio W has the worst risk-return trade-off of all four portfolios and lies below the frontier — given that Portfolio X dominates it in Sharpe ratio terms. An investor could achieve a higher expected return at 36% SD by levering Portfolio X or combining X with Z.
Q10. Statistics for three stocks A, B, C:
| Stock | Std Dev (%) | Corr(A,·) | Corr(B,·) | Corr(C,·) |
|---|---|---|---|---|
| A | 40 | 1.00 | 0.90 | 0.50 |
| B | 20 | 0.90 | 1.00 | 0.10 |
| C | 40 | 0.50 | 0.10 | 1.00 |
Which portfolio is preferable: equal-weight A & B, or equal-weight B & C?
Compute the variance of each equal-weight portfolio (\(w_1 = w_2 = 0.5\)):
\[\sigma^2_p = 0.25\sigma_1^2 + 0.25\sigma_2^2 + 2(0.25)\rho_{12}\sigma_1\sigma_2\]
Portfolio A & B:
\[\sigma^2_{AB} = 0.25(40^2) + 0.25(20^2) + 0.5(0.90)(40)(20)\] \[= 0.25(1600) + 0.25(400) + 0.5(720) = 400 + 100 + 360 = 860\] \[\sigma_{AB} = \sqrt{860} \approx 29.3\%\]
Portfolio B & C:
\[\sigma^2_{BC} = 0.25(20^2) + 0.25(40^2) + 0.5(0.10)(20)(40)\] \[= 0.25(400) + 0.25(1600) + 0.5(80) = 100 + 400 + 40 = 540\] \[\sigma_{BC} = \sqrt{540} \approx 23.2\%\]
Recommendation: Portfolio B & C is preferable. Since expected returns are not provided and we can only compare risk, the portfolio with lower variance (B & C, \(\sigma \approx 23.2\%\)) dominates (same information set, lower risk). The near-zero correlation between B and C (\(\rho = 0.10\)) delivers far superior diversification than the near-perfect correlation between A and B (\(\rho = 0.90\)).
Q1. OLS regression of stock market index excess returns on ABC and XYZ:
| Statistic | ABC | XYZ |
|---|---|---|
| Alpha | −3.20% | 7.3% |
| Beta | 0.60 | 0.97 |
| \(R^2\) | 0.35 | 0.17 |
| Residual standard deviation | 13.02% | 21.45% |
Interpretation:
ABC has a negative alpha (−3.20%), indicating it underperformed its CAPM-predicted return over the sample period. Its beta of 0.60 suggests below-market systematic risk. The relatively high \(R^2 = 0.35\) means 35% of ABC’s return variance is explained by market movements.
XYZ has a positive alpha (7.3%), suggesting outperformance vs. CAPM. However, its \(R^2\) of only 0.17 means the market explains very little of XYZ’s return variation — most risk is idiosyncratic (residual std dev of 21.45% is large).
Implications for future risk-return in a diversified portfolio:
Since both stocks are in a diversified portfolio, only systematic risk (beta) is priced. Unsystematic risk washes out through diversification:
Future expected returns in a CAPM framework should be based on systematic beta alone, not historical alpha. XYZ’s past alpha may not persist.
Q2. Correlation between Baker Fund and market index = 0.70. What percentage of Baker Fund’s total risk is nonsystematic?
\[R^2 = \rho^2 = (0.70)^2 = 0.49\]
Q3. Correlation of Charlottesville International Fund with world index = 1.0. Expected market return = 11%, fund return = 9%, risk-free rate = 3%. What is the implied beta?
When \(\rho = 1.0\), the fund moves perfectly with the market. Using CAPM:
\[E(R_i) = r_f + \beta_i[E(R_m) - r_f]\]
\[9\% = 3\% + \beta(11\% - 3\%)\]
\[6\% = 8\% \times \beta\]
\[\beta = \frac{6}{8} = \mathbf{0.75}\]
The implied beta of Charlottesville International is 0.75.
Q4. Beta is most closely associated with:
Answer: (d) Systematic risk.
Beta measures a security’s sensitivity to systematic (market-wide) risk — the non-diversifiable component of total risk captured by the covariance of a security’s return with the market return, scaled by market variance: \(\beta_i = \text{Cov}(R_i, R_m)/\sigma^2_m\).
Q5. Beta and standard deviation differ as risk measures in that beta measures:
Answer: (b) Only systematic risk, while standard deviation measures total risk.
In a well-diversified portfolio, unsystematic risk is eliminated, so beta is the relevant risk measure for pricing; standard deviation is relevant for undiversified or standalone portfolios.
Reference data:
| Portfolio | Avg Annual Return | Std Dev | Beta |
|---|---|---|---|
| R | 11% | 10% | 0.5 |
| S&P 500 | 14% | 12% | 1.0 |
Q8. When plotting portfolio R relative to the SML, portfolio R lies:
The SML gives the expected return for any beta:
\[E(R) = r_f + \beta[E(R_m) - r_f]\]
We need to infer \(r_f\). Using the S&P 500 as the market proxy and assuming the SML passes through it:
\[14\% = r_f + 1.0 \times (14\% - r_f) \quad \checkmark \text{ (always true)}\]
We need additional info — but we can check Portfolio R using a plausible \(r_f\). Assume \(r_f\) can be inferred from another condition. Under CAPM: the SML-predicted return for \(\beta = 0.5\) is:
\[E(R_R)_{\text{SML}} = r_f + 0.5(14\% - r_f) = 0.5 r_f + 7\%\]
For R to be on the SML: \(11\% = 0.5 r_f + 7\%\), implying \(r_f = 8\%\).
Since an 8% risk-free rate is plausible, Portfolio R plots on the SML.
Answer: (a) On the SML.
Q9. When plotting portfolio R relative to the CML, portfolio R lies:
The CML uses standard deviation (total risk) as the x-axis and only applies to efficient portfolios on the mean-variance frontier. Portfolio R has: - Return = 11%, Std Dev = 10%, Beta = 0.5
The CML equation (with \(r_f = 8\%\) inferred above):
\[E(R_{\text{CML}}) = r_f + \frac{E(R_m) - r_f}{\sigma_m} \times \sigma = 8\% + \frac{14\% - 8\%}{12\%} \times 10\% = 8\% + 5\% = 13\%\]
The CML predicts 13% for a portfolio with \(\sigma = 10\%\), but Portfolio R only earns 11%.
Answer: (b) Below the CML.
Portfolio R is not efficient — it has unsystematic risk that the CML does not reward.
Q10. Should investors expect a higher return on Portfolio A than Portfolio B according to CAPM?
| Measure | Portfolio A | Portfolio B |
|---|---|---|
| Systematic risk (beta) | 1.0 | 1.0 |
| Specific risk per individual security | High | Low |
Answer: No. Under CAPM, only systematic risk (beta) is priced. Since both portfolios have identical betas of 1.0, the CAPM predicts identical expected returns for both, regardless of their specific (idiosyncratic) risk levels.
The high specific risk in Portfolio A is diversifiable and therefore commands no additional risk premium. Investors who hold a well-diversified portfolio are not compensated for bearing avoidable idiosyncratic risk.
Context: Orb Trust uses CAPM. Analyst Kevin McCracken investigates APT. Two-factor model: - Factor 1: real GDP sensitivity, risk premium = 8% - Factor 2: inflation sensitivity, risk premium = 2%
High Growth Fund: sensitivities = 1.25 (GDP), 1.5 (inflation) Large Cap Fund: expected return = 8.5% above risk-free Utility Fund: sensitivities = 1.0 (GDP), 2.0 (inflation)
Q13. If risk-free rate = 4%, what is McCracken’s APT estimate of Orb’s High Growth Fund expected return?
\[E(R_{\text{HGF}}) = r_f + \beta_{\text{GDP}} \times RP_{\text{GDP}} + \beta_{\text{inf}} \times RP_{\text{inf}}\]
\[= 4\% + 1.25 \times 8\% + 1.5 \times 2\% = 4\% + 10\% + 3\% = \mathbf{17\%}\]
Q14. With respect to McCracken’s APT estimate of the Large Cap Fund and Kwon’s fundamental estimate (8.5% above risk-free), is an arbitrage opportunity available?
APT-predicted return for Large Cap Fund:
\[E(R_{\text{LCF}}) = 4\% + 0.75 \times 8\% + 1.25 \times 2\% = 4\% + 6\% + 2.5\% = 12.5\%\]
Kwon’s fundamental estimate: \(r_f + 8.5\% = 4\% + 8.5\% = 12.5\%\)
The APT estimate (12.5%) exactly equals Kwon’s fundamental estimate (12.5%).
Answer: No arbitrage opportunity is available. Since both models agree on the expected return, the fund is correctly priced and there is no mispricing to exploit.
Q15. If the GDP Fund is constructed from the three funds, what is its weight in the Utility Fund?
We need a portfolio of {High Growth (H), Large Cap (L), Utility (U)} such that: - GDP sensitivity = 1.0 - Inflation sensitivity = 0.0
Let weights be \(w_H\), \(w_L\), \(w_U\) with \(w_H + w_L + w_U = 1\).
System of equations:
\[\text{GDP: } 1.25w_H + 0.75w_L + 1.0w_U = 1\] \[\text{Inflation: } 1.5w_H + 1.25w_L + 2.0w_U = 0\] \[\text{Weights: } w_H + w_L + w_U = 1\]
From the inflation constraint: \(1.5w_H + 1.25w_L + 2.0w_U = 0\)
Solving the system (using substitution):
From eq.3: \(w_L = 1 - w_H - w_U\)
Substituting into inflation eq.: \[1.5w_H + 1.25(1 - w_H - w_U) + 2.0w_U = 0\] \[1.5w_H + 1.25 - 1.25w_H - 1.25w_U + 2.0w_U = 0\] \[0.25w_H + 0.75w_U = -1.25 \quad (*)\]
Substituting into GDP eq.: \[1.25w_H + 0.75(1 - w_H - w_U) + w_U = 1\] \[1.25w_H + 0.75 - 0.75w_H - 0.75w_U + w_U = 1\] \[0.5w_H + 0.25w_U = 0.25 \quad (**)\]
From (**): \(w_H = (0.25 - 0.25w_U)/0.5 = 0.5 - 0.5w_U\)
Substitute into (*): \[0.25(0.5 - 0.5w_U) + 0.75w_U = -1.25\] \[0.125 - 0.125w_U + 0.75w_U = -1.25\] \[0.625w_U = -1.375\] \[w_U = -2.2\]
Answer: (a) −2.2
Q16. With respect to Stiles and McCracken’s comments on the GDP Fund:
Answer: (b) Both are correct.
Both perspectives have merit: a fund with unit GDP sensitivity and zero inflation sensitivity benefits when real GDP grows (supports McCracken’s view) and provides predictable, inflation-neutral income streams (supports Stiles’ view for retirees). Neither statement is incorrect given the fund’s factor structure.
library(tidyquant)
library(lubridate)
library(timetk)
library(purrr)
library(tibble)
library(dplyr)
library(tidyr)
library(ggplot2)
library(quadprog)
library(scales)Download daily adjusted closing prices for 8 ETFs from Yahoo Finance from 2010 to current date.
tickers <- c("SPY", "QQQ", "EEM", "IWM", "EFA", "TLT", "IYR", "GLD")
etf_raw <- tq_get(
tickers,
from = "2010-01-01",
to = Sys.Date(),
get = "stock.prices"
)
# Extract adjusted closing prices in wide format (xts)
prices_wide <- etf_raw %>%
select(symbol, date, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date)
prices_xts <- prices_wide %>%
column_to_rownames("date") %>%
as.xts()
# Display first and last 6 rows
head(prices_xts)## SPY QQQ EEM IWM EFA TLT IYR
## 2010-01-04 84.79637 40.29079 30.35151 51.36655 35.12844 55.70950 26.76810
## 2010-01-05 85.02087 40.29079 30.57181 51.18994 35.15940 56.06931 26.83239
## 2010-01-06 85.08072 40.04778 30.63576 51.14178 35.30801 55.31871 26.82069
## 2010-01-07 85.43984 40.07381 30.45811 51.51911 35.17179 55.41175 27.06027
## 2010-01-08 85.72417 40.40362 30.69972 51.80010 35.45044 55.38696 26.87913
## 2010-01-11 85.84389 40.23871 30.63576 51.59137 35.74147 55.08299 27.00768
## 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
# Work from the long-format raw data (etf_raw) — avoids xts multi-column issues
# Weekly returns
weekly_returns_tbl <- etf_raw %>%
select(symbol, date, adjusted) %>%
group_by(symbol) %>%
tq_transmute(select = adjusted,
mutate_fun = periodReturn,
period = "weekly",
type = "arithmetic",
col_rename = "return") %>%
ungroup()
# Monthly returns (long format)
monthly_returns_tbl <- etf_raw %>%
select(symbol, date, adjusted) %>%
group_by(symbol) %>%
tq_transmute(select = adjusted,
mutate_fun = periodReturn,
period = "monthly",
type = "arithmetic",
col_rename = "return") %>%
ungroup()
# Wide-format xts for downstream use
weekly_returns <- weekly_returns_tbl %>%
pivot_wider(names_from = symbol, values_from = return) %>%
arrange(date) %>%
column_to_rownames("date") %>%
as.xts()
monthly_returns <- monthly_returns_tbl %>%
pivot_wider(names_from = symbol, values_from = return) %>%
arrange(date) %>%
column_to_rownames("date") %>%
as.xts()
cat("Weekly returns — dimensions:", dim(weekly_returns), "\n")## Weekly returns — dimensions: 858 8
## Monthly returns — dimensions: 198 8
## SPY QQQ EEM IWM EFA
## 2010-01-29 -0.05241321 -0.07819900 -0.103722696 -0.06048711 -0.074916364
## 2010-02-26 0.03119450 0.04603858 0.017763843 0.04475104 0.002667621
## 2010-03-31 0.06087994 0.07710958 0.081108986 0.08230692 0.063854211
## 2010-04-30 0.01547015 0.02242499 -0.001661939 0.05678469 -0.028045888
## 2010-05-28 -0.07945480 -0.07392356 -0.093936119 -0.07536635 -0.111927689
## 2010-06-30 -0.05174092 -0.05975677 -0.013986219 -0.07743428 -0.020619795
## TLT IYR GLD
## 2010-01-29 0.027836240 -0.05195353 -0.034972713
## 2010-02-26 -0.003423619 0.05457063 0.032748219
## 2010-03-31 -0.020573379 0.09748477 -0.004386396
## 2010-04-30 0.033217571 0.06388069 0.058834363
## 2010-05-28 0.051084353 -0.05683493 0.030513147
## 2010-06-30 0.057977943 -0.04670186 0.023553189
monthly_tbl <- monthly_returns %>%
tk_tbl(rename_index = "date") %>%
mutate(date = as.yearmon(date)) # keep as year-month for easy merging
head(monthly_tbl)ff3_url <- "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_CSV.zip"
temp <- tempfile(fileext = ".zip")
download.file(ff3_url, temp, quiet = TRUE, mode = "wb")
# Inspect actual entry name inside the zip
zip_contents <- unzip(temp, list = TRUE)
print(zip_contents) # confirms the CSV filename## Name Length Date
## 1 F-F_Research_Data_Factors.csv 52333 2026-05-29 13:02:00
csv_name <- zip_contents$Name[1]
# Extract to a temp directory and read
temp_dir <- tempdir()
unzip(temp, files = csv_name, exdir = temp_dir)
csv_path <- file.path(temp_dir, csv_name)
# Find where the data starts — skip header lines before YYYYMM rows
raw_lines <- readLines(csv_path)
# Monthly data rows start with a 6-digit YYYYMM pattern
data_start <- min(which(grepl("^\\s*[12][0-9]{5},", raw_lines)))
ff3_csv <- read.csv(csv_path, skip = data_start - 1,
header = FALSE, stringsAsFactors = FALSE,
col.names = c("date", "Mkt.RF", "SMB", "HML", "RF"))
unlink(temp)
# Keep only monthly rows (6-digit YYYYMM); annual rows are 4-digit YYYY
ff3_monthly <- ff3_csv %>%
filter(grepl("^\\s*[12][0-9]{5}\\s*$", date)) %>%
mutate(
date = as.yearmon(as.Date(paste0(trimws(date), "01"), "%Y%m%d")),
Mkt.RF = as.numeric(Mkt.RF) / 100,
SMB = as.numeric(SMB) / 100,
HML = as.numeric(HML) / 100,
RF = as.numeric(RF) / 100
) %>%
filter(!is.na(Mkt.RF), date >= as.yearmon("Jan 2010"))
head(ff3_monthly)merged_tbl <- monthly_tbl %>%
inner_join(ff3_monthly, by = "date") %>%
arrange(date)
cat("Merged data dimensions:", dim(merged_tbl), "\n")## Merged data dimensions: 196 13
Using 60-month window (2010/02 – 2015/01) to estimate the CAPM-based covariance matrix, then compute optimal GMV weights.
# ── Helper: compute GMV weights from a covariance matrix ──────────────────────
gmv_weights <- function(cov_mat) {
n <- ncol(cov_mat)
Dmat <- 2 * cov_mat
dvec <- rep(0, n)
# Constraints: sum(w) = 1 (equality), w >= 0 (no short-selling)
Amat <- cbind(rep(1, n), diag(n))
bvec <- c(1, rep(0, n))
sol <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
sol$solution
}
assets <- tickers
# ── Training window: Feb 2010 – Jan 2015 ──────────────────────────────────────
train_capm <- merged_tbl %>%
filter(date >= as.yearmon("Feb 2010") & date <= as.yearmon("Jan 2015"))
ret_mat_capm <- train_capm %>% select(all_of(assets)) %>% as.matrix()
excess_mat <- ret_mat_capm - train_capm$RF # excess returns
# CAPM: regress each asset on Mkt.RF → fitted values = systematic component
mkt_rf <- train_capm$Mkt.RF
fitted_capm <- sapply(assets, function(a) {
fit <- lm(excess_mat[, a] ~ mkt_rf)
fitted(fit)
})
# Residuals
resid_capm <- excess_mat - fitted_capm
# Covariance matrix: systematic + diagonal residual
beta_capm <- sapply(assets, function(a) coef(lm(excess_mat[, a] ~ mkt_rf))[2])
sigma2_mkt <- var(mkt_rf)
cov_sys <- outer(beta_capm, beta_capm) * sigma2_mkt
cov_resid <- diag(apply(resid_capm, 2, var))
cov_capm <- cov_sys + cov_resid
rownames(cov_capm) <- colnames(cov_capm) <- assets
cat("CAPM Covariance Matrix (2010/02 – 2015/01):\n")## CAPM Covariance Matrix (2010/02 – 2015/01):
## SPY QQQ EEM IWM EFA TLT IYR GLD
## SPY 13.97 14.59 17.26 18.28 15.81 -10.11 11.79 2.35
## QQQ 14.59 18.16 18.15 19.23 16.62 -10.64 12.40 2.47
## EEM 17.26 18.15 33.50 22.74 19.66 -12.58 14.66 2.92
## IWM 18.28 19.23 22.74 26.99 20.83 -13.33 15.54 3.10
## EFA 15.81 16.62 19.66 20.83 24.13 -11.52 13.43 2.68
## TLT -10.11 -10.64 -12.58 -13.33 -11.52 16.21 -8.59 -1.71
## IYR 11.79 12.40 14.66 15.54 13.43 -8.59 20.25 2.00
## GLD 2.35 2.47 2.92 3.10 2.68 -1.71 2.00 28.99
# ── Optimal GMV weights ────────────────────────────────────────────────────────
w_capm <- gmv_weights(cov_capm)
names(w_capm) <- assets
cat("\nGMV Optimal Weights (CAPM) on 2015/01:\n")##
## GMV Optimal Weights (CAPM) on 2015/01:
## SPY QQQ EEM IWM EFA TLT IYR GLD
## 0.4471 0.0000 0.0000 0.0000 0.0000 0.4483 0.0373 0.0673
# ── Realized portfolio return on 2015/02 ──────────────────────────────────────
ret_2015Feb <- merged_tbl %>%
filter(date == as.yearmon("Feb 2015")) %>%
select(all_of(assets)) %>%
as.numeric()
realized_capm <- sum(w_capm * ret_2015Feb)
cat(sprintf("\nRealized portfolio return (CAPM GMV) on 2015/02: %.4f%%\n",
realized_capm * 100))##
## Realized portfolio return (CAPM GMV) on 2015/02: -0.7330%
# ── Training window: Feb 2010 – Jan 2015 ──────────────────────────────────────
train_ff3 <- merged_tbl %>%
filter(date >= as.yearmon("Feb 2010") & date <= as.yearmon("Jan 2015"))
excess_ff3 <- (train_ff3 %>% select(all_of(assets)) %>% as.matrix()) - train_ff3$RF
factors_ff3 <- train_ff3 %>% select(Mkt.RF, SMB, HML) %>% as.matrix()
# FF3: regress each asset excess return on three factors
fitted_ff3 <- sapply(assets, function(a) {
fit <- lm(excess_ff3[, a] ~ factors_ff3)
fitted(fit)
})
resid_ff3 <- excess_ff3 - fitted_ff3
# Systematic covariance: B * Cov(F) * B'
B <- sapply(assets, function(a) {
coef(lm(excess_ff3[, a] ~ factors_ff3))[-1] # 3 factor loadings
}) # 3 × 8 matrix
cov_factors <- cov(factors_ff3)
cov_sys_ff3 <- t(B) %*% cov_factors %*% B
cov_resid_ff3 <- diag(apply(resid_ff3, 2, var))
cov_ff3 <- cov_sys_ff3 + cov_resid_ff3
rownames(cov_ff3) <- colnames(cov_ff3) <- assets
cat("FF3 Covariance Matrix (2010/02 – 2015/01):\n")## FF3 Covariance Matrix (2010/02 – 2015/01):
## SPY QQQ EEM IWM EFA TLT IYR GLD
## SPY 13.97 14.64 17.25 17.87 16.01 -10.08 11.79 2.04
## QQQ 14.64 18.16 18.44 18.70 17.13 -9.95 12.48 3.37
## EEM 17.25 18.44 33.50 22.70 19.80 -12.28 14.70 3.50
## IWM 17.87 18.70 22.70 26.99 19.43 -13.79 15.52 4.69
## EFA 16.01 17.13 19.80 19.43 24.13 -11.04 13.47 2.37
## TLT -10.08 -9.95 -12.28 -13.79 -11.04 16.21 -8.51 -0.72
## IYR 11.79 12.48 14.70 15.52 13.47 -8.51 20.25 2.16
## GLD 2.04 3.37 3.50 4.69 2.37 -0.72 2.16 28.99
# ── Optimal GMV weights ────────────────────────────────────────────────────────
w_ff3 <- gmv_weights(cov_ff3)
names(w_ff3) <- assets
cat("\nGMV Optimal Weights (FF3) on 2015/01:\n")##
## GMV Optimal Weights (FF3) on 2015/01:
## SPY QQQ EEM IWM EFA TLT IYR GLD
## 0.4579 0.0000 0.0000 0.0000 0.0000 0.4507 0.0334 0.0581
# ── Realized portfolio return on 2015/02 ──────────────────────────────────────
realized_ff3 <- sum(w_ff3 * ret_2015Feb)
cat(sprintf("\nRealized portfolio return (FF3 GMV) on 2015/02: %.4f%%\n",
realized_ff3 * 100))##
## Realized portfolio return (FF3 GMV) on 2015/02: -0.6224%
Using a rolling 60-month window to re-estimate covariance matrices and compute GMV weights monthly.
# ── Date grid ─────────────────────────────────────────────────────────────────
all_dates <- merged_tbl$date
# First formation date: Jan 2015 (using Feb 2010 – Jan 2015 as training)
# First investment date: Feb 2015
invest_dates <- all_dates[all_dates >= as.yearmon("Feb 2015") &
all_dates <= as.yearmon("May 2026")]
backtest_results <- map_dfr(invest_dates, function(invest_dt) {
# Training window: 60 months ending one period before investment
idx_invest <- which(all_dates == invest_dt)
if (idx_invest <= 60) return(NULL)
train_idx <- (idx_invest - 60):(idx_invest - 1)
train <- merged_tbl[train_idx, ]
ret_m <- train %>% select(all_of(assets)) %>% as.matrix()
exc_m <- ret_m - train$RF
mkt_v <- train$Mkt.RF
fac_v <- train %>% select(Mkt.RF, SMB, HML) %>% as.matrix()
# ── CAPM cov ──
b_capm <- sapply(assets, function(a) coef(lm(exc_m[,a] ~ mkt_v))[2])
resid_c <- exc_m - outer(mkt_v, b_capm)
cov_c <- outer(b_capm, b_capm) * var(mkt_v) + diag(apply(resid_c, 2, var))
# ── FF3 cov ──
B_ff3 <- sapply(assets, function(a) coef(lm(exc_m[,a] ~ fac_v))[-1])
resid_f <- exc_m - fac_v %*% B_ff3
cov_f <- t(B_ff3) %*% cov(fac_v) %*% B_ff3 + diag(apply(resid_f, 2, var))
# ── GMV weights ──
wc <- tryCatch(gmv_weights(cov_c), error = function(e) rep(1/8, 8))
wf <- tryCatch(gmv_weights(cov_f), error = function(e) rep(1/8, 8))
# ── Realized returns ──
ret_next <- merged_tbl %>% filter(date == invest_dt) %>%
select(all_of(assets)) %>% as.numeric()
tibble(
date = invest_dt,
ret_capm_gmv = sum(wc * ret_next),
ret_ff3_gmv = sum(wf * ret_next)
)
})
head(backtest_results)# ── Cumulative returns ─────────────────────────────────────────────────────────
cum_results <- backtest_results %>%
mutate(
cum_capm = cumprod(1 + ret_capm_gmv),
cum_ff3 = cumprod(1 + ret_ff3_gmv),
date_real = as.Date(date)
)
# ── Plot ──────────────────────────────────────────────────────────────────────
ggplot(cum_results, aes(x = date_real)) +
geom_line(aes(y = cum_capm, colour = "CAPM GMV"), linewidth = 1.1) +
geom_line(aes(y = cum_ff3, colour = "FF3 GMV"), linewidth = 1.1, linetype = "dashed") +
scale_colour_manual(
values = c("CAPM GMV" = "#2c7bb6", "FF3 GMV" = "#d7191c")
) +
scale_y_continuous(labels = scales::dollar_format(prefix = "$")) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(
title = "Cumulative Returns: GMV Portfolios (2015/02 – 2026/05)",
subtitle = "Rolling 60-month window | CAPM vs. Fama-French 3-Factor covariance estimation",
x = NULL,
y = "Cumulative Value (\\$1 invested)",
colour = "Model",
caption = "Note: GMV = Global Minimum Variance; no-short-selling constraint applied."
) +
theme_minimal(base_size = 13) +
theme(
plot.title = element_text(face = "bold"),
legend.position = "bottom",
panel.grid.minor = element_blank(),
axis.text.x = element_text(angle = 45, hjust = 1)
)# ── Summary statistics ─────────────────────────────────────────────────────────
perf_summary <- backtest_results %>%
summarise(
`CAPM GMV — Ann. Return` = mean(ret_capm_gmv) * 12,
`CAPM GMV — Ann. Std Dev` = sd(ret_capm_gmv) * sqrt(12),
`CAPM GMV — Sharpe` = (mean(ret_capm_gmv) * 12) / (sd(ret_capm_gmv) * sqrt(12)),
`FF3 GMV — Ann. Return` = mean(ret_ff3_gmv) * 12,
`FF3 GMV — Ann. Std Dev` = sd(ret_ff3_gmv) * sqrt(12),
`FF3 GMV — Sharpe` = (mean(ret_ff3_gmv) * 12) / (sd(ret_ff3_gmv) * sqrt(12))
) %>%
pivot_longer(everything(), names_to = "Metric", values_to = "Value") %>%
mutate(Value = round(Value, 4))
knitr::kable(perf_summary,
caption = "Performance Summary: CAPM vs. FF3 GMV Portfolios (2015/02 – 2026/05)",
align = c("l", "r"))| Metric | Value |
|---|---|
| CAPM GMV — Ann. Return | 0.0806 |
| CAPM GMV — Ann. Std Dev | 0.1049 |
| CAPM GMV — Sharpe | 0.7684 |
| FF3 GMV — Ann. Return | 0.0796 |
| FF3 GMV — Ann. Std Dev | 0.1057 |
| FF3 GMV — Sharpe | 0.7529 |
End of Final Exam — Investment Portfolio Analysis