Data Loading & Preparation
library(tidyverse)
library(lubridate)
library(quadprog)
library(knitr)
# Load price data
prices <- read.csv("myetf4.csv", stringsAsFactors = FALSE)
colnames(prices) <- c("Date", "tw0050", "tw0056", "tw006205", "tw00646")
prices$Date <- as.Date(prices$Date, format = "%Y/%m/%d")
# Filter in-sample period: 2015/12/14 – 2018/12/28
insample <- prices %>%
filter(Date >= as.Date("2015-12-14") & Date <= as.Date("2018-12-28"))
cat("In-sample rows:", nrow(insample), "\n")
## In-sample rows: 751
cat("Date range:", as.character(min(insample$Date)), "to",
as.character(max(insample$Date)), "\n")
## Date range: 2015-12-14 to 2018-12-28
kable(head(insample), caption = "First 6 rows of price data")
First 6 rows of price 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 |
Q1 – GMVP Using Daily Returns
Compute Daily Returns
cols <- c("tw0050","tw0056","tw006205","tw00646")
# Simple daily returns: (P_t / P_{t-1}) - 1
daily_ret <- insample %>%
arrange(Date) %>%
mutate(across(all_of(cols), ~ . / lag(.) - 1)) %>%
drop_na()
cat("Daily return observations:", nrow(daily_ret), "\n")
## Daily return observations: 750
kable(head(daily_ret[, c("Date", cols)]), digits = 6,
caption = "First 6 rows of daily returns")
First 6 rows of daily returns
| Date |
tw0050 |
tw0056 |
tw006205 |
tw00646 |
| 2015-12-15 |
0.000751 |
0.007123 |
0.017064 |
0.001020 |
| 2015-12-16 |
0.015188 |
0.009793 |
0.000317 |
0.013245 |
| 2015-12-17 |
0.011636 |
0.013470 |
0.019937 |
0.008044 |
| 2015-12-18 |
-0.004930 |
0.007443 |
-0.001551 |
-0.009975 |
| 2015-12-21 |
-0.001651 |
0.003694 |
0.025482 |
-0.010579 |
| 2015-12-22 |
0.002389 |
-0.003680 |
0.003030 |
0.004073 |
GMVP Optimization (Daily)
The Global Minimum Variance Portfolio (GMVP)
solves:
\[\min_{w} \; w^\top \Sigma w \quad
\text{s.t.} \quad \mathbf{1}^\top w = 1, \quad w_i \geq 0 \text{ (no
short-selling)}\]
ret_d <- as.matrix(daily_ret[, cols])
Sigma_d <- cov(ret_d)
mu_d <- colMeans(ret_d)
n <- length(cols)
# quadprog: minimise 0.5 * w' D w s.t. A' w >= b
# meq = 1 enforces the first constraint as equality (sum = 1)
Dmat <- 2 * Sigma_d
dvec <- rep(0, n)
Amat <- cbind(rep(1, n), diag(n)) # [1-vector | Identity]
bvec <- c(1, rep(0, n)) # [1 | zeros ]
sol_d <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
w_gmvp_d <- sol_d$solution
names(w_gmvp_d) <- cols
kable(data.frame(ETF = cols,
Weight = round(w_gmvp_d, 6),
Pct = paste0(round(w_gmvp_d * 100, 2), "%")),
col.names = c("ETF", "Weight", "Weight (%)"),
caption = "Q1 – GMVP Optimal Weights (Daily Returns, No Short-Selling)")
Q1 – GMVP Optimal Weights (Daily Returns, No
Short-Selling)
|
ETF |
Weight |
Weight (%) |
| tw0050 |
tw0050 |
0.000000 |
0% |
| tw0056 |
tw0056 |
0.571830 |
57.18% |
| tw006205 |
tw006205 |
0.083682 |
8.37% |
| tw00646 |
tw00646 |
0.344487 |
34.45% |
Portfolio Return & Standard Deviation (Daily)
port_ret_d <- as.numeric(t(w_gmvp_d) %*% mu_d)
port_sd_d <- as.numeric(sqrt(t(w_gmvp_d) %*% Sigma_d %*% w_gmvp_d))
kable(data.frame(
Metric = c("Daily Return", "Daily Std Dev"),
Value = c(sprintf("%.6f", port_ret_d), sprintf("%.6f", port_sd_d)),
Pct = c(sprintf("%.4f%%", port_ret_d * 100),
sprintf("%.4f%%", port_sd_d * 100))
), caption = "Q1 – GMVP Performance (Daily)")
Q1 – GMVP Performance (Daily)
| Metric |
Value |
Pct |
| Daily Return |
0.000290 |
0.0290% |
| Daily Std Dev |
0.006019 |
0.6019% |
Q2 – GMVP Using Monthly Returns
Convert Prices to Monthly Returns
We use the last closing price of each calendar month
to form monthly returns.
monthly_ret <- insample %>%
arrange(Date) %>%
mutate(YearMon = format(Date, "%Y-%m")) %>%
group_by(YearMon) %>%
slice_tail(n = 1) %>% # last trading day of month
ungroup() %>%
mutate(across(all_of(cols), ~ . / lag(.) - 1)) %>%
drop_na()
cat("Monthly return observations:", nrow(monthly_ret), "\n")
## Monthly return observations: 36
kable(head(monthly_ret[, c("YearMon", cols)]), digits = 6,
caption = "First 6 rows of monthly returns")
First 6 rows of monthly returns
| YearMon |
tw0050 |
tw0056 |
tw006205 |
tw00646 |
| 2016-01 |
-0.019817 |
-0.013786 |
-0.173071 |
-0.038883 |
| 2016-02 |
0.028641 |
0.043548 |
-0.027578 |
-0.003631 |
| 2016-03 |
0.055505 |
-0.002576 |
0.082751 |
0.026028 |
| 2016-04 |
-0.047241 |
-0.037190 |
-0.024758 |
0.009640 |
| 2016-05 |
0.025154 |
0.016631 |
0.004415 |
0.022111 |
| 2016-06 |
0.036364 |
0.029551 |
-0.025641 |
-0.026057 |
GMVP Optimization (Monthly)
ret_m <- as.matrix(monthly_ret[, cols])
Sigma_m <- cov(ret_m)
mu_m <- colMeans(ret_m)
Dmat_m <- 2 * Sigma_m
sol_m <- solve.QP(Dmat_m, dvec, Amat, bvec, meq = 1)
w_gmvp_m <- sol_m$solution
names(w_gmvp_m) <- cols
kable(data.frame(ETF = cols,
Weight = round(w_gmvp_m, 6),
Pct = paste0(round(w_gmvp_m * 100, 2), "%")),
col.names = c("ETF", "Weight", "Weight (%)"),
caption = "Q2 – GMVP Optimal Weights (Monthly Returns, No Short-Selling)")
Q2 – GMVP Optimal Weights (Monthly Returns, No
Short-Selling)
|
ETF |
Weight |
Weight (%) |
| tw0050 |
tw0050 |
0.003184 |
0.32% |
| tw0056 |
tw0056 |
0.474049 |
47.4% |
| tw006205 |
tw006205 |
0.001204 |
0.12% |
| tw00646 |
tw00646 |
0.521563 |
52.16% |
Portfolio Return & Standard Deviation (Monthly)
port_ret_m <- as.numeric(t(w_gmvp_m) %*% mu_m)
port_sd_m <- as.numeric(sqrt(t(w_gmvp_m) %*% Sigma_m %*% w_gmvp_m))
kable(data.frame(
Metric = c("Monthly Return", "Monthly Std Dev"),
Value = c(sprintf("%.6f", port_ret_m), sprintf("%.6f", port_sd_m)),
Pct = c(sprintf("%.4f%%", port_ret_m * 100),
sprintf("%.4f%%", port_sd_m * 100))
), caption = "Q2 – GMVP Performance (Monthly)")
Q2 – GMVP Performance (Monthly)
| Metric |
Value |
Pct |
| Monthly Return |
0.005734 |
0.5734% |
| Monthly Std Dev |
0.024904 |
2.4904% |
Note on Q2 GMVP: With only 36 monthly observations
and the covariance structure of these four ETFs, the unconstrained
analytical solution (\(w^* =
\Sigma^{-1}\mathbf{1} / \mathbf{1}^\top\Sigma^{-1}\mathbf{1}\))
produces all non-negative weights, so the no-short-selling constraint is
non-binding. The constrained and unconstrained solutions coincide
here.
Q3 – Tangency Portfolio (Monthly, R_f = 0)
The Tangency Portfolio maximises the Sharpe Ratio.
With \(R_f = 0\):
\[\max_{w} \; \frac{w^\top
\mu}{\sqrt{w^\top \Sigma w}} \quad \text{s.t.} \quad \mathbf{1}^\top w =
1, \quad w_i \geq 0\]
Constrained solution via quadprog: We
re-parameterise by minimising \(w^\top \Sigma
w\) subject to \(\mu^\top w =
1\) and \(w \geq 0\), then
renormalise so weights sum to 1.
# Constraint: mu' w = 1 (equality) + w_i >= 0 (inequality)
Amat_t <- cbind(mu_m, diag(n)) # [mu-vector | Identity]
bvec_t <- c(1, rep(0, n))
sol_t <- solve.QP(Dmat_m, dvec, Amat_t, bvec_t, meq = 1)
w_raw_t <- sol_t$solution
w_tan <- w_raw_t / sum(w_raw_t) # renormalise to sum = 1
names(w_tan) <- cols
kable(data.frame(ETF = cols,
Weight = round(w_tan, 6),
Pct = paste0(round(w_tan * 100, 2), "%")),
col.names = c("ETF", "Weight", "Weight (%)"),
caption = "Q3 – Tangency Portfolio Weights (Monthly, R_f = 0, No Short-Selling)")
Q3 – Tangency Portfolio Weights (Monthly, R_f = 0, No
Short-Selling)
|
ETF |
Weight |
Weight (%) |
| tw0050 |
tw0050 |
0.604918 |
60.49% |
| tw0056 |
tw0056 |
0.180718 |
18.07% |
| tw006205 |
tw006205 |
0.000000 |
0% |
| tw00646 |
tw00646 |
0.214364 |
21.44% |
Interpretation
The Tangency Portfolio allocates heavily to tw0050
(~60%) and tw00646 (~21%) while excluding
tw006205 entirely. Compared to the GMVP:
- It achieves a higher monthly return at the cost of
slightly more risk.
- Its Sharpe Ratio is higher than the GMVP — this is
by construction, since the tangency portfolio maximises the Sharpe
Ratio.
- The Capital Market Line passes through the risk-free rate (0) and
the tangency point; all mean–variance efficient portfolios (with
borrowing/lending) lie on this line.
Complete Summary
kable(data.frame(
Question = c("Q1 – GMVP (Daily)", "Q2 – GMVP (Monthly)",
"Q3 – Tangency (Monthly)"),
w_0050 = paste0(round(c(w_gmvp_d["tw0050"], w_gmvp_m["tw0050"],
w_tan["tw0050"]) * 100, 2), "%"),
w_0056 = paste0(round(c(w_gmvp_d["tw0056"], w_gmvp_m["tw0056"],
w_tan["tw0056"]) * 100, 2), "%"),
w_006205 = paste0(round(c(w_gmvp_d["tw006205"],w_gmvp_m["tw006205"],
w_tan["tw006205"]) * 100, 2), "%"),
w_00646 = paste0(round(c(w_gmvp_d["tw00646"], w_gmvp_m["tw00646"],
w_tan["tw00646"]) * 100, 2), "%"),
Return = paste0(round(c(port_ret_d, port_ret_m, tan_ret) * 100, 4), "%"),
StdDev = paste0(round(c(port_sd_d, port_sd_m, tan_sd) * 100, 4), "%")
), caption = "Summary: Weights and Performance for All Three Questions",
col.names = c("Question","w(0050)","w(0056)","w(006205)","w(00646)",
"Return","Std Dev"))
Summary: Weights and Performance for All Three
Questions
| Question |
w(0050) |
w(0056) |
w(006205) |
w(00646) |
Return |
Std Dev |
| Q1 – GMVP (Daily) |
0% |
57.18% |
8.37% |
34.45% |
0.029% |
0.6019% |
| Q2 – GMVP (Monthly) |
0.32% |
47.4% |
0.12% |
52.16% |
0.5734% |
2.4904% |
| Q3 – Tangency (Monthly) |
60.49% |
18.07% |
0% |
21.44% |
0.7583% |
2.8603% |