1 Introduction

This homework analyzes four Taiwan ETFs: 0050, 0056, 006205, and 00646 using in-sample data from 2015/12/14 to 2018/12/28.

We will:

  1. Find the Global Minimum Variance Portfolio (GMVP) using daily returns
  2. Recalculate GMVP using monthly returns
  3. Find the Tangency Portfolio using monthly returns (risk-free rate = 0, short-selling allowed)

2 Load Libraries & Data

library(tidyverse)
library(lubridate)
library(quadprog)
library(knitr)
library(kableExtra)

# Load data
df <- read.csv("myetf4.csv", stringsAsFactors = FALSE)

# Rename columns
colnames(df) <- c("Date", "tw0050", "tw0056", "tw006205", "tw00646")

# Parse dates (handles both "12/14/2015" and "2015-12-14" formats)
df$Date <- parse_date_time(df$Date, orders = c("mdy", "ymd"))
df$Date <- as.Date(df$Date)

# Filter in-sample period: 2015/12/14 to 2018/12/28
df_insample <- df %>%
  filter(Date >= as.Date("2015-12-14") & Date <= as.Date("2018-12-28")) %>%
  arrange(Date)

cat("In-sample rows:", nrow(df_insample), "\n")
## In-sample rows: 751
cat("Date range:", as.character(min(df_insample$Date)),
    "to", as.character(max(df_insample$Date)), "\n")
## Date range: 2015-12-14 to 2018-12-28
# Preview
head(df_insample) %>%
  kable(caption = "First 6 rows of in-sample data") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
First 6 rows of in-sample data
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

3 Q1: GMVP Using Daily Returns

3.1 Compute Daily Returns

prices_daily <- df_insample[, c("tw0050", "tw0056", "tw006205", "tw00646")]

# Daily log returns
ret_daily <- as.data.frame(apply(prices_daily, 2, function(x) diff(log(x))))

cat("Number of daily return observations:", nrow(ret_daily), "\n")
## Number of daily return observations: 750
head(ret_daily) %>%
  kable(caption = "First 6 rows of daily log returns", digits = 6) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
First 6 rows of daily log returns
tw0050 tw0056 tw006205 tw00646
0.000750 0.007098 0.016920 0.001019
0.015074 0.009746 0.000317 0.013158
0.011569 0.013380 0.019741 0.008012
-0.004942 0.007415 -0.001553 -0.010025
-0.001653 0.003687 0.025162 -0.010636
0.002386 -0.003687 0.003026 0.004065

3.2 Solve for GMVP Weights (Daily)

The Global Minimum Variance Portfolio solves:

\[\min_w \; w^\top \Sigma w \quad \text{subject to} \quad \mathbf{1}^\top w = 1, \; w_i \geq 0\]

n <- ncol(ret_daily)
cov_daily <- cov(ret_daily)

# quadprog: minimize 0.5 * w' Dmat w - dvec' w
Dmat <- 2 * cov_daily
dvec <- rep(0, n)

# Constraints: sum(w) = 1 (equality) + w_i >= 0 (non-negativity)
Amat <- cbind(rep(1, n), diag(n))
bvec <- c(1, rep(0, n))

sol_daily <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
weights_daily <- sol_daily$solution
names(weights_daily) <- colnames(ret_daily)

data.frame(
  ETF        = names(weights_daily),
  Weight     = round(weights_daily, 6),
  Weight_pct = paste0(round(weights_daily * 100, 4), "%")
) %>%
  kable(caption = "GMVP Optimal Weights (Daily Returns)",
        col.names = c("ETF", "Weight", "Weight (%)")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
GMVP Optimal Weights (Daily Returns)
ETF Weight Weight (%)
tw0050 tw0050 0.000000 0%
tw0056 tw0056 0.569975 56.9975%
tw006205 tw006205 0.083489 8.3489%
tw00646 tw00646 0.346536 34.6536%

3.3 GMVP Return & Standard Deviation (Daily)

mean_daily   <- colMeans(ret_daily)
port_ret_d   <- sum(weights_daily * mean_daily)
port_var_d   <- as.numeric(t(weights_daily) %*% cov_daily %*% weights_daily)
port_sd_d    <- sqrt(port_var_d)

data.frame(
  Metric = c("Expected Daily Return", "Daily Std Deviation",
             "Annualized Return (×252)", "Annualized Std Dev (×√252)"),
  Value  = c(
    paste0(round(port_ret_d * 100, 6), "%"),
    paste0(round(port_sd_d  * 100, 6), "%"),
    paste0(round(port_ret_d * 252 * 100, 4), "%"),
    paste0(round(port_sd_d  * sqrt(252) * 100, 4), "%")
  )
) %>%
  kable(caption = "GMVP Statistics (Daily Returns)") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
GMVP Statistics (Daily Returns)
Metric Value
Expected Daily Return 0.026138%
Daily Std Deviation 0.604032%
Annualized Return (×252) 6.5867%
Annualized Std Dev (×√252) 9.5887%

4 Q2: GMVP Using Monthly Returns

4.1 Aggregate to Monthly Returns

# Attach dates back (first date lost to differencing)
ret_daily_dated        <- ret_daily
ret_daily_dated$Date   <- df_insample$Date[-1]

# Sum daily log returns within each month → monthly log return
ret_monthly <- ret_daily_dated %>%
  mutate(YearMonth = floor_date(Date, "month")) %>%
  group_by(YearMonth) %>%
  summarise(across(c(tw0050, tw0056, tw006205, tw00646), sum), .groups = "drop")

cat("Number of monthly return observations:", nrow(ret_monthly), "\n")
## Number of monthly return observations: 37
ret_monthly %>%
  mutate(across(where(is.numeric), ~ round(.x, 6))) %>%
  kable(caption = "Monthly Returns (sum of daily log returns)") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
Monthly Returns (sum of daily log returns)
YearMonth tw0050 tw0056 tw006205 tw00646
2015-12-01 0.022452 0.032878 0.030128 0.022688
2016-01-01 -0.020015 -0.013882 -0.190036 -0.039659
2016-02-01 0.028238 0.042627 -0.027966 -0.003637
2016-03-01 0.054019 -0.002579 0.079505 0.025695
2016-04-01 -0.048394 -0.037899 -0.025069 0.009594
2016-05-01 0.024843 0.016494 0.004405 0.021870
2016-06-01 0.035718 0.029123 -0.025975 -0.026403
2016-07-01 0.058709 0.075498 0.011587 0.030328
2016-08-01 0.012292 0.011342 0.027851 0.003910
2016-09-01 0.021969 0.018161 -0.037560 -0.023693
2016-10-01 0.023014 -0.000923 0.016376 -0.002000
2016-11-01 -0.012362 -0.022893 0.054592 0.037814
2016-12-01 0.001381 -0.008066 -0.057919 0.040147
2017-01-01 0.020641 0.020279 0.016894 -0.014456
2017-02-01 0.021401 0.038915 0.002909 0.010280
2017-03-01 0.007471 0.010275 -0.028731 0.003712
2017-04-01 0.011464 -0.011173 -0.016199 -0.003248
2017-05-01 0.025779 0.015165 0.032876 0.003711
2017-06-01 0.049781 0.029658 0.062331 0.016074
2017-07-01 0.020262 0.019996 0.041926 0.014922
2017-08-01 0.019474 0.000000 0.035457 -0.009923
2017-09-01 -0.021311 -0.005068 -0.004484 0.037371
2017-10-01 0.048077 0.021776 0.023478 0.013446
2017-11-01 -0.035158 -0.023047 0.011226 0.013268
2017-12-01 -0.001297 0.000424 -0.007157 0.015609
2018-01-01 0.055549 0.056028 0.082107 0.035366
2018-02-01 -0.027641 -0.019010 -0.044713 -0.018349
2018-03-01 0.007296 0.000000 -0.052814 -0.041594
2018-04-01 -0.036242 -0.021462 -0.013732 0.022902
2018-05-01 0.009313 0.030810 0.003210 0.028520
2018-06-01 0.008717 -0.020434 -0.089111 0.018971
2018-07-01 0.057414 0.057755 0.004545 0.033422
2018-08-01 0.016257 0.021589 -0.034419 0.039801
2018-09-01 -0.000593 -0.018088 0.027770 -0.001488
2018-10-01 -0.113846 -0.079590 -0.077734 -0.065358
2018-11-01 -0.010424 0.028194 -0.006855 0.011848
2018-12-01 -0.016388 -0.021488 -0.036580 -0.092057

4.2 Solve for GMVP Weights (Monthly)

ret_m       <- ret_monthly %>% select(-YearMonth)
cov_monthly <- cov(ret_m)

Dmat_m <- 2 * cov_monthly
dvec_m <- rep(0, n)
Amat_m <- cbind(rep(1, n), diag(n))
bvec_m <- c(1, rep(0, n))

sol_monthly    <- solve.QP(Dmat_m, dvec_m, Amat_m, bvec_m, meq = 1)
weights_monthly <- sol_monthly$solution
names(weights_monthly) <- colnames(ret_m)

data.frame(
  ETF        = names(weights_monthly),
  Weight     = round(weights_monthly, 6),
  Weight_pct = paste0(round(weights_monthly * 100, 4), "%")
) %>%
  kable(caption = "GMVP Optimal Weights (Monthly Returns)",
        col.names = c("ETF", "Weight", "Weight (%)")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
GMVP Optimal Weights (Monthly Returns)
ETF Weight Weight (%)
tw0050 tw0050 0.000000 0%
tw0056 tw0056 0.488816 48.8816%
tw006205 tw006205 0.000000 0%
tw00646 tw00646 0.511184 51.1184%

4.3 GMVP Return & Standard Deviation (Monthly)

mean_monthly  <- colMeans(ret_m)
port_ret_m    <- sum(weights_monthly * mean_monthly)
port_var_m    <- as.numeric(t(weights_monthly) %*% cov_monthly %*% weights_monthly)
port_sd_m     <- sqrt(port_var_m)

data.frame(
  Metric = c("Expected Monthly Return", "Monthly Std Deviation",
             "Annualized Return (×12)", "Annualized Std Dev (×√12)"),
  Value  = c(
    paste0(round(port_ret_m * 100, 6), "%"),
    paste0(round(port_sd_m  * 100, 6), "%"),
    paste0(round(port_ret_m * 12 * 100, 4), "%"),
    paste0(round(port_sd_m  * sqrt(12) * 100, 4), "%")
  )
) %>%
  kable(caption = "GMVP Statistics (Monthly Returns)") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
GMVP Statistics (Monthly Returns)
Metric Value
Expected Monthly Return 0.59258%
Monthly Std Deviation 2.499661%
Annualized Return (×12) 7.111%
Annualized Std Dev (×√12) 8.6591%

5 Q3: Tangency Portfolio (Monthly Returns, Rf = 0)

The Tangency Portfolio maximizes the Sharpe Ratio. With \(R_f = 0\), the analytical closed-form solution is:

\[w^* \propto \Sigma^{-1} \mu \quad \text{then rescale so } \sum w_i = 1\]

Short-selling is allowed (standard textbook formulation).

Rf      <- 0
mu_m    <- colMeans(ret_m)
Sigma_m <- cov(ret_m)

# Analytical solution
z           <- solve(Sigma_m) %*% (mu_m - Rf)
weights_tan <- as.vector(z / sum(z))
names(weights_tan) <- colnames(ret_m)

data.frame(
  ETF        = names(weights_tan),
  Weight     = round(weights_tan, 6),
  Weight_pct = paste0(round(weights_tan * 100, 4), "%")
) %>%
  kable(caption = "Tangency Portfolio Weights (Monthly Returns, Rf = 0)",
        col.names = c("ETF", "Weight", "Weight (%)")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
Tangency Portfolio Weights (Monthly Returns, Rf = 0)
ETF Weight Weight (%)
tw0050 tw0050 1.017563 101.7563%
tw0056 tw0056 0.099867 9.9867%
tw006205 tw006205 -0.764590 -76.459%
tw00646 tw00646 0.647159 64.7159%

5.1 Tangency Portfolio Return, SD & Sharpe Ratio

port_ret_tan <- sum(weights_tan * mu_m)
port_var_tan <- as.numeric(t(weights_tan) %*% Sigma_m %*% weights_tan)
port_sd_tan  <- sqrt(port_var_tan)
sharpe_tan   <- (port_ret_tan - Rf) / port_sd_tan

data.frame(
  Metric = c("Expected Monthly Return", "Monthly Std Deviation", "Monthly Sharpe Ratio",
             "Annualized Return (×12)", "Annualized Std Dev (×√12)", "Annualized Sharpe (×√12)"),
  Value  = c(
    paste0(round(port_ret_tan * 100, 6), "%"),
    paste0(round(port_sd_tan  * 100, 6), "%"),
    round(sharpe_tan, 6),
    paste0(round(port_ret_tan * 12 * 100, 4), "%"),
    paste0(round(port_sd_tan  * sqrt(12) * 100, 4), "%"),
    round(sharpe_tan * sqrt(12), 6)
  )
) %>%
  kable(caption = "Tangency Portfolio Statistics (Monthly Returns, Rf = 0)") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Tangency Portfolio Statistics (Monthly Returns, Rf = 0)
Metric Value
Expected Monthly Return 1.673302%
Monthly Std Deviation 4.158934%
Monthly Sharpe Ratio 0.402339
Annualized Return (×12) 20.0796%
Annualized Std Dev (×√12) 14.407%
Annualized Sharpe (×√12) 1.393744

6 Summary Comparison

data.frame(
  Portfolio    = c("GMVP (Daily)", "GMVP (Monthly)", "Tangency (Monthly)"),
  tw0050       = paste0(round(c(weights_daily["tw0050"],
                                 weights_monthly["tw0050"],
                                 weights_tan["tw0050"]) * 100, 2), "%"),
  tw0056       = paste0(round(c(weights_daily["tw0056"],
                                 weights_monthly["tw0056"],
                                 weights_tan["tw0056"]) * 100, 2), "%"),
  tw006205     = paste0(round(c(weights_daily["tw006205"],
                                  weights_monthly["tw006205"],
                                  weights_tan["tw006205"]) * 100, 2), "%"),
  tw00646      = paste0(round(c(weights_daily["tw00646"],
                                  weights_monthly["tw00646"],
                                  weights_tan["tw00646"]) * 100, 2), "%"),
  Ann_Return   = c(
    paste0(round(port_ret_d   * 252    * 100, 4), "%"),
    paste0(round(port_ret_m   * 12     * 100, 4), "%"),
    paste0(round(port_ret_tan * 12     * 100, 4), "%")
  ),
  Ann_SD       = c(
    paste0(round(port_sd_d   * sqrt(252) * 100, 4), "%"),
    paste0(round(port_sd_m   * sqrt(12)  * 100, 4), "%"),
    paste0(round(port_sd_tan * sqrt(12)  * 100, 4), "%")
  )
) %>%
  kable(caption = "Portfolio Comparison Summary",
        col.names = c("Portfolio", "tw0050", "tw0056", "tw006205", "tw00646",
                      "Ann. Return", "Ann. Std Dev")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
Portfolio Comparison Summary
Portfolio tw0050 tw0056 tw006205 tw00646 Ann. Return Ann. Std Dev
GMVP (Daily) 0% 57% 8.35% 34.65% 6.5867% 9.5887%
GMVP (Monthly) 0% 48.88% 0% 51.12% 7.111% 8.6591%
Tangency (Monthly) 101.76% 9.99% -76.46% 64.72% 20.0796% 14.407%

7 Efficient Frontier Visualization (Monthly)

set.seed(42)
n_sim   <- 5000
sim_ret <- numeric(n_sim)
sim_sd  <- numeric(n_sim)

for (i in 1:n_sim) {
  w        <- runif(n); w <- w / sum(w)
  sim_ret[i] <- sum(w * mu_m)
  sim_sd[i]  <- sqrt(as.numeric(t(w) %*% Sigma_m %*% w))
}

sim_df <- data.frame(SD = sim_sd * 100, Return = sim_ret * 100)

key_pts <- data.frame(
  Label  = c("GMVP (Monthly)", "Tangency Portfolio"),
  SD     = c(port_sd_m * 100,   port_sd_tan * 100),
  Return = c(port_ret_m * 100,  port_ret_tan * 100)
)

ggplot(sim_df, aes(x = SD, y = Return)) +
  geom_point(alpha = 0.15, size = 0.7, color = "#4A90D9") +
  geom_point(data = key_pts, aes(x = SD, y = Return, color = Label),
             size = 5, shape = 18) +
  geom_text(data = key_pts, aes(x = SD, y = Return, label = Label),
            vjust = -1, size = 3.5, fontface = "bold") +
  scale_color_manual(values = c("GMVP (Monthly)"    = "#E74C3C",
                                "Tangency Portfolio" = "#27AE60")) +
  labs(
    title    = "Efficient Frontier: 4 Taiwan ETFs (Monthly Returns)",
    subtitle = "In-sample period: 2015/12/14 – 2018/12/28  |  Rf = 0",
    x        = "Monthly Std Dev (%)",
    y        = "Monthly Return (%)",
    color    = "Portfolio"
  ) +
  theme_minimal(base_size = 13) +
  theme(legend.position = "bottom",
        plot.title = element_text(face = "bold"))