library(tidyverse)
library(lubridate)
library(knitr)
library(kableExtra)
# Load data
etf <- read.csv("myetf4.csv", stringsAsFactors = FALSE)
colnames(etf) <- c("Date", "tw0050", "tw0056", "tw006205", "tw00646")
etf$Date <- as.Date(etf$Date, format = "%Y/%m/%d")
# Filter in-sample: 2015/12/14 to 2018/12/28
etf_is <- etf %>% filter(Date >= as.Date("2015-12-14") & Date <= as.Date("2018-12-28"))
cat("In-sample period:", as.character(min(etf_is$Date)), "to", as.character(max(etf_is$Date)), "\n")## In-sample period: 2015-12-14 to 2018-12-28
## Number of observations: 751
head(etf_is) %>%
kable(caption = "First 6 rows of in-sample data") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)| Date | tw0050 | tw0056 | tw006205 | tw00646 |
|---|---|---|---|---|
| 2015-12-14 | 53.29 | 18.25 | 31.06 | 19.61 |
| 2015-12-15 | 53.33 | 18.38 | 31.59 | 19.63 |
| 2015-12-16 | 54.14 | 18.56 | 31.60 | 19.89 |
| 2015-12-17 | 54.77 | 18.81 | 32.23 | 20.05 |
| 2015-12-18 | 54.50 | 18.95 | 32.18 | 19.85 |
| 2015-12-21 | 54.41 | 19.02 | 33.00 | 19.64 |
# Compute daily simple returns
prices <- etf_is[, c("tw0050","tw0056","tw006205","tw00646")]
daily_ret <- as.data.frame(apply(prices, 2, function(x) diff(x) / head(x, -1)))
cat("Number of daily return observations:", nrow(daily_ret), "\n")## Number of daily return observations: 750
# Summary statistics
summary_daily <- data.frame(
ETF = colnames(daily_ret),
Mean = round(colMeans(daily_ret) * 100, 4),
StdDev = round(apply(daily_ret, 2, sd) * 100, 4)
)
kable(summary_daily, caption = "Daily Return Summary (%)", col.names = c("ETF","Mean (%)","Std Dev (%)")) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| ETF | Mean (%) | Std Dev (%) | |
|---|---|---|---|
| tw0050 | tw0050 | 0.0463 | 0.8853 |
| tw0056 | tw0056 | 0.0385 | 0.6728 |
| tw006205 | tw006205 | -0.0212 | 1.1420 |
| tw00646 | tw00646 | 0.0255 | 0.7683 |
The Global Minimum Variance Portfolio (GMVP) minimizes portfolio variance subject to weights summing to 1.
The analytic solution is:
\[w^* = \frac{\Sigma^{-1}\mathbf{1}}{\mathbf{1}^T \Sigma^{-1}\mathbf{1}}\]
## Covariance matrix (daily):
## tw0050 tw0056 tw006205 tw00646
## tw0050 0.783706 0.455916 0.446726 0.366339
## tw0056 0.455916 0.452641 0.267367 0.235354
## tw006205 0.446726 0.267367 1.304184 0.291037
## tw00646 0.366339 0.235354 0.291037 0.590289
# GMVP weights
ones <- rep(1, 4)
Sigma_inv_d <- solve(Sigma_d)
w_gmvp_d <- (Sigma_inv_d %*% ones) / as.numeric(t(ones) %*% Sigma_inv_d %*% ones)
# Results table
gmvp_d_tbl <- data.frame(
ETF = colnames(daily_ret),
Weight = round(as.numeric(w_gmvp_d) * 100, 4)
)
kable(gmvp_d_tbl, caption = "GMVP Weights (Daily Returns, %)",
col.names = c("ETF","Weight (%)")) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| ETF | Weight (%) |
|---|---|
| tw0050 | -21.9358 |
| tw0056 | 72.8372 |
| tw006205 | 10.7623 |
| tw00646 | 38.3363 |
##
## Sum of weights: 1
# Portfolio return and std dev
gmvp_d_ret <- as.numeric(t(w_gmvp_d) %*% colMeans(daily_ret))
gmvp_d_sd <- sqrt(as.numeric(t(w_gmvp_d) %*% Sigma_d %*% w_gmvp_d))
cat("\nGMVP Daily Expected Return:", round(gmvp_d_ret * 100, 6), "%\n")##
## GMVP Daily Expected Return: 0.025366 %
## GMVP Daily Std Deviation: 0.590494 %
# Annualized (assuming 252 trading days)
cat("\nAnnualized Expected Return:", round(gmvp_d_ret * 252 * 100, 4), "%\n")##
## Annualized Expected Return: 6.3923 %
## Annualized Std Deviation: 9.3738 %
q1_res <- data.frame(
Metric = c("GMVP Daily Expected Return (%)", "GMVP Daily Std Deviation (%)",
"Annualized Return (%)", "Annualized Std Dev (%)"),
Value = c(round(gmvp_d_ret*100, 6), round(gmvp_d_sd*100, 6),
round(gmvp_d_ret*252*100, 4), round(gmvp_d_sd*sqrt(252)*100, 4))
)
kable(q1_res, caption = "Q1: GMVP Summary (Daily Returns)") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| Metric | Value |
|---|---|
| GMVP Daily Expected Return (%) | 0.025366 |
| GMVP Daily Std Deviation (%) | 0.590494 |
| Annualized Return (%) | 6.392300 |
| Annualized Std Dev (%) | 9.373800 |
Monthly returns are computed from the last trading day price of each month.
# Add year-month column, take last price of each month
etf_is_m <- etf_is %>%
mutate(YM = format(Date, "%Y-%m")) %>%
group_by(YM) %>%
slice_tail(n = 1) %>%
ungroup() %>%
arrange(Date)
# Monthly returns from end-of-month prices
prices_m <- as.matrix(etf_is_m[, c("tw0050","tw0056","tw006205","tw00646")])
monthly_ret <- as.data.frame(apply(prices_m, 2, function(x) diff(x) / head(x,-1)))
cat("Number of monthly return observations:", nrow(monthly_ret), "\n")## Number of monthly return observations: 36
summary_monthly <- data.frame(
ETF = colnames(monthly_ret),
Mean = round(colMeans(monthly_ret) * 100, 4),
StdDev = round(apply(monthly_ret, 2, sd) * 100, 4)
)
kable(summary_monthly, caption = "Monthly Return Summary (%)",
col.names = c("ETF","Mean (%)","Std Dev (%)")) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| ETF | Mean (%) | Std Dev (%) | |
|---|---|---|---|
| tw0050 | tw0050 | 0.8820 | 3.4280 |
| tw0056 | tw0056 | 0.7087 | 3.0134 |
| tw006205 | tw006205 | -0.5355 | 4.9409 |
| tw00646 | tw00646 | 0.4511 | 2.9335 |
## Covariance matrix (monthly):
## tw0050 tw0056 tw006205 tw00646
## tw0050 11.751458 8.661004 8.472189 3.928466
## tw0056 8.661004 9.080806 5.553289 3.572509
## tw006205 8.472189 5.553289 24.412877 6.736296
## tw00646 3.928466 3.572509 6.736296 8.605161
Sigma_inv_m <- solve(Sigma_m)
w_gmvp_m <- (Sigma_inv_m %*% ones) / as.numeric(t(ones) %*% Sigma_inv_m %*% ones)
gmvp_m_tbl <- data.frame(
ETF = colnames(monthly_ret),
Weight = round(as.numeric(w_gmvp_m) * 100, 4)
)
kable(gmvp_m_tbl, caption = "GMVP Weights (Monthly Returns, %)",
col.names = c("ETF","Weight (%)")) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| ETF | Weight (%) |
|---|---|
| tw0050 | 0.3184 |
| tw0056 | 47.4049 |
| tw006205 | 0.1204 |
| tw00646 | 52.1563 |
##
## Sum of weights: 1
gmvp_m_ret <- as.numeric(t(w_gmvp_m) %*% colMeans(monthly_ret))
gmvp_m_sd <- sqrt(as.numeric(t(w_gmvp_m) %*% Sigma_m %*% w_gmvp_m))
cat("\nGMVP Monthly Expected Return:", round(gmvp_m_ret * 100, 6), "%\n")##
## GMVP Monthly Expected Return: 0.573367 %
## GMVP Monthly Std Deviation: 2.490441 %
# Annualized (12 months)
cat("\nAnnualized Expected Return:", round(gmvp_m_ret * 12 * 100, 4), "%\n")##
## Annualized Expected Return: 6.8804 %
## Annualized Std Deviation: 8.6271 %
q2_res <- data.frame(
Metric = c("GMVP Monthly Expected Return (%)", "GMVP Monthly Std Deviation (%)",
"Annualized Return (%)", "Annualized Std Dev (%)"),
Value = c(round(gmvp_m_ret*100, 6), round(gmvp_m_sd*100, 6),
round(gmvp_m_ret*12*100, 4), round(gmvp_m_sd*sqrt(12)*100, 4))
)
kable(q2_res, caption = "Q2: GMVP Summary (Monthly Returns)") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| Metric | Value |
|---|---|
| GMVP Monthly Expected Return (%) | 0.573367 |
| GMVP Monthly Std Deviation (%) | 2.490441 |
| Annualized Return (%) | 6.880400 |
| Annualized Std Dev (%) | 8.627100 |
The Tangency Portfolio maximizes the Sharpe ratio. With \(R_f = 0\):
\[w_{tan} = \frac{\Sigma^{-1}\mu}{\mathbf{1}^T \Sigma^{-1}\mu}\]
mu_m <- colMeans(monthly_ret) # mean monthly returns
Rf <- 0 # risk-free rate
# Excess returns (= mu since Rf = 0)
excess_m <- mu_m - Rf
# Tangency portfolio weights
w_tan_raw <- Sigma_inv_m %*% excess_m
w_tan <- w_tan_raw / sum(w_tan_raw)
tan_tbl <- data.frame(
ETF = colnames(monthly_ret),
Weight = round(as.numeric(w_tan) * 100, 4)
)
kable(tan_tbl, caption = "Tangency Portfolio Weights (Monthly Returns, Rf=0, %)",
col.names = c("ETF","Weight (%)")) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| ETF | Weight (%) |
|---|---|
| tw0050 | 130.5054 |
| tw0056 | -15.7681 |
| tw006205 | -84.7532 |
| tw00646 | 70.0159 |
##
## Sum of weights: 1
tan_ret <- as.numeric(t(w_tan) %*% mu_m)
tan_sd <- sqrt(as.numeric(t(w_tan) %*% Sigma_m %*% w_tan))
tan_sr <- (tan_ret - Rf) / tan_sd
cat("\nTangency Portfolio Monthly Return: ", round(tan_ret * 100, 6), "%\n")##
## Tangency Portfolio Monthly Return: 1.809002 %
## Tangency Portfolio Monthly Std Dev: 4.423638 %
## Sharpe Ratio (monthly): 0.40894
##
## Annualized Return: 21.708 %
## Annualized Std Dev: 15.3239 %
## Annualized Sharpe: 1.4166
q3_res <- data.frame(
Metric = c("Monthly Expected Return (%)", "Monthly Std Deviation (%)",
"Monthly Sharpe Ratio",
"Annualized Return (%)", "Annualized Std Dev (%)","Annualized Sharpe"),
Value = c(round(tan_ret*100, 6), round(tan_sd*100, 6), round(tan_sr, 6),
round(tan_ret*12*100, 4), round(tan_sd*sqrt(12)*100, 4), round(tan_sr*sqrt(12),4))
)
kable(q3_res, caption = "Q3: Tangency Portfolio Summary") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)| Metric | Value |
|---|---|
| Monthly Expected Return (%) | 1.809002 |
| Monthly Std Deviation (%) | 4.423638 |
| Monthly Sharpe Ratio | 0.408940 |
| Annualized Return (%) | 21.708000 |
| Annualized Std Dev (%) | 15.323900 |
| Annualized Sharpe | 1.416600 |
comp <- data.frame(
Portfolio = c("GMVP (Daily)", "GMVP (Monthly)", "Tangency (Monthly)"),
Weights_0050 = c(round(w_gmvp_d[1]*100,2), round(w_gmvp_m[1]*100,2), round(w_tan[1]*100,2)),
Weights_0056 = c(round(w_gmvp_d[2]*100,2), round(w_gmvp_m[2]*100,2), round(w_tan[2]*100,2)),
Weights_006205= c(round(w_gmvp_d[3]*100,2), round(w_gmvp_m[3]*100,2), round(w_tan[3]*100,2)),
Weights_00646 = c(round(w_gmvp_d[4]*100,2), round(w_gmvp_m[4]*100,2), round(w_tan[4]*100,2))
)
kable(comp, caption = "Portfolio Weights Comparison (%)",
col.names = c("Portfolio","0050 (%)","0056 (%)","006205 (%)","00646 (%)")) %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)| Portfolio | 0050 (%) | 0056 (%) | 006205 (%) | 00646 (%) |
|---|---|---|---|---|
| GMVP (Daily) | -21.94 | 72.84 | 10.76 | 38.34 |
| GMVP (Monthly) | 0.32 | 47.40 | 0.12 | 52.16 |
| Tangency (Monthly) | 130.51 | -15.77 | -84.75 | 70.02 |
library(ggplot2)
# Grid over target returns
target_rets <- seq(min(mu_m), max(mu_m)*1.5, length.out = 200)
port_sd <- sapply(target_rets, function(mu_p) {
# Lagrangian QP for minimum variance at target return
A <- rbind(cbind(2*Sigma_m, -mu_m, -ones),
cbind(t(mu_m), 0, 0),
cbind(t(ones), 0, 0))
b <- c(rep(0,4), mu_p, 1)
tryCatch({
sol <- solve(A, b)
w <- sol[1:4]
sqrt(as.numeric(t(w) %*% Sigma_m %*% w))
}, error = function(e) NA)
})
ef_df <- data.frame(StdDev = port_sd * 100, Return = target_rets * 100)
ef_df <- ef_df[!is.na(ef_df$StdDev), ]
# Individual ETFs
ind_df <- data.frame(
ETF = c("0050","0056","006205","00646"),
StdDev = apply(monthly_ret, 2, sd) * 100,
Return = colMeans(monthly_ret) * 100
)
ggplot(ef_df, aes(x = StdDev, y = Return)) +
geom_path(color = "steelblue", linewidth = 1.2) +
geom_point(data = ind_df, aes(color = ETF), size = 3, shape = 17) +
geom_point(aes(x = gmvp_m_sd*100, y = gmvp_m_ret*100),
color = "darkgreen", size = 4, shape = 8) +
geom_point(aes(x = tan_sd*100, y = tan_ret*100),
color = "red", size = 4, shape = 8) +
annotate("text", x = gmvp_m_sd*100, y = gmvp_m_ret*100,
label = "GMVP", hjust = -0.15, size = 3.5, color = "darkgreen") +
annotate("text", x = tan_sd*100, y = tan_ret*100,
label = "Tangency", hjust = -0.15, size = 3.5, color = "red") +
labs(title = "Efficient Frontier (Monthly Returns)",
x = "Monthly Std Deviation (%)", y = "Monthly Expected Return (%)",
color = "ETF") +
theme_minimal(base_size = 13)| GMVP (Daily) | GMVP (Monthly) | Tangency (Monthly, Rf=0) | |
|---|---|---|---|
| 0050 weight | -21.94% | 0.32% | 130.51% |
| 0056 weight | 72.84% | 47.4% | -15.77% |
| 006205 weight | 10.76% | 0.12% | -84.75% |
| 00646 weight | 38.34% | 52.16% | 70.02% |
| E(R) monthly | 0.0254% | 0.5734% | 1.809% |
| Std Dev monthly | 0.5905% | 2.4904% | 4.4236% |
| Sharpe (monthly) | — | — | 0.4089 |