a. Will the limitation to 20 stocks likely increase or decrease the risk of the portfolio? Explain.
Answer: It will likely increase the risk.
When you cut the number of stocks from 40 down to 20, you are reducing your diversification (not putting all your eggs in one basket). Because more money is now tied up in fewer companies, a sharp drop in just one or two stocks will cause a much larger, more painful loss to the overall portfolio.
b. Is there any way Hennessy could reduce the number of issues from 40 to 20 without significantly affecting risk? Explain.
Answer: Yes, there is.
In finance, the safety benefits of spreading out your investments mostly peak once you hit about 15 to 20 stocks. Hennessy can keep the risk nearly the same if they ensure the 20 stocks they keep belong to completely different industries (for example, mixing tech, healthcare, and energy). Because these industries don’t usually rise and fall at the same time, the portfolio stays balanced and safe.
Why reducing to 10 stocks might be less likely to be advantageous than 20.
Answer: Cutting the portfolio down to just 10 stocks is a step too far because it destroys the safety net completely.
The text mentions that Hennessy is good at identifying about 10 “star” stocks that make huge gains each year. However, if they only hold 10 stocks in total, they have to be 100% perfect. If even one or two of those choices turn out to be wrong or fail unexpectedly, the entire portfolio will suffer massive, devastating losses. Holding 20 stocks gives them room to pick their 10 favorites while keeping 10 others as a safety cushion.
How a “total fund” point of view could affect the decision to limit holdings to 10 or 20 issues.
Answer: Looking at the total fund makes the idea of limiting Hennessy to 10 or 20 stocks much more attractive.
The Big Picture: Hennessy only manages a small piece ($30 million) of a much larger $280 million pension fund. The rest of the money ($250 million) is already safely spread across more than 150 different stocks by five other managers.
The Decision: Because the total fund is already highly diversified and safe, the committee doesn’t need Hennessy to be safe. They can easily afford to let Hennessy take high risks with their small $30 million portion to chase maximum profits.
Answer: d. Portfolio Y.
Why Portfolio Y cannot be on the efficient frontier:
According to Markowitz, a portfolio is “efficient” if it gives you the best possible return for a specific level of risk. A portfolio cannot be on the efficient frontier if another option exists that gives you more return for less risk.
If you compare Portfolio X and Portfolio Y:
Portfolio X: Gives a 12% return with only 15% risk (standard deviation).
Portfolio Y: Gives a lower 9% return but forces you to take on a higher 21% risk.
Because Portfolio X is completely superior to Portfolio Y in every way (higher reward, safer ride), no rational investor would choose Portfolio Y. Therefore, Portfolio Y is inefficient and cannot lie on the efficient frontier.
Answer: I recommend the portfolio made up of equal amounts of Stock B and Stock C.
Here is why:
Identical Individual Risks: Both portfolios contain Stock B (\(20\%\) risk). The other half of the portfolio is either Stock A or Stock C. Since Stock A and Stock C have the exact same individual risk (\(40\%\)), the baseline risk of both combinations starts off equal.
Superior Diversification (Correlation): Correlation measures how closely two stocks move together.
Stocks A and B have a very high correlation (0.90), meaning they move up and down almost in lockstep. This provides very little safety benefit.
Stocks B and C have a very low correlation (0.10), meaning they move independently of each other.
Because Stocks B and C move independently, they will balance each other out when market swings happen. This low correlation effectively cancels out a lot of the risk, making the B and C portfolio much safer (lower total risk) than the A and B portfolio while holding the exact same caliber of assets.
Answer:
1. Past Performance (The 5-Year Historical Results)
To understand how these stocks behaved in the past, we look at two main things: their extra returns (Alpha) and their market sensitivity (Beta).
ABC (The Underperformer)
Return (Alpha = -3.20%): ABC did poorly. A negative alpha means it underperformed and failed to deliver the returns expected for the amount of risk it carried.
Risk (Beta = 0.60): It was a “defensive” stock. A beta of 0.60 means it was much less volatile than the overall market (if the market dropped 10%, ABC would only drop about 6%).
Company Drama (\(R^2\) & Residual SD): About 65% of ABC’s price movements were caused by its own unique company events rather than the broader market.
Stock XYZ (The Overperformer)
Return (Alpha = 7.3%): XYZ did exceptionally well. It generated a big, positive extra return beyond what the market expected.
Risk (Beta = 0.97): It moved almost exactly in lockstep with the broader market (a beta of 1.00 means it matches the market perfectly).
Company Drama (\(R^2\) & Residual SD): A massive 83% of XYZ’s movements were driven by its own unique company news. It carried a lot of independent, unpredictable risk (Residual SD of 21.45%).
2. Future Outlook & Portfolio Implications
When you put stocks into a diversified portfolio, the unique “company drama” of individual stocks cancels out. The only risk that actually matters to the portfolio is Beta (how much the stock reacts to the overall market). Looking at the new data from the two brokerage houses (which focus on the most recent 2 years), we see a major shift in risk:
Future Risk Implications
ABC remains stable: Its beta crept up slightly (from 0.60 to around 0.66), but it is still a relatively safe, low-risk stock that will cushion a portfolio during market downturns.
XYZ has become much riskier: Its beta has jumped significantly from 0.97 to an average of 1.35. This means XYZ is no longer an average-risk stock—it has recently become highly sensitive and volatile. If the market drops, XYZ will now fall much harder than it used to.
Future Return Implications
An analyst should not assume XYZ will continue to pull in that amazing 7.3% extra return (Alpha) indefinitely. In the stock market, historical hot streaks tend to fade back to average over time.
Answer: 51% \[\text{Systematic Risk} = 0.70^2 = 0.49 = 49\%\]
Total risk is always 100%. To find the remaining specific risk, simply subtract the market risk from 100%:
\[\text{Specific Risk} = 100\% - 49\% = 51\%\]
Answer: 0.75
\[\text{Expected Return} = \text{Risk-Free Rate} + \text{Beta} \times (\text{World Market Return} - \text{Risk-Free Rate}) \]Plug in the numbers given in the problem:\[9\% = 3\% + \text{Beta} \times (11\% - 3\%) \]Simplify the equation:\[9\% = 3\% + \text{Beta} \times 8\% \]Subtract 3% from both sides:\[6\% = \text{Beta} \times 8\% \]Divide by 8% to solve for Beta:\[\text{Beta} = \frac{6\%}{8\%} = 0.75\]
Answer: d. Systematic risk.
Why: Beta specifically measures how much a stock’s price jumps or drops compared to the overall market. Because market-wide risk affects everyone and cannot be avoided by diversifying, it is known as systematic (or market) risk.
Answer: b. Only systematic risk, while standard deviation measures total risk.
Why: Beta isolates and measures only the risk that comes from moving with the broader market.
Standard deviation looks at the entire picture. It measures every single up and down a stock experiences, which includes both market swings and unique company drama combined (total risk).
Answer: d. Insufficient data given.
Why: To figure out if a portfolio sits on, above, or below the Security Market Line (SML), you have to calculate its required “fair” return. To do this calculation, you absolutely must know the risk-free rate (the return on a completely safe asset like a government bond). Because that rate is not provided anywhere in the problem, it is impossible to determine where the portfolio lies.
Answer: d. Insufficient data given.
Why: Just like the previous question, mapping a portfolio relative to the Capital Market Line (CML) requires knowing the exact baseline where the line starts on the graph. This baseline is also the risk-free rate. Without this missing piece of information, you cannot calculate the slope of the line or verify the portfolio’s position relative to it.
Answer: No, investors should expect the exact same return on both portfolios.
Why the returns should be identical:
According to the Capital Asset Pricing Model (CAPM), expected returns are based only on systematic risk (beta).
Systematic Risk (Beta): This is market-wide risk that cannot be avoided. Because you cannot escape it, the market compensates you with a higher return for taking it on. Both portfolios have a beta of 1.0, meaning they carry the exact same amount of market risk.
Specific Risk: This is company-specific risk (like a factory fire or bad management). CAPM assumes that investors can easily get rid of specific risk for free simply by diversifying their portfolios. Because it can be wiped away through smart diversification, the market does not give you extra returns for holding it.
Since both portfolios have the same beta of 1.0, CAPM states they must offer the same expected return, completely ignoring the difference in their specific risks.
Answer: \[\text{Expected Return} = \text{Risk-Free Rate} + (\beta_{\text{GDP}} \times \text{GDP Premium}) + (\beta_{\text{Inflation}} \times \text{Inflation Premium})\]
\[Risk-Free Rate: $4\%\] \[GDP Part: $1.25 \times 8\% = 10\%\] \[Inflation Part: $1.5 \times 2\% = 3\%\]
\[\text{Expected Return} = 4\% + 10\% + 3\% = \mathbf{17\%}\]
Answer:
No, there is no arbitrage opportunity available.
Model Return Premium: Using the APT model for the Large Cap Fund, the expected return above the risk-free rate is:\[(0.75 \times 8\%) + (1.25 \times 2\%) = 6\% + 2.5\% = 8.5\%\]
Actual Return Premium: Sue Kwon’s analysis also shows the fund returns exactly \(8.5\%\) above the risk-free rate.
Since the estimated fair return matches the actual expected return, the fund is priced perfectly, leaving no room for a risk-free profit (arbitrage).
Answer:
The correct choice is (a) \(-2.2\).
To build a portfolio using all three funds that has a total weight of \(1.0\), a GDP sensitivity of \(1.0\), and \(0\) exposure to inflation, we set up a system of equations for the weights (\(w\)):
- Total Weight: \(w_{\text{High Growth}} + w_{\text{Large Cap}} + w_{\text{Utility}} = 1.0\)
- GDP Sensitivity: \(1.25(w_{\text{High Growth}}) + 0.75(w_{\text{Large Cap}}) + 1.0(w_{\text{Utility}}) = 1.0\)
- Inflation Sensitivity: \(1.5(w_{\text{High Growth}}) + 1.25(w_{\text{Large Cap}}) + 2.0(w_{\text{Utility}}) = 0.0\)
Solving this system gives:
\(w_{\text{High Growth}} = 1.6\)
\(w_{\text{Large Cap}} = 1.6\)
\(w_{\text{Utility}} = \mathbf{-2.2}\)
Answer: a. McCracken is correct and Stiles is wrong.
Why Stiles is wrong: The fund is directly tied to changes in economic growth (GDP). Retirees seeking steady, reliable income generally avoid funds heavily exposed to economic ups and downs.
Why McCracken is correct: Supply-side policies target economic growth. Since this fund benefits directly from GDP growth but is completely insulated from inflation, it will perform beautifully if those policies succeed.
# Load the requested libraries
library(tidyquant)
## Registered S3 method overwritten by 'quantmod':
## method from
## as.zoo.data.frame zoo
## ── Attaching core tidyquant packages ─────────────────────── tidyquant 1.0.12 ──
## ✔ PerformanceAnalytics 2.1.0 ✔ TTR 0.24.4
## ✔ quantmod 0.4.28 ✔ xts 0.14.2
## ── Conflicts ────────────────────────────────────────── tidyquant_conflicts() ──
## ✖ zoo::as.Date() masks base::as.Date()
## ✖ zoo::as.Date.numeric() masks base::as.Date.numeric()
## ✖ PerformanceAnalytics::legend() masks graphics::legend()
## ✖ quantmod::summary() masks base::summary()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(lubridate)
##
## Attaching package: 'lubridate'
##
## The following objects are masked from 'package:base':
##
## date, intersect, setdiff, union
library(timetk)
##
## Attaching package: 'timetk'
##
## The following object is masked from 'package:tidyquant':
##
## FANG
library(purrr)
# Define tickers and date range
tickers <- c("SPY", "QQQ", "EEM", "IWM", "EFA", "TLT", "IYR", "GLD")
start_date <- ymd("2010-01-01")
end_date <- today()
# Download data using tidyquant and purrr
etf_data_list <- tickers %>%
set_names() %>%
map(~ tq_get(.x, from = start_date, to = end_date, get = "stock.prices"))
# Extract adjusted close and convert to xts using timetk and purrr
adj_prices <- etf_data_list %>%
imap(~ {
xts_obj <- tk_xts(.x, select = adjusted, date_var = date, silent = TRUE)
colnames(xts_obj) <- .y
xts_obj
}) %>%
reduce(merge)
# View the first few rows of the final dataset
head(adj_prices)
## SPY QQQ EEM IWM EFA TLT IYR
## 2010-01-04 84.79638 40.29080 30.35150 51.36656 35.12843 55.70953 26.76811
## 2010-01-05 85.02085 40.29080 30.57181 51.18994 35.15940 56.06935 26.83239
## 2010-01-06 85.08071 40.04776 30.63577 51.14176 35.30801 55.31876 26.82070
## 2010-01-07 85.43985 40.07381 30.45810 51.51910 35.17178 55.41175 27.06028
## 2010-01-08 85.72416 40.40363 30.69973 51.80009 35.45043 55.38699 26.87913
## 2010-01-11 85.84389 40.23872 30.63577 51.59136 35.74146 55.08298 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
# --- Calculate Weekly Returns ---
weekly_returns_xts <- etf_data_list %>%
imap(~ tq_transmute(.x,
select = adjusted,
mutate_fun = periodReturn,
period = "weekly",
type = "arithmetic",
col_rename = .y) %>%
tk_xts(date_var = date, silent = TRUE)) %>%
reduce(merge)
# --- Calculate Monthly Returns ---
monthly_returns_xts <- etf_data_list %>%
imap(~ tq_transmute(.x,
select = adjusted,
mutate_fun = periodReturn,
period = "monthly",
type = "arithmetic",
col_rename = .y) %>%
tk_xts(date_var = date, silent = TRUE)) %>%
reduce(merge)
# Display head of monthly returns
head(monthly_returns_xts)
## SPY QQQ EEM IWM EFA
## 2010-01-29 -0.05241330 -0.07819927 -0.103722828 -0.06048768 -0.074915946
## 2010-02-26 0.03119441 0.04603910 0.017764057 0.04475153 0.002667503
## 2010-03-31 0.06087976 0.07710896 0.081109054 0.08230691 0.063854204
## 2010-04-30 0.01547050 0.02242518 -0.001662194 0.05678412 -0.028045995
## 2010-05-28 -0.07945471 -0.07392374 -0.093935627 -0.07536592 -0.111927803
## 2010-06-30 -0.05174119 -0.05975678 -0.013986567 -0.07743420 -0.020619607
## TLT IYR GLD
## 2010-01-29 0.027836571 -0.05195388 -0.034972713
## 2010-02-26 -0.003424749 0.05457042 0.032748219
## 2010-03-31 -0.020573120 0.09748496 -0.004386396
## 2010-04-30 0.033217985 0.06388116 0.058834363
## 2010-05-28 0.051083944 -0.05683547 0.030513147
## 2010-06-30 0.057977951 -0.04670065 0.023553189
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.2.1 ✔ stringr 1.6.0
## ✔ forcats 1.0.1 ✔ tibble 3.3.1
## ✔ ggplot2 4.0.2 ✔ tidyr 1.3.2
## ✔ readr 2.2.0
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::first() masks xts::first()
## ✖ dplyr::lag() masks stats::lag()
## ✖ dplyr::last() masks xts::last()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
monthly_returns_tbl <- monthly_returns_xts %>%
tk_tbl(rename_index = 'date') %>%
mutate(date = floor_date(date, "month"))
head(monthly_returns_tbl)
## # A tibble: 6 × 9
## date SPY QQQ EEM IWM EFA TLT IYR GLD
## <date> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 2010-01-01 -0.0524 -0.0782 -0.104 -0.0605 -0.0749 0.0278 -0.0520 -0.0350
## 2 2010-02-01 0.0312 0.0460 0.0178 0.0448 0.00267 -0.00342 0.0546 0.0327
## 3 2010-03-01 0.0609 0.0771 0.0811 0.0823 0.0639 -0.0206 0.0975 -0.00439
## 4 2010-04-01 0.0155 0.0224 -0.00166 0.0568 -0.0280 0.0332 0.0639 0.0588
## 5 2010-05-01 -0.0795 -0.0739 -0.0939 -0.0754 -0.112 0.0511 -0.0568 0.0305
## 6 2010-06-01 -0.0517 -0.0598 -0.0140 -0.0774 -0.0206 0.0580 -0.0467 0.0236
# Read raw text lines
all_lines <- readLines("F-F_Research_Data_Factors.csv")
# Locate start and end indices of the monthly factors section
start_idx <- grep(",Mkt-RF", all_lines)[1]
end_idx <- grep("Annual Factors", all_lines)[1]
# Extract and clean lines
monthly_lines <- all_lines[start_idx:(end_idx - 1)]
monthly_lines <- monthly_lines[trimws(monthly_lines) != ""]
# Read lines as CSV, rename date column, and convert to standard date format
ff_factors <- read_csv(paste(monthly_lines, collapse = "\n"), show_col_types = FALSE) %>%
rename(date = 1) %>%
mutate(date = ymd(paste0(date, "01")))
## Warning: The `file` argument of `read_csv()` should use `I()` for literal data as of
## readr 2.2.0.
##
## # Bad (for example):
## read_csv("x,y\n1,2")
##
## # Good:
## read_csv(I("x,y\n1,2"))
## This warning is displayed once per session.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
## New names:
## • `` -> `...1`
# Convert percent returns to decimal format
ff_factors_decimal <- ff_factors %>%
mutate(across(-date, ~ . / 100))
head(ff_factors_decimal)
## # A tibble: 6 × 5
## date `Mkt-RF` SMB HML RF
## <date> <dbl> <dbl> <dbl> <dbl>
## 1 1926-07-01 0.0289 -0.0255 -0.0239 0.0022
## 2 1926-08-01 0.0264 -0.0114 0.0381 0.0025
## 3 1926-09-01 0.0038 -0.0136 0.0005 0.0023
## 4 1926-10-01 -0.0327 -0.0014 0.0082 0.0032
## 5 1926-11-01 0.0254 -0.0011 -0.0061 0.0031
## 6 1926-12-01 0.0262 -0.0007 0.0006 0.0028
merged_portfolio_tbl <- inner_join(monthly_returns_tbl, ff_factors_decimal, by = "date")
head(merged_portfolio_tbl)
## # A tibble: 6 × 13
## date SPY QQQ EEM IWM EFA TLT IYR GLD
## <date> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 2010-01-01 -0.0524 -0.0782 -0.104 -0.0605 -0.0749 0.0278 -0.0520 -0.0350
## 2 2010-02-01 0.0312 0.0460 0.0178 0.0448 0.00267 -0.00342 0.0546 0.0327
## 3 2010-03-01 0.0609 0.0771 0.0811 0.0823 0.0639 -0.0206 0.0975 -0.00439
## 4 2010-04-01 0.0155 0.0224 -0.00166 0.0568 -0.0280 0.0332 0.0639 0.0588
## 5 2010-05-01 -0.0795 -0.0739 -0.0939 -0.0754 -0.112 0.0511 -0.0568 0.0305
## 6 2010-06-01 -0.0517 -0.0598 -0.0140 -0.0774 -0.0206 0.0580 -0.0467 0.0236
## # ℹ 4 more variables: `Mkt-RF` <dbl>, SMB <dbl>, HML <dbl>, RF <dbl>
# 1. Filter historical data (60 months)
hist_data <- merged_portfolio_tbl %>%
filter(date >= "2010-02-01" & date <= "2015-01-01")
# 2. CAPM Parameter Estimation (Using Excess Returns)
var_mkt <- var(hist_data$`Mkt-RF`)
betas <- numeric(length(tickers))
res_vars <- numeric(length(tickers))
names(betas) <- tickers
names(res_vars) <- tickers
for (ticker in tickers) {
asset_excess <- hist_data[[ticker]] - hist_data$RF
mkt_excess <- hist_data$`Mkt-RF`
fit <- lm(asset_excess ~ mkt_excess)
betas[ticker] <- coef(fit)[2]
res_vars[ticker] <- var(residuals(fit))
}
# 3. Construct the CAPM-implied Covariance Matrix
cov_capm <- (betas %*% t(betas)) * var_mkt + diag(res_vars)
rownames(cov_capm) <- tickers
colnames(cov_capm) <- tickers
# 4. Compute optimal weights for the GMVP
inv_cov <- solve(cov_capm)
ones <- rep(1, length(tickers))
weights_capm <- (inv_cov %*% ones) / as.numeric(t(ones) %*% inv_cov %*% ones)
weights_capm <- as.vector(weights_capm)
names(weights_capm) <- tickers
# 5. Calculate realized return on 2015/02
feb_2015_row <- merged_portfolio_tbl %>% filter(date == "2015-02-01")
realized_returns <- feb_2015_row %>% select(all_of(tickers)) %>% as.numeric()
names(realized_returns) <- tickers
realized_return_capm <- sum(weights_capm * realized_returns)
Results Summary
Optimal GMVP Weights (2015/01):
## SPY QQQ EEM IWM EFA TLT IYR GLD
## 0.7748 -0.0130 -0.0361 -0.2029 -0.0357 0.4131 0.0373 0.0626
Realized Portfolio Return in 2015/02: -0.3341%
# 1. Compute factor covariance matrix
factors <- hist_data %>% select(`Mkt-RF`, SMB, HML)
cov_factors <- cov(factors)
# 2. Estimate factor loadings and residuals
B <- matrix(NA, nrow = length(tickers), ncol = 3)
rownames(B) <- tickers
colnames(B) <- c("Mkt-RF", "SMB", "HML")
res_vars_ff <- numeric(length(tickers))
names(res_vars_ff) <- tickers
for (ticker in tickers) {
asset_excess <- hist_data[[ticker]] - hist_data$RF
fit <- lm(asset_excess ~ `Mkt-RF` + SMB + HML, data = hist_data)
B[ticker, ] <- coef(fit)[2:4]
res_vars_ff[ticker] <- var(residuals(fit))
}
# 3. Construct FF3-implied Covariance Matrix
cov_ff3 <- B %*% cov_factors %*% t(B) + diag(res_vars_ff)
rownames(cov_ff3) <- tickers
colnames(cov_ff3) <- tickers
# 4. Compute optimal weights for GMVP
inv_cov_ff3 <- solve(cov_ff3)
weights_ff3 <- (inv_cov_ff3 %*% ones) / as.numeric(t(ones) %*% inv_cov_ff3 %*% ones)
weights_ff3 <- as.vector(weights_ff3)
names(weights_ff3) <- tickers
# 5. Calculate realized return on 2015/02
realized_return_ff3 <- sum(weights_ff3 * realized_returns)
Results Summary:
Optimal GMVP Weights (2015/01):
## SPY QQQ EEM IWM EFA TLT IYR GLD
## 0.8828 -0.1425 -0.0431 -0.1153 -0.1037 0.4159 0.0368 0.0691
Realized Portfolio Return in 2015/02: -0.656%
library(PerformanceAnalytics)
library(scales)
##
## Attaching package: 'scales'
## The following object is masked from 'package:readr':
##
## col_factor
## The following object is masked from 'package:purrr':
##
## discard
# Filter backtest data range
end_date_limit <- ymd("2026-05-01")
last_date <- min(max(merged_portfolio_tbl$date), end_date_limit)
backtest_data <- merged_portfolio_tbl %>%
filter(date >= "2010-02-01" & date <= last_date) %>%
arrange(date)
# Out-of-sample investment dates
investment_dates <- backtest_data %>%
filter(date >= "2015-02-01") %>%
pull(date)
portfolio_returns <- tibble(
date = investment_dates,
CAPM = NA_real_,
FF3 = NA_real_
)
# Rolling Backtest Execution Loop
for (i in seq_along(investment_dates)) {
t_date <- investment_dates[i]
t_idx <- which(backtest_data$date == t_date)
est_window <- backtest_data[(t_idx - 60):(t_idx - 1), ]
# CAPM Covariance Matrix
var_mkt_capm <- var(est_window$`Mkt-RF`)
betas_capm <- numeric(length(tickers))
res_vars_capm <- numeric(length(tickers))
names(betas_capm) <- tickers
names(res_vars_capm) <- tickers
for (ticker in tickers) {
asset_excess <- est_window[[ticker]] - est_window$RF
fit_capm <- lm(asset_excess ~ `Mkt-RF`, data = est_window)
betas_capm[ticker] <- coef(fit_capm)[2]
res_vars_capm[ticker] <- var(residuals(fit_capm))
}
c_cov_capm <- (betas_capm %*% t(betas_capm)) * var_mkt_capm + diag(res_vars_capm)
inv_cov_capm <- solve(c_cov_capm)
w_capm <- (inv_cov_capm %*% ones) / as.numeric(t(ones) %*% inv_cov_capm %*% ones)
w_capm <- as.vector(w_capm)
# FF3 Covariance Matrix
factors_ff3 <- est_window %>% select(`Mkt-RF`, SMB, HML)
cov_factors_ff3 <- cov(factors_ff3)
B_ff3 <- matrix(NA, nrow = length(tickers), ncol = 3)
rownames(B_ff3) <- tickers
res_vars_ff3 <- numeric(length(tickers))
names(res_vars_ff3) <- tickers
for (ticker in tickers) {
asset_excess <- est_window[[ticker]] - est_window$RF
fit_ff3 <- lm(asset_excess ~ `Mkt-RF` + SMB + HML, data = est_window)
B_ff3[ticker, ] <- coef(fit_ff3)[2:4]
res_vars_ff3[ticker] <- var(residuals(fit_ff3))
}
c_cov_ff3 <- B_ff3 %*% cov_factors_ff3 %*% t(B_ff3) + diag(res_vars_ff3)
inv_cov_ff3 <- solve(c_cov_ff3)
w_ff3 <- (inv_cov_ff3 %*% ones) / as.numeric(t(ones) %*% inv_cov_ff3 %*% ones)
w_ff3 <- as.vector(w_ff3)
# Realized Returns at month t
realized_returns_t <- backtest_data[t_idx, ] %>% select(all_of(tickers)) %>% as.numeric()
portfolio_returns$CAPM[i] <- sum(w_capm * realized_returns_t)
portfolio_returns$FF3[i] <- sum(w_ff3 * realized_returns_t)
}
cumulative_returns <- portfolio_returns %>%
mutate(
CAPM_Cum = cumprod(1 + CAPM) - 1,
FF3_Cum = cumprod(1 + FF3) - 1
)
cumulative_long <- cumulative_returns %>%
select(date, CAPM_Cum, FF3_Cum) %>%
pivot_longer(cols = -date, names_to = "Model", values_to = "Cumulative_Return") %>%
mutate(Model = recode(Model, "CAPM_Cum" = "CAPM GMV", "FF3_Cum" = "Fama-French 3-Factor GMV"))
ggplot(cumulative_long, aes(x = date, y = Cumulative_Return, color = Model)) +
geom_line(linewidth = 1) +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(
title = "Cumulative Returns of GMV Portfolios",
subtitle = "CAPM vs. Fama-French 3-Factor Covariance Estimation (60-Month Rolling Window)",
x = "Date",
y = "Cumulative Return",
color = "Model"
) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(size = 11)
)
returns_xts <- portfolio_returns %>%
tk_xts(date_var = date, silent = TRUE)
performance_table <- table.AnnualizedReturns(returns_xts, Rf = 0, scale = 12)
knitr::kable(performance_table, caption = "Annualized Portfolio Performance (2015/02 - 2026/05)")
| CAPM | FF3 | |
|---|---|---|
| Annualized Return | 0.0780 | 0.0476 |
| Annualized Std Dev | 0.1068 | 0.1088 |
| Annualized Sharpe (Rf=0%) | 0.7308 | 0.4379 |