Answer: (A)Limiting the portfolio to no more than 20 stocks will likely increase the total risk of the portfolio. According to modern portfolio theory, diversification reduces the unsystematic (firm-specific) risk of a portfolio. By cutting the number of holdings from 40 down to 20, the concentration in individual assets doubles (increasing from an average of 2%–3% to roughly 5% per stock). This increases the portfolio’s vulnerability to unique corporate shocks and weakens the overall diversification benefit.
Answer: (B)Yes. Hennessy could reduce the holdings to 20 stocks without experiencing a significant spike in risk if they carefully select 20 stocks that possess low or negative correlations with one another. Alternatively, if the 20 stocks they eliminate were highly correlated with the 20 remaining stocks, the overall portfolio variance might remain relatively stable despite having fewer assets.
Answer: While a smaller portfolio allows a manager with superior stock-picking skills to capitalize heavily on their absolute top ideas, reducing the portfolio down to only 10 stocks dramatically elevates unsystematic risk. At 10 stocks, the portfolio becomes extremely concentrated (averaging 10% per position). A single stock experiencing unexpected negative earnings or distress would severely damage the performance of the entire portfolio, which is highly likely to offset any potential gains achieved through stock-selection skill. The marginal benefit of further concentration declines rapidly relative to the sharp, uncompensated increase in unique risk.
Answer: When evaluating the change from a total fund perspective, the Wilstead Pension Fund already holds a massive, highly diversified base of over 150 individual issues worth $250 million across its other five managers. Because the remainder of the aggregate fund is so thoroughly diversified, the unique, unsystematic risk of the Hennessy portfolio becomes negligible to the total fund’s net volatility. Taking this broader view, the committee would be much more inclined to support the restriction to 10 or 20 stocks. It allows Hennessy to act as an aggressive “alpha-generator” maximizing their selection skill without exposing the total pension pool to a meaningful threat.
# Input Portfolio Data from Text
portfolios <- data.frame(
Name = c("W", "X", "Z", "Y"),
Return = c(15, 12, 5, 9),
SD = c(36, 15, 7, 21)
)
print(portfolios)
Name Return SD
1 W 15 36
2 X 12 15
3 Z 5 7
4 Y 9 21
Answer: Portfolio Y cannot lie on the efficient frontier.
An efficient portfolio must maximize expected return for a given level of risk, or minimize risk for a given level of expected return. Comparing Portfolio X and Portfolio Y:
Portfolio X has a higher expected return (12% vs. 9%).
Portfolio X has a lower standard deviation (15% vs. 21%) than Portfolio Y.
Because Portfolio X strictly dominates Portfolio Y on both metrics, Portfolio Y is inefficient and cannot lie on the efficient frontier.
# Inputs from provided tables
w_A <- 0.5; w_B <- 0.5; w_C <- 0.5
sd_A <- 0.40; sd_B <- 0.20; sd_C <- 0.40
corr_AB <- 0.90
corr_BC <- 0.10
# Portfolio AB Variance and SD
var_AB <- (w_A^2 * sd_A^2) + (w_B^2 * sd_B^2) + (2 * w_A * w_B * sd_A * sd_B * corr_AB)
sd_portfolio_AB <- sqrt(var_AB)
# Portfolio BC Variance and SD
var_BC <- (w_B^2 * sd_B^2) + (w_C^2 * sd_C^2) + (2 * w_B * w_C * sd_B * sd_C * corr_BC)
sd_portfolio_BC <- sqrt(var_BC)
cat("Standard Deviation of Portfolio AB:", round(sd_portfolio_AB * 100, 2), "%\n")
Standard Deviation of Portfolio AB: 29.33 %
cat("Standard Deviation of Portfolio BC:", round(sd_portfolio_BC * 100, 2), "%\n")
Standard Deviation of Portfolio BC: 23.24 %
Answer: I recommend the portfolio made up of equal amounts of stocks B and C.
Justification: Both portfolios pair an asset with a 20% standard deviation (Stock B) with an asset possessing a 40% standard deviation (Stock A and Stock C both equal 40%). However, the correlation between B and C (0.10) is significantly lower than the correlation between A and B (0.90). As shown by the mathematical formulation inside the R chunk, this weaker correlation delivers powerful diversification benefits, resulting in a total portfolio standard deviation of 22.80% for BC compared to 29.15% for AB.
Answer:
Alpha (\(\alpha\)): ABC generated a negative abnormal return (-3.20%), while XYZ delivered a strong positive abnormal return (7.3%) relative to the market index over the sample period.
Beta (\(\beta\)): ABC (\(\beta = 0.60\)) acted as a defensive stock, being less sensitive to market swings, while XYZ (\(\beta = 0.97\)) moved nearly matching the market volatility.
\(R^2\): 35% of ABC’s variance is explained by the market (meaning 65% is firm-specific). For XYZ, only 17% is market-driven, leaving a massive 83% as firm-specific risk.
Implications for Future Relationships: When included in a well-diversified common stock portfolio, firm-specific risks (residual standard deviations) can be diversified away. Therefore, beta becomes the true measure of risk contribution. However, the newest 2-year data from the brokerage houses indicates that both stocks’ betas are increasing. ABC’s beta is edging higher (~0.66 average), and XYZ’s beta has spiked substantially (~1.35 average). This implies that both stocks—especially XYZ—will introduce considerably more systematic risk to a portfolio in the future than the historical 5-year regression reflects.
correlation <- 0.70
r_squared <- correlation^2 # Explains systematic risk proportion
nonsystematic_risk <- 1 - r_squared
cat("Nonsystematic (Specific) Risk Percentage:", nonsystematic_risk * 100, "%\n")
Nonsystematic (Specific) Risk Percentage: 51 %
Answer: 51% of the Baker Fund’s total risk is specific or nonsystematic.
E_Rm <- 0.11
E_Ri <- 0.09
Rf <- 0.03
# CAPM Formula: E(Ri) = Rf + Beta * (E(Rm) - Rf)
# Rearranging to find Beta: Beta = (E(Ri) - Rf) / (E(Rm) - Rf)
implied_beta <- (E_Ri - Rf) / (E_Rm - Rf)
cat("Implied Beta:", implied_beta, "\n")
Implied Beta: 0.75
Answer: The implied beta of the Charlottesville International Fund is 0.75.
Answer: (D) Systematic risk. Beta isolates and measures the volatility of a security relative to macro-level, non-diversifiable market movements.
Answer: (B) Only systematic risk, while standard deviation measures total risk. Standard deviation accounts for absolute dispersion (systematic plus unsystematic risk), while beta exclusively screens for market-driven sensitivity.
Answer: (C) Above the SML. Even without explicit risk-free rate data, we can evaluate relative return per unit of systematic risk. The S&P 500 benchmark represents a beta of 1.0 with a 14% return. Portfolio R provides an 11% return for only 0.5 beta. Since half of the market’s risk exposure yields far more than half of the market’s return baseline (\(11\% > 7\%\)), Portfolio R generates positive alpha and plots above the Security Market Line.
Answer: (B) Below the CML. The Capital Market Line (CML) charts the efficient frontier for perfectly diversified combinations of the market portfolio and risk-free assets. Because Portfolio R is a managed fund and possesses unique, non-diversified asset risk (indicated by its high standard deviation relative to its low beta), it is inefficient compared to a clean combination on the CML and must plot below it.
Answer: According to the Capital Asset Pricing Model (CAPM), investors should not expect a higher return on portfolio A than on portfolio B. CAPM maintains that the market only offers a risk premium for bearing systematic risk (\(\beta\)). Because Portfolio A and Portfolio B have completely identical systematic risks (\(\beta = 1.0\)), they must share the exact same expected equilibrium return. The high specific risk present in Portfolio A can easily be diversified away completely by investors at no cost, and therefore gains no extra compensation.
Rf <- 0.04
premium_GDP <- 0.08
premium_inflation <- 0.02
beta_GDP_HG <- 1.25
beta_inflation_HG <- 1.50
# APT Expected Return Calculation
E_R_HG <- Rf + (beta_GDP_HG * premium_GDP) + (beta_inflation_HG * premium_inflation)
cat("Expected Return of High Growth Fund:", E_R_HG * 100, "%\n")
Expected Return of High Growth Fund: 17 %
Answer: The APT estimate for the expected return of Orb’s High Growth Fund is 17%.
Answer: No, an arbitrage opportunity is not available. The APT model shows that the fair equilibrium risk premium for the Large Cap Fund above the risk-free rate is exactly 8.5% (\(0.75 \times 8\% + 1.25 \times 2\% = 8.5\%\)). Since Kwon’s fundamental analysis states that the expected return is exactly 8.5% above the risk-free rate, the asset’s marketplace projection perfectly aligns with its factor pricing. Because there is no pricing discrepancy between the structural equilibrium and fundamental analysis, no mispricing exists to exploit.
To isolate the structural weights (\(w_{HG}, w_{LC}, w_{Util}\)) for the High Growth, Large Cap, and Utility funds, we formulate a system of linear equations matching the required design parameters:
\(w_{HG} + w_{LC} + w_{Util} = 1\) (Weights must sum to 1)
\(1.25w_{HG} + 0.75w_{LC} + 1.0w_{Util} = 1\) (GDP sensitivity equals 1)
\(1.5w_{HG} + 1.25w_{LC} + 2.0w_{Util} = 0\) (Inflation sensitivity equals 0)
# Construct Matrix A for sensitivities and Vector B for target values
A <- matrix(c(1, 1, 1,
1.25, 0.75, 1.0,
1.5, 1.25, 2.0), nrow = 3, byrow = TRUE)
B <- c(1, 1, 0)
# Matrix inversion solution via R
fund_weights <- solve(A, B)
names(fund_weights) <- c("High Growth", "Large Cap", "Utility")
print(round(fund_weights, 1))
High Growth Large Cap Utility
1.6 1.6 -2.2
Answer: The correct option is (B) -3.2.
Answer: (A) McCracken is correct and Stiles is wrong.
Justification: Stiles claims this fund would be ideal for retirees living off fixed dividend income. This is fundamentally incorrect because a portfolio structured to have a positive unit sensitivity to GDP expansion exhibits massive cyclical macroeconomic risk, yielding major income volatility that threatens risk-averse retirees.
Conversely, McCracken is correct. Successful supply-side economic initiatives focus on generating expansion in real output and industrial productivity while preventing structural cost push inflation shocks. A portfolio specifically designed to isolate real GDP upside while entirely neutralizing inflation exposure is structurally optimized to thrive in this exact economic outcome.
knitr::opts_chunk$set(echo = TRUE, warning = FALSE, message = FALSE, comment = NA)
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)
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.3 ✔ 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
# Define tickers
tickers <- c("SPY", "QQQ", "EEM", "IWM", "EFA", "TLT", "IYR", "GLD")
# Fetch daily data using tidyquant
etf_daily_raw <- tq_get(tickers,
from = "2010-01-01",
to = "2026-06-05",
get = "stock.prices")
# Pivot to wide format extracting the adjusted close price
etf_prices_wide <- etf_daily_raw %>%
select(symbol, date, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date) %>%
na.omit() # Clean any missing dates to ensure completeness
# Inspect raw price structure
head(etf_prices_wide)
# 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-04 84.8 40.3 30.4 51.4 35.1 55.7 26.8 110.
2 2010-01-05 85.0 40.3 30.6 51.2 35.2 56.1 26.8 110.
3 2010-01-06 85.1 40.0 30.6 51.1 35.3 55.3 26.8 112.
4 2010-01-07 85.4 40.1 30.5 51.5 35.2 55.4 27.1 111.
5 2010-01-08 85.7 40.4 30.7 51.8 35.5 55.4 26.9 111.
6 2010-01-11 85.8 40.2 30.6 51.6 35.7 55.1 27.0 113.
# Convert tibble to xts format
etf_prices_xts <- tk_xts(etf_prices_wide, date_var = date)
# Compute Weekly Returns using simple methods
etf_weekly_prices <- to.period(etf_prices_xts, period = "weeks", OHLC = FALSE)
etf_weekly_returns <- Return.calculate(etf_weekly_prices, method = "simple")[-1, ]
# Compute Monthly Returns using simple methods
etf_monthly_prices <- to.period(etf_prices_xts, period = "months", OHLC = FALSE)
etf_monthly_returns <- Return.calculate(etf_monthly_prices, method = "simple")[-1, ]
# Check a snippet of the monthly returns matrix
head(etf_monthly_returns)
SPY QQQ EEM IWM EFA
2010-02-26 0.03119431 0.04603889 0.017763633 0.04475104 0.002667738
2010-03-31 0.06087976 0.07710907 0.081109002 0.08230708 0.063853486
2010-04-30 0.01546981 0.02242536 -0.001662003 0.05678433 -0.028045237
2010-05-28 -0.07945424 -0.07392338 -0.093935505 -0.07536624 -0.111927991
2010-06-30 -0.05174063 -0.05975675 -0.013986707 -0.07743415 -0.020619604
2010-07-30 0.06829985 0.07258175 0.109324852 0.06730943 0.116104196
TLT IYR GLD
2010-02-26 -0.003424083 0.05457040 0.032748219
2010-03-31 -0.020573637 0.09748499 -0.004386396
2010-04-30 0.033218042 0.06388101 0.058834363
2010-05-28 0.051084716 -0.05683546 0.030513147
2010-06-30 0.057976628 -0.04670135 0.023553189
2010-07-30 -0.009463534 0.09404800 -0.050871157
# Convert monthly returns xts object into a tibble
etf_monthly_tibble <- tk_tbl(etf_monthly_returns, rename_index = "date") %>%
mutate(date = as.Date(date))
head(etf_monthly_tibble)
# A tibble: 6 × 9
date SPY QQQ EEM IWM EFA TLT IYR GLD
<date> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 2010-02-26 0.0312 0.0460 0.0178 0.0448 0.00267 -0.00342 0.0546 0.0327
2 2010-03-31 0.0609 0.0771 0.0811 0.0823 0.0639 -0.0206 0.0975 -0.00439
3 2010-04-30 0.0155 0.0224 -0.00166 0.0568 -0.0280 0.0332 0.0639 0.0588
4 2010-05-28 -0.0795 -0.0739 -0.0939 -0.0754 -0.112 0.0511 -0.0568 0.0305
5 2010-06-30 -0.0517 -0.0598 -0.0140 -0.0774 -0.0206 0.0580 -0.0467 0.0236
6 2010-07-30 0.0683 0.0726 0.109 0.0673 0.116 -0.00946 0.0940 -0.0509
# Direct ZIP URL from Ken French Dartmouth Data Library
ff_zip_url <- "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_CSV.zip"
temp_zip <- tempfile(fileext = ".zip")
download.file(ff_zip_url, temp_zip, mode = "wb")
# Extract CSV
unzipped_files <- unzip(temp_zip, exdir = tempdir())
ff_csv_path <- unzipped_files[grep("F-F_Research_Data_Factors", unzipped_files)]
# Identify where the annual section starts to parse monthly data cleanly
ff_all_lines <- readLines(ff_csv_path)
annual_section_index <- grep(" Annual Factors", ff_all_lines)[1]
# Load and parse monthly section
ff_factors_raw <- read_csv(ff_csv_path,
skip = 3,
n_max = annual_section_index - 5,
show_col_types = FALSE) %>%
rename(date_temp = 1) %>%
filter(nchar(as.character(date_temp)) == 6) # Keep valid YYYYMM formats
# Structure dates to the first day of the month and divide percentages by 100
ff_monthly <- ff_factors_raw %>%
mutate(
year = substr(as.character(date_temp), 1, 4),
month = substr(as.character(date_temp), 5, 6),
date = as.Date(paste(year, month, "01", sep = "-"))
) %>%
select(-year, -month, -date_temp) %>%
mutate(across(-date, ~ as.numeric(.x) / 100))
head(ff_monthly)
# A tibble: 6 × 5
`Mkt-RF` SMB HML RF date
<dbl> <dbl> <dbl> <dbl> <date>
1 0.0289 -0.0255 -0.0239 0.0022 1926-07-01
2 0.0264 -0.0114 0.0381 0.0025 1926-08-01
3 0.0038 -0.0136 0.0005 0.0023 1926-09-01
4 -0.0327 -0.0014 0.0082 0.0032 1926-10-01
5 0.0254 -0.0011 -0.0061 0.0031 1926-11-01
6 0.0262 -0.0007 0.0006 0.0028 1926-12-01
# Standardize date keys
etf_monthly_prep <- etf_monthly_tibble %>%
mutate(date_ym = format(date, "%Y-%m"))
ff_monthly_prep <- ff_monthly %>%
mutate(date_ym = format(date, "%Y-%m"))
# Perform the merge and restore Date format to the start of each month
merged_portfolio_data <- inner_join(
etf_monthly_prep %>% select(-date),
ff_monthly_prep %>% select(-date),
by = "date_ym"
) %>%
rename(date = date_ym) %>%
mutate(date = as.Date(paste0(date, "-01"))) %>%
select(date, everything())
head(merged_portfolio_data)
# A tibble: 6 × 13
date SPY QQQ EEM IWM EFA TLT IYR GLD
<date> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 2010-02-01 0.0312 0.0460 0.0178 0.0448 0.00267 -0.00342 0.0546 0.0327
2 2010-03-01 0.0609 0.0771 0.0811 0.0823 0.0639 -0.0206 0.0975 -0.00439
3 2010-04-01 0.0155 0.0224 -0.00166 0.0568 -0.0280 0.0332 0.0639 0.0588
4 2010-05-01 -0.0795 -0.0739 -0.0939 -0.0754 -0.112 0.0511 -0.0568 0.0305
5 2010-06-01 -0.0517 -0.0598 -0.0140 -0.0774 -0.0206 0.0580 -0.0467 0.0236
6 2010-07-01 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>
We isolate the training subset from 2010/02 to 2015/01 (60 months). Under the CAPM framework, the covariance matrix \(\Sigma_{CAPM}\) of the 8-asset universe is modeled as:
\[\Sigma_{CAPM} = \beta \beta^T \sigma_m^2 + D\]
where \(\beta\) represents the sensitivities to the market excess factor (\(Mkt-RF\)), \(\sigma_m^2\) represents the variance of the market excess return, and \(D\) represents the diagonal matrix containing the residual variances (\(\sigma_{ei}^2\)) of each asset’s regression model.
Using this structured matrix, we calculate the unrestricted Global Minimum Variance (GMV) weights for 2015/01:
\[w = \frac{\Sigma^{-1} \mathbf{1}}{\mathbf{1}^T \Sigma^{-1} \mathbf{1}}\]
We then apply these weights to evaluate our realized return in 2015/02.
# Filter training window
training_subset <- merged_portfolio_data %>%
filter(date >= "2010-02-01" & date <= "2015-01-31")
asset_names <- c("SPY", "QQQ", "EEM", "IWM", "EFA", "TLT", "IYR", "GLD")
n_assets <- length(asset_names)
# Market Excess return variance
mkt_excess_ret <- training_subset$`Mkt-RF`
market_variance <- var(mkt_excess_ret)
# Linear regression for CAPM parameters
capm_betas <- numeric(n_assets)
capm_residuals <- numeric(n_assets)
names(capm_betas) <- asset_names
names(capm_residuals) <- asset_names
for (asset in asset_names) {
excess_asset_ret <- training_subset[[asset]] - training_subset$RF
capm_regression <- lm(excess_asset_ret ~ mkt_excess_ret)
capm_betas[asset] <- coef(capm_regression)[2]
capm_residuals[asset] <- var(residuals(capm_regression))
}
# Construct the structured CAPM Covariance Matrix
Sigma_CAPM <- (capm_betas %*% t(capm_betas)) * market_variance + diag(capm_residuals)
rownames(Sigma_CAPM) <- asset_names
colnames(Sigma_CAPM) <- asset_names
# Compute GMV optimal weights
ones_vector <- rep(1, n_assets)
inverse_Sigma_CAPM <- solve(Sigma_CAPM)
w_GMV_CAPM <- as.vector(inverse_Sigma_CAPM %*% ones_vector /
as.numeric(t(ones_vector) %*% inverse_Sigma_CAPM %*% ones_vector))
names(w_GMV_CAPM) <- asset_names
# Calculate realized portfolio return in 2015/02
realized_month <- merged_portfolio_data %>% filter(date == "2015-02-01")
realized_returns_Feb15 <- as.numeric(realized_month[asset_names])
realized_ret_CAPM <- sum(w_GMV_CAPM * realized_returns_Feb15)
cat("--- CAPM-Based Allocation Analysis ---\n")
--- CAPM-Based Allocation Analysis ---
print(round(w_GMV_CAPM, 4))
SPY QQQ EEM IWM EFA TLT IYR GLD
0.7748 -0.0130 -0.0361 -0.2029 -0.0357 0.4131 0.0373 0.0626
cat("\nRealized Portfolio Return on 2015/02:", round(realized_ret_CAPM * 100, 4), "%\n")
Realized Portfolio Return on 2015/02: -0.334 %
Under the multifactor Fama-French 3-factor setup, we regress each asset’s excess returns on the three factor portfolios:
\[R_{i,t} - R_{f,t} = \alpha_i + \beta_{i,1} F_{1,t} + \beta_{i,2} F_{2,t} + \beta_{i,3} F_{3,t} + e_{i,t}\]
The multifactor covariance matrix \(\Sigma_{FF3}\) is expressed as:
\[\Sigma_{FF3} = B \Omega_F B^T + D\]
where \(B\) is the \(8 \times 3\) matrix of factor loadings, \(\Omega_F\) is the \(3 \times 3\) covariance matrix of the three factor portfolios (\(Mkt-RF\), \(SMB\), and \(HML\)), and \(D\) is the diagonal matrix of specific residual variances.
We repeat the weights optimization and evaluate the realized performance on 2015/02.
# Compute the covariance of the three factors
factor_matrix <- training_subset %>%
select(`Mkt-RF`, SMB, HML) %>%
as.matrix()
Omega_Factors <- cov(factor_matrix)
# Run multifactor regressions
B_matrix <- matrix(0, nrow = n_assets, ncol = 3)
rownames(B_matrix) <- asset_names
colnames(B_matrix) <- c("Mkt-RF", "SMB", "HML")
ff3_residuals <- numeric(n_assets)
names(ff3_residuals) <- asset_names
for (asset in asset_names) {
excess_asset_ret <- training_subset[[asset]] - training_subset$RF
ff3_regression <- lm(excess_asset_ret ~ factor_matrix)
B_matrix[asset, ] <- coef(ff3_regression)[2:4]
ff3_residuals[asset] <- var(residuals(ff3_regression))
}
# Construct the structured Fama-French Covariance Matrix
Sigma_FF3 <- B_matrix %*% Omega_Factors %*% t(B_matrix) + diag(ff3_residuals)
rownames(Sigma_FF3) <- asset_names
colnames(Sigma_FF3) <- asset_names
# Compute GMV optimal weights
inverse_Sigma_FF3 <- solve(Sigma_FF3)
w_GMV_FF3 <- as.vector(inverse_Sigma_FF3 %*% ones_vector /
as.numeric(t(ones_vector) %*% inverse_Sigma_FF3 %*% ones_vector))
names(w_GMV_FF3) <- asset_names
# Calculate realized portfolio return in 2015/02
realized_ret_FF3 <- sum(w_GMV_FF3 * realized_returns_Feb15)
cat("--- Fama-French 3-Factor-Based Allocation Analysis ---\n")
--- Fama-French 3-Factor-Based Allocation Analysis ---
print(round(w_GMV_FF3, 4))
SPY QQQ EEM IWM EFA TLT IYR GLD
0.8828 -0.1425 -0.0431 -0.1153 -0.1037 0.4159 0.0368 0.0691
cat("\nRealized Portfolio Return on 2015/02:", round(realized_ret_FF3 * 100, 4), "%\n")
Realized Portfolio Return on 2015/02: -0.6559 %
We implement a systematic, rolling-window backtest simulation over the entire historical window from 2015/02 to 2026/05. For each month \(t\) within the evaluation period, we:
Isolate the preceding 60 months (\(t-60\) to \(t-1\)) of historical data.
Estimate the structured covariance matrices (\(\Sigma_{CAPM}\) and \(\Sigma_{FF3}\)).
Solve for the respective optimal GMV allocation weights.
Apply the weights to evaluate the realized portfolio returns in month \(t\).
Compile and plot the cumulative performance of both strategies.
# Ensure the dataset dates are properly sequenced and sorted
chronological_dates <- sort(unique(merged_portfolio_data$date))
evaluation_start <- as.Date("2015-02-01")
evaluation_end <- as.Date("2026-05-01")
backtest_dates <- chronological_dates[chronological_dates >= evaluation_start &
chronological_dates <= evaluation_end]
# Pre-allocate vectors to store realized monthly returns
realized_returns_CAPM <- numeric(length(backtest_dates))
realized_returns_FF3 <- numeric(length(backtest_dates))
# Execute the rolling window portfolio backtest
for (i in seq_along(backtest_dates)) {
current_date <- backtest_dates[i]
current_index <- which(chronological_dates == current_date)
# Set the 60-month training index window (t-60 to t-1)
train_indexes <- (current_index - 60):(current_index - 1)
rolling_train_set <- merged_portfolio_data[train_indexes, ]
# --- CAPM Co-estimation Loop ---
mkt_series <- rolling_train_set$`Mkt-RF`
rolling_mkt_var <- var(mkt_series)
rolling_betas <- numeric(n_assets)
rolling_residuals <- numeric(n_assets)
names(rolling_betas) <- asset_names
names(rolling_residuals) <- asset_names # FIX: Set names to prevent unwanted size expansion
for (asset in asset_names) {
ex_ret <- rolling_train_set[[asset]] - rolling_train_set$RF
fit_model <- lm(ex_ret ~ mkt_series)
rolling_betas[asset] <- coef(fit_model)[2]
rolling_residuals[asset] <- var(residuals(fit_model))
}
Sigma_C <- (rolling_betas %*% t(rolling_betas)) * rolling_mkt_var + diag(rolling_residuals)
inv_Sigma_C <- solve(Sigma_C)
w_C <- as.vector(inv_Sigma_C %*% ones_vector / as.numeric(t(ones_vector) %*% inv_Sigma_C %*% ones_vector))
# --- Fama-French 3-Factor Co-estimation Loop ---
rolling_factors <- rolling_train_set %>% select(`Mkt-RF`, SMB, HML) %>% as.matrix()
rolling_omega_f <- cov(rolling_factors)
rolling_B <- matrix(0, nrow = n_assets, ncol = 3)
rownames(rolling_B) <- asset_names
rolling_residuals_ff3 <- numeric(n_assets)
names(rolling_residuals_ff3) <- asset_names
for (asset in asset_names) {
ex_ret <- rolling_train_set[[asset]] - rolling_train_set$RF
fit_model_ff3 <- lm(ex_ret ~ rolling_factors)
rolling_B[asset, ] <- coef(fit_model_ff3)[2:4]
rolling_residuals_ff3[asset] <- var(residuals(fit_model_ff3))
}
Sigma_FF <- rolling_B %*% rolling_omega_f %*% t(rolling_B) + diag(rolling_residuals_ff3)
inv_Sigma_FF <- solve(Sigma_FF)
w_FF <- as.vector(inv_Sigma_FF %*% ones_vector / as.numeric(t(ones_vector) %*% inv_Sigma_FF %*% ones_vector))
# --- Realized Returns Calculation ---
actual_returns_t <- as.numeric(merged_portfolio_data[current_index, asset_names])
realized_returns_CAPM[i] <- sum(w_C * actual_returns_t)
realized_returns_FF3[i] <- sum(w_FF * actual_returns_t)
}
# Consolidate backtest metrics and calculate cumulative wealth ratios
backtest_results <- tibble(
Date = backtest_dates,
CAPM_GMV = realized_returns_CAPM,
FF3_GMV = realized_returns_FF3
) %>%
mutate(
CAPM_Cumulative = cumprod(1 + CAPM_GMV) - 1,
FF3_Cumulative = cumprod(1 + FF3_GMV) - 1
)
# Preview final performance metrics
tail(backtest_results)
# A tibble: 6 × 5
Date CAPM_GMV FF3_GMV CAPM_Cumulative FF3_Cumulative
<date> <dbl> <dbl> <dbl> <dbl>
1 2025-11-01 0.0213 0.0220 1.18 0.575
2 2025-12-01 0.0145 0.0121 1.21 0.594
3 2026-01-01 0.0686 0.0710 1.37 0.707
4 2026-02-01 0.0667 0.0659 1.52 0.819
5 2026-03-01 -0.0842 -0.0818 1.31 0.670
6 2026-04-01 0.00753 0.0107 1.33 0.688
# Transform to long format for clean ggplot2 visualization
plot_data <- backtest_results %>%
select(Date, CAPM_Cumulative, FF3_Cumulative) %>%
pivot_longer(cols = -Date, names_to = "Strategy", values_to = "CumulativeReturn") %>%
mutate(Strategy = ifelse(Strategy == "CAPM_Cumulative", "CAPM GMV", "Fama-French 3-Factor GMV"))
ggplot(plot_data, aes(x = Date, y = CumulativeReturn, color = Strategy)) +
geom_line(size = 1.2) +
scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
labs(
title = "Historical Portfolio Backtest: GMV Model Performance Comparison",
subtitle = "Rolling 60-Month Evaluation Window (2015/02 - 2026/05)",
x = "Timeline",
y = "Cumulative Return (%)",
color = "Structured Strategy"
) +
theme_minimal(base_size = 13) +
theme(
legend.position = "top",
plot.title = element_text(face = "bold", size = 15),
panel.grid.minor = element_blank()
)