a. Will the limitation to 20 stocks likely increase or decrease the risk of the portfolio? Explain.
The limitation to 20 stocks will increase portfolio risk. Reducing the number of holdings from 40 to 20 reduces diversification. With fewer stocks, the portfolio retains more unsystematic (firm-specific) risk that could otherwise be diversified away. The variance reduction from diversification diminishes as the number of stocks increases, but moving from 40 down to 20 still meaningfully increases exposure to idiosyncratic risk.
b. Is there any way Hennessy could reduce the number of issues from 40 to 20 without significantly affecting risk?
Yes. Hennessy could select the 20 stocks such that their pairwise correlations are as low as possible. If Hennessy concentrates the portfolio in the 10 stocks per year that register particularly large gains — and those stocks are drawn from different industries with low inter-correlations — the remaining 20 stocks could retain risk properties close to the 40-stock portfolio. Alternatively, Hennessy could replace the dropped stocks with index funds or ETFs covering those sectors so that systematic exposure is maintained while reducing the number of individual lines.
One committee member suggested reducing holdings to 10. If reducing from 40 to 20 is expected to be advantageous (higher concentration in Hennessy’s best picks), why might reducing further to 10 be less advantageous?
Answer: The benefit of concentration comes from focusing on Hennessy’s highest-conviction picks. Hennessy identifies approximately 10 outstanding stocks per year. Concentrating to 20 still allows double-weighting each of those 10 winners while retaining some diversification buffer. However, reducing to only 10 stocks eliminates that diversification buffer entirely. Any one stock experiencing an unexpected negative event would have a catastrophic impact on the portfolio (each position is ~10% of the fund). The incremental gain in concentration is outweighed by the much larger jump in unsystematic risk. In other words, at 10 stocks, idiosyncratic variance dominates, and even Hennessy’s skill cannot reliably overcome that noise.
Another committee member suggests evaluating Hennessy’s portfolio in the context of the total fund (not independently). How does this broader view affect the decision to limit holdings to 10 or 20 issues?
Answer: When viewed as one component of a larger multi-manager fund ($250 million across six other managers holding 150+ stocks), the Hennessy portfolio is already highly diversified at the total-fund level. From the perspective of the total fund:
This broader view makes a stronger case for allowing Hennessy to concentrate further (even to 10 stocks), since the skill-based alpha generation is the objective, and the fund-level diversification is already achieved by the other managers.
Which one of the following portfolios 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 cannot lie on the efficient frontier.
On the Markowitz efficient frontier, no portfolio with a higher standard deviation should offer a lower expected return relative to another feasible portfolio. Comparing W and X: Portfolio X achieves a 12% return with only 15% standard deviation, while Portfolio W offers 15% return with 36% standard deviation. More importantly, for W to be efficient, there should be no other portfolio with the same or higher return and lower risk. Given that X already achieves 12% at σ=15%, we can construct a portfolio combining X and a risk-free asset or other assets to dominate W.
The key: Portfolio W (15%, 36%) is dominated in risk-adjusted terms. The reward-to-variability ratio of W = 15/36 = 0.417, while X = 12/15 = 0.800. Any rational investor would prefer X or a leveraged position in X over W. Thus W cannot lie on the efficient frontier.
(Answer: a. W)
Statistics for stocks A, B, and C:
| Stock | Std Dev (%) | Corr with A | Corr with B | Corr with 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 would you recommend: equal amounts of A & B, or equal amounts of B & C?
Calculate the variance of each equal-weight (50/50) portfolio:
Portfolio A+B variance:
\[\sigma^2_{AB} = (0.5)^2(40)^2 + (0.5)^2(20)^2 + 2(0.5)(0.5)(0.90)(40)(20)\] \[= 0.25(1600) + 0.25(400) + 2(0.25)(0.90)(800)\] \[= 400 + 100 + 360 = 860\] \[\sigma_{AB} = \sqrt{860} \approx 29.3\%\]
Portfolio B+C variance:
\[\sigma^2_{BC} = (0.5)^2(20)^2 + (0.5)^2(40)^2 + 2(0.5)(0.5)(0.10)(20)(40)\] \[= 0.25(400) + 0.25(1600) + 2(0.25)(0.10)(800)\] \[= 100 + 400 + 40 = 540\] \[\sigma_{BC} = \sqrt{540} \approx 23.2\%\]
Recommendation: Portfolio B+C.
Since expected returns are not given, we can only compare on risk. Portfolio B+C has significantly lower variance (540 vs 860) due to the very low correlation between B and C (0.10 vs 0.90 for A and B). The diversification benefit from combining B and C is far superior. Under mean-variance analysis with no return information, B+C dominates A+B on risk grounds and should be preferred.
Regression results for ABC and XYZ stocks:
| Statistic | ABC | XYZ |
|---|---|---|
| Alpha | −3.20% | 7.3% |
| Beta | 0.60 | 0.97 |
| R² | 0.35 | 0.17 |
| Residual std deviation | 13.02% | 21.45% |
Interpretation and implications:
ABC: Alpha = −3.20% indicates that over the sample period, ABC underperformed its CAPM-predicted return by 3.2% per year. Beta = 0.60 means ABC has below-market systematic risk. R² = 0.35 means 35% of ABC’s return variance is explained by market movements; the remaining 65% is firm-specific (unsystematic) risk. The high residual standard deviation (13.02%) confirms significant idiosyncratic risk.
XYZ: Alpha = +7.3% suggests XYZ outperformed its CAPM benchmark by 7.3% annually over the sample period. Beta ≈ 1.0 means near-market-level systematic risk. However, R² = 0.17 is very low — only 17% of XYZ’s variance is explained by the market. The residual standard deviation of 21.45% is very large, indicating XYZ has enormous firm-specific risk.
Future implications for a diversified portfolio: When both stocks are included in a well-diversified common stock portfolio, their unsystematic risk is diversified away. Only beta (systematic risk) is relevant for pricing. Therefore:
However, the past alphas are not reliable predictors of future performance. The betas from the two brokerage houses differ considerably (ABC: 0.62 vs 0.71; XYZ: 1.45 vs 1.25), indicating estimation instability. The true betas for XYZ may be significantly higher than 0.97 from the 5-year regression, suggesting XYZ carries more systematic risk than initially estimated. Investors should use current beta estimates with caution.
Correlation of Baker Fund with market index = 0.70. What percentage of Baker Fund’s total risk is nonsystematic (specific)?
\[R^2 = \rho^2 = (0.70)^2 = 0.49\]
R² = 0.49 means 49% of total variance is systematic (explained by the market).
Therefore, nonsystematic (specific) risk = 1 − 0.49 = 0.51 = 51% of total variance.
Given: - Correlation of Charlottesville International Fund with world market = 1.0 - Expected return on world market index = 11% - Expected return on Charlottesville International = 9% - Risk-free rate = 3%
Find implied beta.
When correlation = 1.0, the fund moves perfectly with the market. Using the CAPM Security Market Line:
\[E(r) = r_f + \beta[E(r_M) - r_f]\] \[9\% = 3\% + \beta(11\% - 3\%)\] \[6\% = \beta \times 8\%\] \[\beta = \frac{6\%}{8\%} = 0.75\]
The implied beta of Charlottesville International is 0.75.
The concept of beta is most closely associated with:
Answer: d. Systematic risk.
Beta measures the sensitivity of a security’s return to the return of the overall market (systematic/market risk). It does not measure total risk, unsystematic risk, or correlation coefficients directly.
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 diversified portfolio context, only systematic risk (beta) is compensated by the market; unsystematic risk is eliminated through diversification.
Reference data for CFA Problems 8 and 9:
| Portfolio | Avg Annual Return | Std Deviation | Beta |
|---|---|---|---|
| R | 11% | 10% | 0.5 |
| S&P 500 | 14% | 12% | 1.0 |
(Assume risk-free rate can be inferred from the SML.)
When plotting portfolio R relative to the SML, portfolio R lies:
Using the S&P 500 as the market: the SML gives E(r) for any beta. We need to find the risk-free rate. Using the market: 14% = rf + 1.0 × (14% − rf), which is always satisfied. We need another anchor. Assume rf is implied.
Using the SML: E(r_R) = rf + 0.5 × (14% − rf) = rf + 7% − 0.5rf = 0.5rf + 7%
Portfolio R’s actual return = 11%.
For R to be on the SML: 11% = 0.5rf + 7% → rf = 8%
If rf < 8%, then SML predicts E(r_R) > 11%, meaning R lies below the SML. If rf > 8%, R lies above the SML.
Typical assumption: rf is around 6% (common in these problems). With rf = 6%: SML prediction for R = 6% + 0.5(14% − 6%) = 6% + 4% = 10%. Actual return = 11% > 10%.
Answer: c. Above the SML. (Portfolio R earned more than predicted by CAPM given its beta of 0.5, indicating positive alpha.)
When plotting portfolio R relative to the Capital Market Line (CML), portfolio R lies:
The CML plots expected return vs. total risk (standard deviation) for efficient portfolios. Portfolio R has σ = 10% vs. S&P 500’s σ = 12%.
CML equation: E(r) = rf + [(14% − rf)/12%] × σ
With rf = 6%: CML at σ=10%: E(r) = 6% + (8%/12%) × 10% = 6% + 6.67% = 12.67%
Portfolio R’s actual return = 11% < 12.67%.
Answer: b. Below the CML.
Portfolio R is not mean-variance efficient (it does not lie on the CML), earning less than an efficient portfolio with the same total risk. This is consistent with R having a low beta (0.5) and therefore low total return despite its 10% standard deviation — much of its risk is unsystematic.
Should investors expect a higher return on Portfolio A than Portfolio B according to CAPM?
| Portfolio A | Portfolio B | |
|---|---|---|
| Systematic risk (beta) | 1.0 | 1.0 |
| Specific (unsystematic) risk | High | Low |
Answer: No. According to CAPM, investors should NOT expect a higher return on Portfolio A.
CAPM states that only systematic risk (beta) is priced and compensated in equilibrium. Both portfolios have identical betas of 1.0, so CAPM predicts the same expected return for both.
Unsystematic (specific) risk can be eliminated through diversification and therefore is not compensated by the market. Portfolio A’s higher specific risk represents inefficiency that rational, diversified investors would not be paid extra to bear. Investors holding Portfolio A should diversify to eliminate the excess unsystematic risk rather than expect a return premium for it.
Context: McCracken uses a two-factor APT model where the factors are changes in real GDP (factor risk premium = 8%) and changes in inflation (factor risk premium = 2%). Risk-free rate = 4%.
Orb’s High Growth Fund: sensitivities β_GDP = 1.25,
β_inflation = 1.5
Orb’s Large Cap Fund: Kwon estimates expected return =
8.5% above risk-free rate; sensitivities β_GDP = 0.75, β_inflation =
1.25
Orb’s Utility Fund: sensitivities β_GDP = 1.0,
β_inflation = 2.0
According to APT, if risk-free rate = 4%, what is McCracken’s estimate of the expected return of Orb’s High Growth Fund?
\[E(r) = r_f + \beta_{GDP} \times RP_{GDP} + \beta_{inflation} \times RP_{inflation}\] \[E(r) = 4\% + 1.25 \times 8\% + 1.5 \times 2\%\] \[E(r) = 4\% + 10\% + 3\% = \mathbf{17\%}\]
The APT expected return for Orb’s High Growth Fund is 17%.
Is there an arbitrage opportunity available for the Large Cap Fund?
First, compute APT equilibrium expected return for Large Cap Fund:
\[E(r)_{APT} = 4\% + 0.75 \times 8\% + 1.25 \times 2\% = 4\% + 6\% + 2.5\% = 12.5\%\]
Kwon’s fundamental analysis estimate: rf + 8.5% = 4% + 8.5% = 12.5%
The fundamental analysis return (12.5%) equals the APT model return (12.5%).
No arbitrage opportunity exists. The Large Cap Fund is correctly priced according to the APT model — there is no mispricing to exploit.
If the GDP Fund is constructed from the three funds, which is its weight in the Utility Fund?
The GDP Fund must have: β_GDP = 1, β_inflation = 0.
Let w_H = weight in High Growth, w_L = weight in Large Cap, w_U = weight in Utility Fund. Weights sum to 1: w_H + w_L + w_U = 1.
GDP sensitivity equation: \[1.25 w_H + 0.75 w_L + 1.0 w_U = 1\]
Inflation sensitivity equation: \[1.5 w_H + 1.25 w_L + 2.0 w_U = 0\]
From the constraint: w_L = 1 − w_H − w_U
Substituting into GDP equation: \[1.25 w_H + 0.75(1 - w_H - w_U) + w_U = 1\] \[1.25 w_H + 0.75 - 0.75 w_H - 0.75 w_U + w_U = 1\] \[0.5 w_H + 0.25 w_U = 0.25 \quad \Rightarrow \quad 2w_H + w_U = 1 \quad \cdots (I)\]
Substituting into inflation equation: \[1.5 w_H + 1.25(1 - w_H - w_U) + 2 w_U = 0\] \[1.5 w_H + 1.25 - 1.25 w_H - 1.25 w_U + 2 w_U = 0\] \[0.25 w_H + 0.75 w_U = -1.25 \quad \Rightarrow \quad w_H + 3 w_U = -5 \quad \cdots (II)\]
From (I): w_H = (1 − w_U)/2. Substitute into (II): \[(1 - w_U)/2 + 3 w_U = -5\] \[1 - w_U + 6 w_U = -10\] \[5 w_U = -11\] \[w_U = -2.2\]
Answer: (a) −2.2. The weight in the Utility Fund is −2.2.
(w_H = (1−(−2.2))/2 = 1.6; w_L = 1 − 1.6 − (−2.2) = 1.6; verify: GDP: 1.25(1.6)+0.75(1.6)+1.0(−2.2) = 2.0+1.2−2.2 = 1.0 ✓; Inflation: 1.5(1.6)+1.25(1.6)+2.0(−2.2) = 2.4+2.0−4.4 = 0 ✓)
With respect to the comments of Stiles and McCracken concerning for whom the GDP Fund would be appropriate:
Answer: b. Both are correct.
The GDP Fund has unit sensitivity to real GDP growth and zero sensitivity to inflation. Stiles is correct that retirees concerned about inflation eroding their income would benefit — the fund is completely hedged against inflation shocks. McCracken is also correct that the fund benefits specifically when real GDP growth is strong (positive unit exposure to GDP), making it attractive if pro-growth policies succeed. Both arguments are valid from different angles.
library(purrr)
tickers <- c("SPY", "QQQ", "EEM", "IWM", "EFA", "TLT", "IYR", "GLD")
# Download daily adjusted prices from 2010-01-01 to today
prices_raw <- tq_get(tickers,
from = "2010-01-01",
to = Sys.Date(),
get = "stock.prices")
# Extract adjusted closing prices in wide xts format
prices_xts <- prices_raw %>%
select(date, symbol, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date) %>%
tk_xts(date_var = date)
head(prices_xts)## SPY QQQ EEM IWM EFA TLT IYR
## 2010-01-04 84.79637 40.29078 30.35150 51.36656 35.12844 55.70953 26.76812
## 2010-01-05 85.02085 40.29078 30.57181 51.18995 35.15940 56.06931 26.83239
## 2010-01-06 85.08070 40.04777 30.63577 51.14177 35.30801 55.31876 26.82069
## 2010-01-07 85.43983 40.07380 30.45810 51.51909 35.17178 55.41179 27.06028
## 2010-01-08 85.72420 40.40362 30.69973 51.80011 35.45044 55.38697 26.87913
## 2010-01-11 85.84391 40.23871 30.63577 51.59135 35.74146 55.08302 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
# Weekly simple returns
weekly_returns <- prices_xts %>%
apply.weekly(function(x) x[nrow(x),]) %>% # end-of-week prices
CalculateReturns(method = "simple") %>%
na.omit()
# Monthly simple returns
monthly_returns <- prices_xts %>%
apply.monthly(function(x) x[nrow(x),]) %>% # end-of-month prices
CalculateReturns(method = "simple") %>%
na.omit()
cat("Weekly returns (first 3 rows):\n")## Weekly returns (first 3 rows):
## SPY QQQ EEM IWM EFA
## 2010-01-15 -0.008117805 -0.01503715 -0.02893535 -0.01301943 -0.003493553
## 2010-01-22 -0.038982111 -0.03685928 -0.05578098 -0.03062170 -0.055740562
## 2010-01-29 -0.016665700 -0.03102359 -0.03357721 -0.02624321 -0.025802740
## TLT IYR GLD
## 2010-01-15 0.020047024 -0.006304459 -0.004579349
## 2010-01-22 0.010101393 -0.041785012 -0.033285246
## 2010-01-29 0.003369243 -0.008447863 -0.011290465
##
## Monthly returns (first 3 rows):
## SPY QQQ EEM IWM EFA
## 2010-02-26 0.03119470 0.04603847 0.017763773 0.04475128 0.00266762
## 2010-03-31 0.06087967 0.07710898 0.081108578 0.08230707 0.06385420
## 2010-04-30 0.01547024 0.02242518 -0.001661622 0.05678419 -0.02804588
## TLT IYR GLD
## 2010-02-26 -0.003425016 0.05457042 0.032748219
## 2010-03-31 -0.020572990 0.09748489 -0.004386396
## 2010-04-30 0.033218260 0.06388117 0.058834363
library(tidyverse)
library(magrittr)
monthly_ret_tbl <- monthly_returns %>%
tk_tbl(rename_index = "date") %>%
mutate(date = as.yearmon(date))
head(monthly_ret_tbl)## # A tibble: 6 × 9
## date SPY QQQ EEM IWM EFA TLT IYR GLD
## <yearmon> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Feb 2010 0.0312 0.0460 0.0178 0.0448 0.00267 -0.00343 0.0546 0.0327
## 2 Mar 2010 0.0609 0.0771 0.0811 0.0823 0.0639 -0.0206 0.0975 -0.00439
## 3 Apr 2010 0.0155 0.0224 -0.00166 0.0568 -0.0280 0.0332 0.0639 0.0588
## 4 May 2010 -0.0795 -0.0739 -0.0939 -0.0754 -0.112 0.0511 -0.0568 0.0305
## 5 Jun 2010 -0.0517 -0.0598 -0.0140 -0.0774 -0.0206 0.0580 -0.0467 0.0236
## 6 Jul 2010 0.0683 0.0726 0.109 0.0673 0.116 -0.00946 0.0940 -0.0509
library(frenchdata)
# Download FF3 monthly factors
ff3_raw <- download_french_data("Fama/French 3 Factors")
ff3_monthly <- ff3_raw$subsets$data[[1]] # Monthly data
ff3_tbl <- ff3_monthly %>%
mutate(date = as.yearmon(as.character(date), "%Y%m")) %>%
filter(date >= as.yearmon("2010-01") & date <= as.yearmon(format(Sys.Date(), "%Y-%m"))) %>%
mutate(across(c(`Mkt-RF`, SMB, HML, RF), ~ as.numeric(.) / 100)) # Convert from % to decimal
head(ff3_tbl)## # A tibble: 6 × 5
## date `Mkt-RF` SMB HML RF
## <yearmon> <dbl> <dbl> <dbl> <dbl>
## 1 Jan 2010 -0.0335 0.0043 0.0033 0
## 2 Feb 2010 0.0339 0.0118 0.0318 0
## 3 Mar 2010 0.063 0.0146 0.0219 0.0001
## 4 Apr 2010 0.0199 0.0484 0.0296 0.0001
## 5 May 2010 -0.079 0.0013 -0.0248 0.0001
## 6 Jun 2010 -0.0556 -0.0179 -0.0473 0.0001
## # A tibble: 6 × 5
## date `Mkt-RF` SMB HML RF
## <yearmon> <dbl> <dbl> <dbl> <dbl>
## 1 Nov 2025 -0.0013 0.0054 0.0357 0.003
## 2 Dec 2025 -0.0036 -0.0103 0.0236 0.0034
## 3 Jan 2026 0.0103 0.0212 0.0386 0.003
## 4 Feb 2026 -0.0117 0.0024 0.0265 0.0028
## 5 Mar 2026 -0.0518 0.0044 0.0335 0.0029
## 6 Apr 2026 0.0994 0.0013 -0.0127 0.0029
merged_tbl <- monthly_ret_tbl %>%
inner_join(ff3_tbl, by = "date") %>%
arrange(date)
cat("Merged tibble dimensions:", dim(merged_tbl), "\n")## Merged tibble dimensions: 195 13
## # A tibble: 6 × 13
## date SPY QQQ EEM IWM EFA TLT IYR GLD
## <yearmon> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Feb 2010 0.0312 0.0460 0.0178 0.0448 0.00267 -0.00343 0.0546 0.0327
## 2 Mar 2010 0.0609 0.0771 0.0811 0.0823 0.0639 -0.0206 0.0975 -0.00439
## 3 Apr 2010 0.0155 0.0224 -0.00166 0.0568 -0.0280 0.0332 0.0639 0.0588
## 4 May 2010 -0.0795 -0.0739 -0.0939 -0.0754 -0.112 0.0511 -0.0568 0.0305
## 5 Jun 2010 -0.0517 -0.0598 -0.0140 -0.0774 -0.0206 0.0580 -0.0467 0.0236
## 6 Jul 2010 0.0683 0.0726 0.109 0.0673 0.116 -0.00946 0.0940 -0.0509
## # ℹ 4 more variables: `Mkt-RF` <dbl>, SMB <dbl>, HML <dbl>, RF <dbl>
# Helper: compute GMV weights using quadprog
gmv_weights <- function(cov_mat) {
n <- ncol(cov_mat)
Dmat <- 2 * cov_mat
dvec <- rep(0, n)
Amat <- cbind(rep(1, n), diag(n)) # sum=1, w >= 0
bvec <- c(1, rep(0, n))
result <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
result$solution
}
# Filter training window 2010/02 - 2015/01
train_data <- merged_tbl %>%
filter(date >= as.yearmon("2010-02") & date <= as.yearmon("2015-01"))
# Excess returns for CAPM regression
asset_cols <- tickers
train_excess <- train_data %>%
mutate(across(all_of(asset_cols), ~ . - RF))
# CAPM: regress each asset excess return on Mkt-RF, get residuals
# Covariance matrix = beta*beta' * var(Mkt) + diag(residual variances)
mkt_var <- var(train_data$`Mkt-RF`)
betas <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF`")), data = train_data)
coef(fit)[2]
})
resid_vars <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF`")), data = train_data)
var(resid(fit))
})
cov_capm <- outer(betas, betas) * mkt_var + diag(resid_vars)
rownames(cov_capm) <- colnames(cov_capm) <- asset_cols
cat("CAPM Covariance Matrix (2010/02-2015/01):\n")## CAPM Covariance Matrix (2010/02-2015/01):
## SPY QQQ EEM IWM EFA TLT IYR
## SPY 0.001397 0.001459 0.001726 0.001828 0.001581 -0.001011 0.001179
## QQQ 0.001459 0.001816 0.001815 0.001923 0.001662 -0.001064 0.001240
## EEM 0.001726 0.001815 0.003350 0.002274 0.001966 -0.001258 0.001466
## IWM 0.001828 0.001923 0.002274 0.002699 0.002083 -0.001333 0.001554
## EFA 0.001581 0.001662 0.001966 0.002083 0.002413 -0.001152 0.001343
## TLT -0.001011 -0.001064 -0.001258 -0.001333 -0.001152 0.001621 -0.000859
## IYR 0.001179 0.001240 0.001466 0.001554 0.001343 -0.000859 0.002025
## GLD 0.000235 0.000247 0.000292 0.000310 0.000268 -0.000171 0.000200
## GLD
## SPY 0.000235
## QQQ 0.000247
## EEM 0.000292
## IWM 0.000310
## EFA 0.000268
## TLT -0.000171
## IYR 0.000200
## GLD 0.002899
# GMV weights
w_capm <- gmv_weights(cov_capm)
names(w_capm) <- asset_cols
cat("\nGMV Weights (CAPM):\n")##
## GMV Weights (CAPM):
## 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 return 2015/02
ret_201502 <- merged_tbl %>% filter(date == as.yearmon("2015-02"))
realized_capm <- sum(w_capm * as.numeric(ret_201502[asset_cols]))
cat("\nRealized GMV Portfolio Return (CAPM) on 2015/02:", round(realized_capm * 100, 4), "%\n")##
## Realized GMV Portfolio Return (CAPM) on 2015/02: -0.7329 %
# FF3: regress each asset excess return on Mkt-RF, SMB, HML
# Cov = B * Cov(factors) * B' + diag(residual variances)
factor_cols <- c("Mkt-RF", "SMB", "HML")
factor_cov <- cov(train_data[, factor_cols])
B_mat <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF` + SMB + HML")), data = train_data)
coef(fit)[2:4]
}) # 3 x 8 matrix of factor loadings
resid_vars_ff3 <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF` + SMB + HML")), data = train_data)
var(resid(fit))
})
cov_ff3 <- t(B_mat) %*% factor_cov %*% B_mat + diag(resid_vars_ff3)
rownames(cov_ff3) <- colnames(cov_ff3) <- asset_cols
cat("FF3 Covariance Matrix (2010/02-2015/01):\n")## FF3 Covariance Matrix (2010/02-2015/01):
## SPY QQQ EEM IWM EFA TLT IYR
## SPY 0.001397 0.001464 0.001725 0.001787 0.001601 -0.001008 0.001179
## QQQ 0.001464 0.001816 0.001844 0.001870 0.001713 -0.000995 0.001248
## EEM 0.001725 0.001844 0.003350 0.002270 0.001980 -0.001228 0.001470
## IWM 0.001787 0.001870 0.002270 0.002699 0.001943 -0.001379 0.001552
## EFA 0.001601 0.001713 0.001980 0.001943 0.002413 -0.001104 0.001347
## TLT -0.001008 -0.000995 -0.001228 -0.001379 -0.001104 0.001621 -0.000851
## IYR 0.001179 0.001248 0.001470 0.001552 0.001347 -0.000851 0.002025
## GLD 0.000204 0.000337 0.000350 0.000469 0.000237 -0.000072 0.000216
## GLD
## SPY 0.000204
## QQQ 0.000337
## EEM 0.000350
## IWM 0.000469
## EFA 0.000237
## TLT -0.000072
## IYR 0.000216
## GLD 0.002899
# GMV weights
w_ff3 <- gmv_weights(cov_ff3)
names(w_ff3) <- asset_cols
cat("\nGMV Weights (FF3):\n")##
## GMV Weights (FF3):
## 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 return 2015/02
realized_ff3 <- sum(w_ff3 * as.numeric(ret_201502[asset_cols]))
cat("\nRealized GMV Portfolio Return (FF3) on 2015/02:", round(realized_ff3 * 100, 4), "%\n")##
## Realized GMV Portfolio Return (FF3) on 2015/02: -0.6224 %
all_dates <- merged_tbl$date
out_dates <- all_dates[all_dates >= as.yearmon("2015-02") & all_dates <= as.yearmon("2026-05")]
backtest_returns <- tibble(date = out_dates, capm = NA_real_, ff3 = NA_real_)
for (d in seq_along(out_dates)) {
t_date <- out_dates[d]
t_end <- all_dates[all_dates < t_date]
t_start <- tail(t_end, 60)[1]
if (is.na(t_start)) next
win <- merged_tbl %>% filter(date >= t_start & date < t_date)
if (nrow(win) < 30) next
# CAPM covariance
mkt_v <- var(win$`Mkt-RF`)
betas_w <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF`")), data = win)
coef(fit)[2]
})
rv_w <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF`")), data = win)
var(resid(fit))
})
cov_c <- outer(betas_w, betas_w) * mkt_v + diag(rv_w)
# FF3 covariance
fc <- cov(win[, factor_cols])
B_w <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF` + SMB + HML")), data = win)
coef(fit)[2:4]
})
rv_ff_w <- sapply(asset_cols, function(tk) {
fit <- lm(as.formula(paste0("`", tk, "` - RF ~ `Mkt-RF` + SMB + HML")), data = win)
var(resid(fit))
})
cov_f <- t(B_w) %*% fc %*% B_w + diag(rv_ff_w)
# GMV weights
tryCatch({
wc <- gmv_weights(cov_c)
wf <- gmv_weights(cov_f)
row_t <- merged_tbl %>% filter(date == t_date)
backtest_returns$capm[d] <- sum(wc * as.numeric(row_t[asset_cols]))
backtest_returns$ff3[d] <- sum(wf * as.numeric(row_t[asset_cols]))
}, error = function(e) NULL)
}
# Cumulative returns
cum_ret <- backtest_returns %>%
na.omit() %>%
mutate(
cum_capm = cumprod(1 + capm),
cum_ff3 = cumprod(1 + ff3)
)
# Plot
cum_ret %>%
select(date, cum_capm, cum_ff3) %>%
pivot_longer(-date, names_to = "model", values_to = "cum_return") %>%
mutate(model = recode(model,
"cum_capm" = "GMV (CAPM)",
"cum_ff3" = "GMV (FF3)")) %>%
ggplot(aes(x = as.Date(date), y = cum_return, color = model)) +
geom_line(linewidth = 1.2) +
scale_color_manual(values = c("GMV (CAPM)" = "#2196F3", "GMV (FF3)" = "#F44336")) +
scale_y_continuous(labels = scales::percent_format(scale = 100)) +
labs(
title = "Cumulative Returns: GMV Portfolios (2015/02 – 2026/05)",
subtitle = "Rolling 60-month window; CAPM vs. Fama-French 3-Factor covariance",
x = "Date",
y = "Cumulative Return",
color = "Model",
caption = "Assets: SPY, QQQ, EEM, IWM, EFA, TLT, IYR, GLD"
) +
theme_minimal(base_size = 13) +
theme(legend.position = "bottom")##
## === Backtest Summary ===
cum_ret %>%
summarise(
CAPM_Total_Return = scales::percent(last(cum_capm) - 1, accuracy = 0.01),
FF3_Total_Return = scales::percent(last(cum_ff3) - 1, accuracy = 0.01),
CAPM_Ann_Return = scales::percent((last(cum_capm))^(12/n()) - 1, accuracy = 0.01),
FF3_Ann_Return = scales::percent((last(cum_ff3))^(12/n()) - 1, accuracy = 0.01),
CAPM_Ann_Vol = scales::percent(sd(capm) * sqrt(12), accuracy = 0.01),
FF3_Ann_Vol = scales::percent(sd(ff3) * sqrt(12), accuracy = 0.01)
) %>%
pivot_longer(everything(), names_to = "Statistic", values_to = "Value") %>%
knitr::kable()| Statistic | Value |
|---|---|
| CAPM_Total_Return | 132.28% |
| FF3_Total_Return | 129.49% |
| CAPM_Ann_Return | 7.78% |
| FF3_Ann_Return | 7.66% |
| CAPM_Ann_Vol | 10.49% |
| FF3_Ann_Vol | 10.57% |