# install.packages(c("tidyquant","timetk","lubridate","purrr",
# "quadprog","PerformanceAnalytics","ggplot2",
# "dplyr","tidyr","frenchdata","scales","knitr","kableExtra"))
library(tidyquant)
library(timetk)
library(lubridate)
library(purrr)
library(dplyr)
library(tidyr)
library(quadprog)
library(PerformanceAnalytics)
library(ggplot2)
library(frenchdata)
library(scales)
library(knitr)
library(kableExtra)Download adjusted daily closing prices for 8 ETFs from Yahoo Finance (2010-01-01 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")
# Wide tibble of adjusted prices
prices_tbl <- etf_raw %>%
select(symbol, date, adjusted) %>%
pivot_wider(names_from = symbol, values_from = adjusted) %>%
arrange(date)
# Convert to xts for return calculations
prices_xts <- prices_tbl %>%
tk_xts(date_var = date)# First 6 rows
head(prices_xts) %>%
as.data.frame() %>%
round(2) %>%
kable(caption = "Adjusted Prices — First 6 Trading Days") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| SPY | QQQ | EEM | IWM | EFA | TLT | IYR | GLD | |
|---|---|---|---|---|---|---|---|---|
| 2010-01-04 | 84.80 | 40.29 | 30.35 | 51.37 | 35.13 | 55.71 | 26.77 | 109.80 |
| 2010-01-05 | 85.02 | 40.29 | 30.57 | 51.19 | 35.16 | 56.07 | 26.83 | 109.70 |
| 2010-01-06 | 85.08 | 40.05 | 30.64 | 51.14 | 35.31 | 55.32 | 26.82 | 111.51 |
| 2010-01-07 | 85.44 | 40.07 | 30.46 | 51.52 | 35.17 | 55.41 | 27.06 | 110.82 |
| 2010-01-08 | 85.72 | 40.40 | 30.70 | 51.80 | 35.45 | 55.39 | 26.88 | 111.37 |
| 2010-01-11 | 85.84 | 40.24 | 30.64 | 51.59 | 35.74 | 55.08 | 27.01 | 112.85 |
# Last 6 rows
tail(prices_xts) %>%
as.data.frame() %>%
round(2) %>%
kable(caption = "Adjusted Prices — Last 6 Trading Days") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| 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) {
r <- diff(x) / lag(x)
tail(r, 1)
}) %>%
na.omit()
# Monthly simple returns
monthly_returns <- prices_xts %>%
apply.monthly(function(x) {
first_p <- as.numeric(x[1, ])
last_p <- as.numeric(x[nrow(x), ])
matrix((last_p - first_p) / first_p,
nrow = 1, dimnames = list(NULL, tickers))
}) %>%
na.omit()
cat("Weekly returns: ", nrow(weekly_returns), "observations\n")## Weekly returns: 857 observations
## Monthly returns: 197 observations
tail(monthly_returns, 6) %>%
as.data.frame() %>%
mutate(across(everything(), ~ percent(., accuracy = 0.01))) %>%
kable(caption = "Monthly Simple Returns — Last 6 Months") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| SPY | QQQ | EEM | IWM | EFA | TLT | IYR | GLD | |
|---|---|---|---|---|---|---|---|---|
| 2025-12-31 | 0.54% | -0.34% | 2.23% | 0.56% | 3.22% | -1.43% | -1.09% | 1.68% |
| 2026-01-30 | 1.29% | 1.43% | 5.09% | 4.37% | 3.81% | 0.11% | 2.36% | 11.72% |
| 2026-02-27 | -1.35% | -3.01% | 5.57% | -0.29% | 3.93% | 4.93% | 6.35% | 13.26% |
| 2026-03-31 | -4.99% | -4.96% | -7.66% | -5.83% | -5.97% | -3.26% | -6.61% | -12.19% |
| 2026-04-30 | 9.68% | 14.28% | 11.81% | 11.38% | 3.76% | -0.74% | 8.17% | -3.23% |
| 2026-05-29 | 4.97% | 9.52% | 6.97% | 3.99% | 2.64% | 0.18% | -0.91% | -1.43% |
monthly_tbl <- monthly_returns %>%
tk_tbl(rename_index = "date") %>%
mutate(date = as.yearmon(date))
head(monthly_tbl) %>%
mutate(across(-date, ~ round(., 4))) %>%
kable(caption = "Monthly Returns Tibble (first 6 rows)") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| date | SPY | QQQ | EEM | IWM | EFA | TLT | IYR | GLD |
|---|---|---|---|---|---|---|---|---|
| Jan 2010 | -0.0524 | -0.0782 | -0.1037 | -0.0605 | -0.0749 | 0.0278 | -0.0520 | -0.0350 |
| Feb 2010 | 0.0154 | 0.0347 | -0.0089 | 0.0326 | -0.0153 | 0.0057 | 0.0357 | 0.0100 |
| Mar 2010 | 0.0500 | 0.0617 | 0.0631 | 0.0577 | 0.0556 | -0.0201 | 0.0863 | -0.0044 |
| Apr 2010 | 0.0086 | 0.0224 | -0.0271 | 0.0471 | -0.0449 | 0.0358 | 0.0590 | 0.0463 |
| May 2010 | -0.0912 | -0.0867 | -0.0989 | -0.0957 | -0.1182 | 0.0525 | -0.0852 | 0.0272 |
| Jun 2010 | -0.0355 | -0.0510 | 0.0045 | -0.0486 | -0.0108 | 0.0506 | -0.0278 | 0.0148 |
ff3_raw <- download_french_data("Fama/French 3 Factors")
ff3_monthly <- ff3_raw$subsets$data[[1]] %>%
rename(MktRF = `Mkt-RF`) %>%
mutate(
date = as.yearmon(as.character(date), "%Y%m"),
MktRF = MktRF / 100,
SMB = SMB / 100,
HML = HML / 100,
RF = RF / 100
) %>%
filter(date >= as.yearmon("2010-01"))
head(ff3_monthly) %>%
mutate(across(-date, ~ round(., 5))) %>%
kable(caption = "FF3 Factors — First 6 Months (decimal form)") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| date | MktRF | SMB | HML | RF |
|---|---|---|---|---|
| Jan 2010 | -0.0335 | 0.0043 | 0.0033 | 0e+00 |
| Feb 2010 | 0.0339 | 0.0118 | 0.0318 | 0e+00 |
| Mar 2010 | 0.0630 | 0.0146 | 0.0219 | 1e-04 |
| Apr 2010 | 0.0199 | 0.0484 | 0.0296 | 1e-04 |
| May 2010 | -0.0790 | 0.0013 | -0.0248 | 1e-04 |
| Jun 2010 | -0.0556 | -0.0179 | -0.0473 | 1e-04 |
merged_tbl <- monthly_tbl %>%
inner_join(ff3_monthly, by = "date")
cat("Merged tibble:", nrow(merged_tbl), "rows,", ncol(merged_tbl), "columns\n")## Merged tibble: 196 rows, 13 columns
cat("Date range: ", as.character(min(merged_tbl$date)), "to",
as.character(max(merged_tbl$date)), "\n")## Date range: Jan 2010 to Apr 2026
head(merged_tbl) %>%
mutate(across(-date, ~ round(., 4))) %>%
kable(caption = "Merged Return + FF3 Tibble") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE, font_size = 11)| date | SPY | QQQ | EEM | IWM | EFA | TLT | IYR | GLD | MktRF | SMB | HML | RF |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Jan 2010 | -0.0524 | -0.0782 | -0.1037 | -0.0605 | -0.0749 | 0.0278 | -0.0520 | -0.0350 | -0.0335 | 0.0043 | 0.0033 | 0e+00 |
| Feb 2010 | 0.0154 | 0.0347 | -0.0089 | 0.0326 | -0.0153 | 0.0057 | 0.0357 | 0.0100 | 0.0339 | 0.0118 | 0.0318 | 0e+00 |
| Mar 2010 | 0.0500 | 0.0617 | 0.0631 | 0.0577 | 0.0556 | -0.0201 | 0.0863 | -0.0044 | 0.0630 | 0.0146 | 0.0219 | 1e-04 |
| Apr 2010 | 0.0086 | 0.0224 | -0.0271 | 0.0471 | -0.0449 | 0.0358 | 0.0590 | 0.0463 | 0.0199 | 0.0484 | 0.0296 | 1e-04 |
| May 2010 | -0.0912 | -0.0867 | -0.0989 | -0.0957 | -0.1182 | 0.0525 | -0.0852 | 0.0272 | -0.0790 | 0.0013 | -0.0248 | 1e-04 |
| Jun 2010 | -0.0355 | -0.0510 | 0.0045 | -0.0486 | -0.0108 | 0.0506 | -0.0278 | 0.0148 | -0.0556 | -0.0179 | -0.0473 | 1e-04 |
## ── Global Minimum Variance weights (long-only) ──────────────────────────────
gmv_weights <- function(cov_mat) {
n <- ncol(cov_mat)
Dmat <- 2 * cov_mat
dvec <- rep(0, n)
# Equality: sum(w) = 1; Inequality: w_i >= 0
Amat <- cbind(rep(1, n), diag(n))
bvec <- c(1, rep(0, n))
sol <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
w <- sol$solution
names(w) <- colnames(cov_mat)
w
}
## ── CAPM residual covariance matrix ──────────────────────────────────────────
capm_cov <- function(ret_tbl, ff_tbl) {
assets <- setdiff(names(ret_tbl), "date")
df <- ret_tbl %>%
inner_join(ff_tbl %>% select(date, MktRF, RF), by = "date") %>%
mutate(across(all_of(assets), ~ . - RF, .names = "excess_{.col}"))
betas <- numeric(length(assets))
resids <- matrix(NA, nrow(df), length(assets),
dimnames = list(NULL, assets))
for (i in seq_along(assets)) {
fit <- lm(df[[paste0("excess_", assets[i])]] ~ df$MktRF)
betas[i] <- coef(fit)[2]
resids[, i] <- residuals(fit)
}
sigma_m2 <- var(df$MktRF)
beta_mat <- matrix(betas, ncol = 1)
cov_mat <- (beta_mat %*% t(beta_mat)) * sigma_m2 + diag(apply(resids, 2, var))
dimnames(cov_mat) <- list(assets, assets)
cov_mat
}
## ── FF3 residual covariance matrix ───────────────────────────────────────────
ff3_cov <- function(ret_tbl, ff_tbl) {
assets <- setdiff(names(ret_tbl), "date")
df <- ret_tbl %>%
inner_join(ff_tbl %>% select(date, MktRF, SMB, HML, RF), by = "date") %>%
mutate(across(all_of(assets), ~ . - RF, .names = "excess_{.col}"))
B <- matrix(NA, 3, length(assets),
dimnames = list(c("MktRF","SMB","HML"), assets))
resids <- matrix(NA, nrow(df), length(assets),
dimnames = list(NULL, assets))
for (i in seq_along(assets)) {
fit <- lm(df[[paste0("excess_", assets[i])]] ~
df$MktRF + df$SMB + df$HML)
B[, i] <- coef(fit)[2:4]
resids[, i] <- residuals(fit)
}
Sigma_F <- cov(df[, c("MktRF","SMB","HML")])
cov_mat <- t(B) %*% Sigma_F %*% B + diag(apply(resids, 2, var))
dimnames(cov_mat) <- list(assets, assets)
cov_mat
}Training window: 2010/02 – 2015/01 (60 months)
Allocation date: 2015/01 (weights computed)
Realized return: 2015/02
train_start <- as.yearmon("2010-02")
train_end <- as.yearmon("2015-01")
invest_date <- as.yearmon("2015-02")
train_tbl <- merged_tbl %>%
filter(date >= train_start & date <= train_end)
invest_ret <- merged_tbl %>%
filter(date == invest_date) %>%
select(all_of(tickers))
# CAPM covariance matrix
cov_capm_q6 <- capm_cov(
train_tbl %>% select(date, all_of(tickers)),
ff3_monthly
)
# GMV weights
w_capm_q6 <- gmv_weights(cov_capm_q6)
# Realized portfolio return
r_capm_q6 <- sum(w_capm_q6 * as.numeric(invest_ret))# Weight table
data.frame(
Asset = names(w_capm_q6),
Weight = percent(w_capm_q6, accuracy = 0.01)
) %>%
kable(caption = "CAPM GMV Optimal Weights (as of 2015/01)") %>%
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE)| Asset | Weight | |
|---|---|---|
| SPY | SPY | 26.04% |
| QQQ | QQQ | 10.35% |
| EEM | EEM | 0.52% |
| IWM | IWM | 0.00% |
| EFA | EFA | 3.48% |
| TLT | TLT | 45.98% |
| IYR | IYR | 5.78% |
| GLD | GLD | 7.84% |
# Weight bar chart
data.frame(Asset = names(w_capm_q6), Weight = w_capm_q6) %>%
ggplot(aes(x = reorder(Asset, Weight), y = Weight, fill = Asset)) +
geom_col(show.legend = FALSE) +
geom_text(aes(label = percent(Weight, accuracy = 0.1)),
hjust = -0.1, size = 3.5) +
coord_flip() +
scale_y_continuous(labels = percent_format(), limits = c(0, max(w_capm_q6) * 1.2)) +
scale_fill_brewer(palette = "Set2") +
labs(title = "Q6: CAPM GMV Weights (2015/01)",
x = NULL, y = "Portfolio Weight") +
theme_minimal(base_size = 12)cat(sprintf("\n✅ Realized Portfolio Return (CAPM) in 2015/02: %.4f (%.2f%%)\n",
r_capm_q6, r_capm_q6 * 100))##
## ✅ Realized Portfolio Return (CAPM) in 2015/02: -0.0122 (-1.22%)
cov_ff3_q7 <- ff3_cov(
train_tbl %>% select(date, all_of(tickers)),
ff3_monthly
)
w_ff3_q7 <- gmv_weights(cov_ff3_q7)
r_ff3_q7 <- sum(w_ff3_q7 * as.numeric(invest_ret))data.frame(
Asset = names(w_ff3_q7),
Weight = percent(w_ff3_q7, accuracy = 0.01)
) %>%
kable(caption = "FF3 GMV Optimal Weights (as of 2015/01)") %>%
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE)| Asset | Weight | |
|---|---|---|
| SPY | SPY | 32.75% |
| QQQ | QQQ | 2.82% |
| EEM | EEM | 0.00% |
| IWM | IWM | 2.92% |
| EFA | EFA | 2.14% |
| TLT | TLT | 46.89% |
| IYR | IYR | 6.41% |
| GLD | GLD | 6.08% |
# Compare CAPM vs FF3 weights side by side
data.frame(
Asset = tickers,
CAPM = w_capm_q6,
FF3 = w_ff3_q7
) %>%
pivot_longer(-Asset, names_to = "Model", values_to = "Weight") %>%
ggplot(aes(x = Asset, y = Weight, fill = Model)) +
geom_col(position = "dodge") +
scale_y_continuous(labels = percent_format()) +
scale_fill_manual(values = c("CAPM" = "#2166ac", "FF3" = "#d6604d")) +
labs(title = "Q6 vs Q7: CAPM and FF3 GMV Weights Comparison (2015/01)",
x = NULL, y = "Portfolio Weight") +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom")cat(sprintf("\n✅ Realized Portfolio Return (FF3) in 2015/02: %.4f (%.2f%%)\n",
r_ff3_q7, r_ff3_q7 * 100))##
## ✅ Realized Portfolio Return (FF3) in 2015/02: -0.0132 (-1.32%)
# Side-by-side realized return summary
data.frame(
Model = c("CAPM GMV", "FF3 GMV"),
Realized_Return = percent(c(r_capm_q6, r_ff3_q7), accuracy = 0.01)
) %>%
kable(caption = "Realized Portfolio Return in 2015/02") %>%
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE)| Model | Realized_Return |
|---|---|
| CAPM GMV | -1.22% |
| FF3 GMV | -1.32% |
Strategy: At every month t, use 60-month history (t−60 to t−1) to estimate the GMV weights. Invest at t and record the realized return.
all_dates <- sort(unique(merged_tbl$date))
roll_start <- as.yearmon("2015-02")
roll_end <- as.yearmon("2026-05")
invest_dates <- all_dates[all_dates >= roll_start & all_dates <= roll_end]
n_periods <- length(invest_dates)
port_returns_capm <- rep(NA_real_, n_periods)
port_returns_ff3 <- rep(NA_real_, n_periods)
for (i in seq_len(n_periods)) {
t_invest <- invest_dates[i]
idx <- which(all_dates == t_invest)
t_end_train <- all_dates[idx - 1]
t_start_train <- all_dates[idx - 60]
if (length(t_start_train) == 0 || is.na(t_start_train)) next
win <- merged_tbl %>%
filter(date >= t_start_train & date <= t_end_train)
if (nrow(win) < 60) next
ret_month <- merged_tbl %>%
filter(date == t_invest) %>%
select(all_of(tickers))
if (nrow(ret_month) == 0) next
tryCatch({
cv_c <- capm_cov(win %>% select(date, all_of(tickers)), ff3_monthly)
w_c <- gmv_weights(cv_c)
port_returns_capm[i] <- sum(w_c * as.numeric(ret_month))
}, error = function(e) NULL)
tryCatch({
cv_f <- ff3_cov(win %>% select(date, all_of(tickers)), ff3_monthly)
w_f <- gmv_weights(cv_f)
port_returns_ff3[i] <- sum(w_f * as.numeric(ret_month))
}, error = function(e) NULL)
}
backtest_tbl <- tibble(
date = invest_dates,
ret_capm = port_returns_capm,
ret_ff3 = port_returns_ff3
) %>%
na.omit() %>%
mutate(
cum_capm = cumprod(1 + ret_capm) - 1,
cum_ff3 = cumprod(1 + ret_ff3) - 1,
date_dt = as.Date(date)
)
cat("Backtest periods computed:", nrow(backtest_tbl), "\n")## Backtest periods computed: 135
backtest_tbl %>%
select(date_dt, cum_capm, cum_ff3) %>%
pivot_longer(-date_dt, names_to = "Model", values_to = "Cum_Return") %>%
mutate(Model = recode(Model,
"cum_capm" = "CAPM GMV",
"cum_ff3" = "FF3 GMV")) %>%
ggplot(aes(x = date_dt, y = Cum_Return, color = Model)) +
geom_line(size = 1.1) +
geom_hline(yintercept = 0, linetype = "dashed", color = "grey50") +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
scale_color_manual(values = c("CAPM GMV" = "#2166ac",
"FF3 GMV" = "#d6604d")) +
labs(
title = "Cumulative Returns: CAPM GMV vs FF3 GMV",
subtitle = "Rolling 60-month window · 8-Asset ETF Universe (2015/02 – 2026/05)",
x = NULL, y = "Cumulative Return", color = NULL,
caption = "Assets: SPY, QQQ, EEM, IWM, EFA, TLT, IYR, GLD"
) +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold"),
legend.position = "bottom")backtest_tbl %>%
select(date_dt, ret_capm, ret_ff3) %>%
pivot_longer(-date_dt, names_to = "Model", values_to = "Return") %>%
mutate(Model = recode(Model,
"ret_capm" = "CAPM GMV",
"ret_ff3" = "FF3 GMV")) %>%
ggplot(aes(x = Return, fill = Model)) +
geom_histogram(bins = 40, alpha = 0.7, position = "identity") +
scale_x_continuous(labels = percent_format()) +
scale_fill_manual(values = c("CAPM GMV" = "#2166ac",
"FF3 GMV" = "#d6604d")) +
labs(title = "Distribution of Monthly Portfolio Returns",
x = "Monthly Return", y = "Count", fill = NULL) +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom")perf_summary <- function(rets, label) {
n <- length(rets)
ann_ret <- prod(1 + rets)^(12 / n) - 1
ann_vol <- sd(rets) * sqrt(12)
sr <- ann_ret / ann_vol
cum_ret <- cumprod(1 + rets) - 1
drawdown <- cum_ret - cummax(cum_ret)
max_dd <- min(drawdown)
tibble(
Model = label,
`Ann. Return` = percent(ann_ret, accuracy = 0.01),
`Ann. Volatility` = percent(ann_vol, accuracy = 0.01),
`Sharpe Ratio` = round(sr, 3),
`Max Drawdown` = percent(max_dd, accuracy = 0.01),
`Total Months` = n
)
}
bind_rows(
perf_summary(backtest_tbl$ret_capm, "CAPM GMV"),
perf_summary(backtest_tbl$ret_ff3, "FF3 GMV")
) %>%
kable(caption = "Performance Summary: 2015/02 – 2026/05") %>%
kable_styling(bootstrap_options = c("striped","hover","bordered"),
full_width = FALSE) %>%
row_spec(0, bold = TRUE)| Model | Ann. Return | Ann. Volatility | Sharpe Ratio | Max Drawdown | Total Months |
|---|---|---|---|---|---|
| CAPM GMV | 5.50% | 10.06% | 0.546 | -46.21% | 135 |
| FF3 GMV | 5.30% | 10.15% | 0.522 | -46.15% | 135 |
End of Final Exam — Portfolio Analysis