1 Giới thiệu

Báo cáo này trình bày quy trình tối ưu hóa danh mục đầu tư gồm các cổ phiếu niêm yết trên thị trường chứng khoán Việt Nam, sử dụng dữ liệu được cung cấp từ file có sẵn.


2 Bước 1: Chuẩn bị Môi trường và Tải Dữ liệu từ File

2.1 1.1. Cài đặt và Tải các thư viện cần thiết

2.2 1.2. Đọc dữ liệu từ file Excel hoặc CSV

file_path <- "C:/Users/ASUS/Downloads/data kltn HIEU.xlsx"
data_df <- read_excel(file_path)
# Chuyển đổi cột ngày tháng sang đúng định dạng Date
# Giả định cột ngày tháng của bạn có tên là 'Date' và định dạng là 'ngày/tháng/năm'
# Nếu định dạng khác, hãy thay đổi cho phù hợp (ví dụ: "%Y-%m-%d")
data_df$DATE <- as.Date(data_df$DATE, format="%d/%m/%Y")

# Chuyển đổi từ data.frame sang đối tượng xts (time series) để các thư viện tài chính có thể xử lý
prices <- xts(data_df[,-1], order.by=data_df$DATE)

# Hiển thị 6 dòng dữ liệu đầu tiên để kiểm tra
kable(head(prices), caption = "Dữ liệu giá được tải từ file")
Dữ liệu giá được tải từ file
VCB CTG ACB VIC KDH NLG VNM MSN MWG HPG REE GMD FPT CMG ELC TCM GIL KMR VNI
16822 9134.3 4008.938 27827 4434.6 8755.464 66667 55667 9957 3459.4 10149.4 19067 7437.9 4257.358 7499.559 14636.7 9001.8 6636 549.66
17929 9200.0 4002.425 27944 4479.4 8751.672 66667 56333 9957 3492.0 10331.3 19600 7484.6 4505.355 7496.483 14911.2 9021.6 6909 552.05
17718 9462.9 4015.450 27886 4501.8 8759.256 68056 56333 9773 3459.4 10222.1 19867 7531.3 4009.360 7502.634 14956.9 9111.8 6636 553.47
18245 9265.7 3989.400 28061 4479.4 8744.087 68403 56333 9865 3459.4 10113.0 20000 7453.5 5001.350 7490.331 14728.2 9021.7 6545 569.73
19458 9528.6 4041.500 27944 4524.2 8774.425 71528 56333 10049 3524.7 10185.8 20267 7515.7 5015.140 7514.938 14956.9 9001.7 6636 574.32
19616 9660.0 4093.700 27652 4524.2 8713.750 69444 57000 10142 3524.7 10258.5 19733 7562.4 5028.940 7465.725 14545.2 9011.6 6455 580.60

2.3 1.3. Thiết lập các tham số dựa trên dữ liệu đã tải

# Lấy danh sách mã cổ phiếu và chỉ số thị trường từ tên cột của file
# Giả định cột cuối cùng là chỉ số thị trường
all_symbols <- colnames(prices)
market_index <- tail(all_symbols, 1)
tickers <- setdiff(all_symbols, market_index)

# Lãi suất phi rủi ro (Risk-Free Rate - Rf)
# Giả định lãi suất TPCP 10 năm trung bình là 4.5%/năm
rf_annual <- 0.045
rf_daily <- (1 + rf_annual)^(1/252) - 1

3 Bước 2: Xử lý và Tính toán các tham số

3.1 2.1. Tính Tỷ suất sinh lợi (Returns)

returns <- CalculateReturns(prices, method = "log")
returns <- returns[-1, ]

stock_returns <- returns[, tickers]
market_returns <- returns[, market_index]

3.2 2.2. Ước tính Beta cho từng cổ phiếu

stock_excess_returns <- stock_returns - rf_daily
market_excess_returns <- market_returns - rf_daily

calculate_beta <- function(stock_excess_ret, market_excess_ret) {
  model <- lm(stock_excess_ret ~ market_excess_ret)
  return(coef(model)[2])
}

betas <- apply(stock_excess_returns, 2, calculate_beta, market_excess_ret = market_excess_returns)

beta_df <- data.frame(Ticker = names(betas), Beta = betas)
kable(beta_df, caption = "Hệ số Beta ước tính cho từng cổ phiếu")
Hệ số Beta ước tính cho từng cổ phiếu
Ticker Beta
VCB VCB 0.7868643
CTG CTG 1.1349729
ACB ACB 0.5232669
VIC VIC 0.7191851
KDH KDH 0.8230070
NLG NLG 0.9281138
VNM VNM 0.5392409
MSN MSN 0.8666979
MWG MWG 0.9852989
HPG HPG 1.0303310
REE REE 0.7860779
GMD GMD 0.8185845
FPT FPT 0.7918349
CMG CMG 0.8077552
ELC ELC 0.6776843
TCM TCM 0.7030618
GIL GIL 0.8427838
KMR KMR 0.5583024

3.3 2.3. Ước tính TSSL kỳ vọng theo CAPM

mean_market_return_daily <- mean(market_returns)
mean_market_return_annual <- mean_market_return_daily * 252

expected_returns_annual <- rf_annual + betas * (mean_market_return_annual - rf_annual)

er_df <- data.frame(Ticker = names(expected_returns_annual), ExpectedReturn = expected_returns_annual)
kable(er_df, caption = "Tỷ suất sinh lợi kỳ vọng (hàng năm) theo CAPM")
Tỷ suất sinh lợi kỳ vọng (hàng năm) theo CAPM
Ticker ExpectedReturn
VCB VCB 0.0759208
CTG CTG 0.0896001
ACB ACB 0.0655624
VIC VIC 0.0732612
KDH KDH 0.0773410
NLG NLG 0.0814713
VNM VNM 0.0661901
MSN MSN 0.0790579
MWG MWG 0.0837185
HPG HPG 0.0854881
REE REE 0.0758899
GMD GMD 0.0771672
FPT FPT 0.0761161
CMG CMG 0.0767417
ELC ELC 0.0716304
TCM TCM 0.0726276
GIL GIL 0.0781182
KMR KMR 0.0669392

3.4 2.4. Tính Ma trận Hiệp phương sai

cov_matrix <- cov(stock_returns) * 252
kable(round(cov_matrix[1:min(5, ncol(cov_matrix)), 1:min(5, ncol(cov_matrix))], 6), caption = "Một phần Ma trận Hiệp phương sai (thường niên hóa)")
Một phần Ma trận Hiệp phương sai (thường niên hóa)
VCB CTG ACB VIC KDH
VCB 0.082697 0.055485 0.013487 0.025069 0.023838
CTG 0.055485 0.118272 0.025992 0.028670 0.034918
ACB 0.013487 0.025992 0.091586 0.012123 0.018645
VIC 0.025069 0.028670 0.012123 0.084762 0.024213
KDH 0.023838 0.034918 0.018645 0.024213 0.085604

3.5 2.5. Kiểm định giả thiết thống kê về quy luật phân phối xác suất

# Áp dụng kiểm định Jarque-Bera cho từng chuỗi tỷ suất sinh lợi của cổ phiếu
jb_results <- lapply(tickers, function(ticker) {
  # Lấy chuỗi TSSL của một cổ phiếu
  returns_vector <- as.vector(stock_returns[, ticker])
  
  # Thực hiện kiểm định
  test <- jarque.bera.test(returns_vector)
  
  # Trả về kết quả dưới dạng data frame
  data.frame(
    "Mã CK" = ticker,
    "Hệ số Jarque-Bera" = round(test$statistic, 2),
    "p-value" = test$p.value,
    "Kết luận (mức ý nghĩa 5%)" = ifelse(test$p.value < 0.05, "Bác bỏ H0", "Không đủ bằng chứng bác bỏ H0"),
    stringsAsFactors = FALSE,
    check.names = FALSE
  )
})

# Gộp tất cả kết quả lại thành một bảng duy nhất
jb_table <- do.call(rbind, jb_results)

# In bảng kết quả ra văn bản
kable(jb_table, row.names = FALSE, caption = "Kết quả kiểm định phân phối chuẩn Jarque-Bera")
Kết quả kiểm định phân phối chuẩn Jarque-Bera
Mã CK Hệ số Jarque-Bera p-value Kết luận (mức ý nghĩa 5%)
VCB 2385.84 0 Bác bỏ H0
CTG 559.89 0 Bác bỏ H0
ACB 16527.58 0 Bác bỏ H0
VIC 5254.68 0 Bác bỏ H0
KDH 1328.43 0 Bác bỏ H0
NLG 596.01 0 Bác bỏ H0
VNM 277379.43 0 Bác bỏ H0
MSN 3029.60 0 Bác bỏ H0
MWG 488.74 0 Bác bỏ H0
HPG 283.54 0 Bác bỏ H0
REE 818.92 0 Bác bỏ H0
GMD 14581.80 0 Bác bỏ H0
FPT 1348.15 0 Bác bỏ H0
CMG 2407.11 0 Bác bỏ H0
ELC 8305.43 0 Bác bỏ H0
TCM 292.06 0 Bác bỏ H0
GIL 6944.70 0 Bác bỏ H0
KMR 205.71 0 Bác bỏ H0

3.6 2.6. Phân tích đặc điểm Rủi ro, Lợi nhuận của các cổ phiếu riêng lẻ

### 1.3. Thiết lập các tham số dựa trên dữ liệu đã tải (Đã sửa lỗi)

# BƯỚC 1: In ra tên cột thực tế để kiểm tra
print("Tên các cột trong file dữ liệu của bạn là:")
## [1] "Tên các cột trong file dữ liệu của bạn là:"
print(colnames(prices))
##  [1] "VCB" "CTG" "ACB" "VIC" "KDH" "NLG" "VNM" "MSN" "MWG" "HPG" "REE" "GMD"
## [13] "FPT" "CMG" "ELC" "TCM" "GIL" "KMR" "VNI"
# BƯỚC 2: Khai báo chính xác tên cột chỉ số thị trường
# (Hãy nhìn kết quả in ra ở trên và điền đúng tên cột VN-Index vào đây)
market_index <- "VNINDEX" 

# BƯỚC 3: Code sẽ tự động lấy các tên cột còn lại làm mã cổ phiếu
all_symbols <- colnames(prices)
tickers <- setdiff(all_symbols, market_index)

# In ra danh sách tickers đã được nhận diện để kiểm tra lại lần nữa
print("Các mã cổ phiếu được nhận diện để phân tích:")
## [1] "Các mã cổ phiếu được nhận diện để phân tích:"
print(tickers)
##  [1] "VCB" "CTG" "ACB" "VIC" "KDH" "NLG" "VNM" "MSN" "MWG" "HPG" "REE" "GMD"
## [13] "FPT" "CMG" "ELC" "TCM" "GIL" "KMR" "VNI"
# Lãi suất phi rủi ro (Risk-Free Rate - Rf)
rf_annual <- 0.045
rf_daily <- (1 + rf_annual)^(1/252) - 1
### 2.6. Phân tích đặc điểm Rủi ro, Lợi nhuận của các cổ phiếu riêng lẻ (Đã sửa lỗi)

# BƯỚC 1: Tạo một data frame để định nghĩa ngành cho mỗi cổ phiếu
industry_map <- data.frame(
  Ticker = c("VCB", "CTG", "ACB", "VIC", "KDH", "NLG", 
             "VNM", "MSN", "MWG", "HPG", "REE", "GMD", 
             "FPT", "CMG", "ELC", "TCM", "GIL", "KMR"),
  Ngành = c("Ngân hàng", "Ngân hàng", "Ngân hàng", 
            "Bất động sản", "Bất động sản", "Bất động sản",
            "Tiêu dùng & Bán lẻ", "Tiêu dùng & Bán lẻ", "Tiêu dùng & Bán lẻ",
            "Công nghiệp & Vật liệu", "Công nghiệp & Vật liệu", "Công nghiệp & Vật liệu",
            "Công nghệ & Viễn thông", "Công nghệ & Viễn thông", "Công nghệ & Viễn thông","Dệt may","Dệt may","Dệt may")
)

# BƯỚC 2: Gộp tất cả các kết quả đã tính vào một data frame duy nhất
# Sử dụng names(betas) làm nguồn ticker chính để đảm bảo chỉ lấy các mã đã được tính beta thành công
summary_df <- data.frame(
  Ticker = names(betas),
  "TSSL Kỳ vọng (Năm)" = expected_returns_annual[names(betas)],
  "Rủi ro (Độ lệch chuẩn - Năm)" = sqrt(diag(cov_matrix))[names(betas)],
  "Hệ số Beta" = betas,
  row.names = NULL # Đảm bảo không có lỗi về tên hàng
)

# Thêm thông tin ngành vào bảng
summary_df <- merge(summary_df, industry_map, by = "Ticker")

# Sắp xếp lại thứ tự các cột cho đẹp
summary_df <- summary_df[, c("Ticker", "Ngành", "TSSL.Kỳ.vọng..Năm.", "Rủi.ro..Độ.lệch.chuẩn...Năm.", "Hệ.số.Beta")]
# Đổi lại tên cột cho đẹp
colnames(summary_df) <- c("Mã CK", "Ngành", "TSSL Kỳ vọng (Năm)", "Rủi ro (ĐLC - Năm)", "Hệ số Beta")


# BƯỚC 3: Trình bày bảng kết quả
kable(summary_df, 
      digits = 4,
      row.names = FALSE, 
      caption = "Bảng 2.3: Đặc điểm Rủi ro và Lợi nhuận kỳ vọng của các Cổ phiếu Riêng lẻ")
Bảng 2.3: Đặc điểm Rủi ro và Lợi nhuận kỳ vọng của các Cổ phiếu Riêng lẻ
Mã CK Ngành TSSL Kỳ vọng (Năm) Rủi ro (ĐLC - Năm) Hệ số Beta
ACB Ngân hàng 0.0656 0.3026 0.5233
CMG Công nghệ & Viễn thông 0.0767 0.4050 0.8078
CTG Ngân hàng 0.0896 0.3439 1.1350
ELC Công nghệ & Viễn thông 0.0716 0.4197 0.6777
FPT Công nghệ & Viễn thông 0.0761 0.2495 0.7918
GIL Dệt may 0.0781 0.4199 0.8428
GMD Công nghiệp & Vật liệu 0.0772 0.3357 0.8186
HPG Công nghiệp & Vật liệu 0.0855 0.3299 1.0303
KDH Bất động sản 0.0773 0.2926 0.8230
KMR Dệt may 0.0669 0.4145 0.5583
MSN Tiêu dùng & Bán lẻ 0.0791 0.3372 0.8667
MWG Tiêu dùng & Bán lẻ 0.0837 0.3370 0.9853
NLG Bất động sản 0.0815 0.3425 0.9281
REE Công nghiệp & Vật liệu 0.0759 0.2922 0.7861
TCM Dệt may 0.0726 0.3743 0.7031
VCB Ngân hàng 0.0759 0.2876 0.7869
VIC Bất động sản 0.0733 0.2911 0.7192
VNM Tiêu dùng & Bán lẻ 0.0662 0.2625 0.5392

4 Bước 3 & 4: Tối ưu hóa và Trình bày Kết quả (Phương pháp Toàn diện)

## Bước 3 & 4: Tối ưu hóa và Trình bày Kết quả (Phiên bản Hoàn chỉnh)

# --- PHẦN 1: Chuẩn bị các tham số đầu vào ---
expected_returns_daily <- expected_returns_annual / 252
cov_matrix_daily <- cov(stock_returns)
library(Matrix)
## 
## Attaching package: 'Matrix'
## The following objects are masked from 'package:tidyr':
## 
##     expand, pack, unpack
cov_matrix_stable <- as.matrix(nearPD(cov_matrix_daily, corr = FALSE)$mat)

min_ret <- min(expected_returns_daily) + 1e-6
max_ret <- max(expected_returns_daily) - 1e-6
target_returns <- seq(min_ret, max_ret, length.out = 100)

# --- PHẦN 2: HÀM TỐI ƯU HÓA (CẢI TIẾN ĐỂ TRẢ VỀ TỶ TRỌNG) ---
find_optimal_portfolio <- function(target_return, expected_returns, cov_matrix) {
  n <- length(expected_returns)
  objective <- Q_objective(Q = 2 * cov_matrix, L = rep(0, n))
  constraints <- L_constraint(L = rbind(rep(1, n), expected_returns),
                              dir = c("==", "=="),
                              rhs = c(1, target_return))
  opt_problem <- OP(objective = objective, constraints = constraints,
                    bounds = V_bound(li = 1:n, lb = rep(0, n)))
  sol <- ROI_solve(opt_problem, solver = "quadprog")
  weights <- sol$solution
  names(weights) <- names(expected_returns)
  risk <- sqrt(sum(weights * (cov_matrix %*% weights)))
  
  # Trả về cả TSSL, Rủi ro và Tỷ trọng
  return(c(Return = target_return, Risk = risk, weights))
}

# --- PHẦN 3: CHẠY VÒNG LẶP ---
efficient_portfolios <- lapply(target_returns, function(tr) {
  tryCatch({
    find_optimal_portfolio(tr, expected_returns_daily, cov_matrix_stable)
  }, error = function(e) NULL)
})

frontier_df <- do.call(rbind, efficient_portfolios)
frontier_df <- as.data.frame(frontier_df) %>% filter(is.finite(Return) & is.finite(Risk))

# --- PHẦN 4: XÁC ĐỊNH CÁC DANH MỤC TIÊU BIỂU ---
# Thường niên hóa TSSL và Rủi ro
frontier_df$Return_annual <- frontier_df$Return * 252
frontier_df$Risk_annual <- frontier_df$Risk * sqrt(252)

# BƯỚC 1: Tính Tỷ lệ Sharpe cho TẤT CẢ các danh mục trên đường biên
frontier_df$Sharpe_Ratio <- (frontier_df$Return_annual - rf_annual) / frontier_df$Risk_annual

# BƯỚC 2: Bây giờ mới trích xuất các danh mục cụ thể
# Tìm Danh mục MVP (có Risk_annual nhỏ nhất)
mvp_portfolio <- frontier_df[which.min(frontier_df$Risk_annual), ]

# Tìm Danh mục có tỷ lệ Sharpe tối đa
max_sharpe_portfolio <- frontier_df[which.max(frontier_df$Sharpe_Ratio), ]

# --- PHẦN 5: TRÌNH BÀY BẢNG TỶ TRỌNG (PHIÊN BẢN SỬA LỖI CUỐI CÙNG) ---

# Hàm để định dạng bảng (Đã sửa lỗi)
# Hàm này sẽ tự động tìm các cột tỷ trọng thay vì dựa vào biến 'tickers' bên ngoài
format_portfolio_table <- function(portfolio, caption_text) {
  # Tự động xác định các cột không phải là tỷ trọng
  meta_cols <- c("Return", "Risk", "Return_annual", "Risk_annual", "Sharpe_Ratio")
  # Lấy các cột còn lại làm cột tỷ trọng
  ticker_cols <- setdiff(colnames(portfolio), meta_cols)
  
  # Chuyển đổi dòng dữ liệu tỷ trọng thành một vector có tên
  portfolio_weights_vector <- as.numeric(portfolio[, ticker_cols])
  names(portfolio_weights_vector) <- ticker_cols

  # Lọc và hiển thị các cổ phiếu có tỷ trọng > 0.01%
  display_weights <- portfolio_weights_vector[portfolio_weights_vector > 0.0001]
  
  df <- data.frame(
    "Cổ phiếu" = names(display_weights),
    "Tỷ trọng" = paste0(round(as.numeric(display_weights) * 100, 2), "%")
  )
  
  # Tạo bảng thông số
  stats <- data.frame(
    "Thuộc tính" = c("TSSL kỳ vọng (năm)", "Rủi ro - ĐLC (năm)", "Tỷ lệ Sharpe"),
    "Giá trị" = c(paste0(round(portfolio$Return_annual * 100, 2), "%"),
                  paste0(round(portfolio$Risk_annual * 100, 2), "%"),
                  round(portfolio$Sharpe_Ratio, 2))
  )
  
  print(kable(df, row.names = FALSE, caption = caption_text, align = 'lr'))
  print(kable(stats, row.names = FALSE, caption = "Thông số của Danh mục", align = 'lr'))
}

# Trình bày 2 bảng kết quả (cách gọi hàm đã thay đổi, không cần 'tickers')
format_portfolio_table(mvp_portfolio, "Bảng: Tỷ trọng Danh mục Rủi ro Tối thiểu (MVP)")
## 
## 
## Table: Bảng: Tỷ trọng Danh mục Rủi ro Tối thiểu (MVP)
## 
## |Cổ.phiếu | Tỷ.trọng|
## |:--------|--------:|
## |VCB      |    9.11%|
## |ACB      |   15.87%|
## |VIC      |   13.87%|
## |KDH      |    5.69%|
## |VNM      |    19.1%|
## |MSN      |     3.3%|
## |REE      |    5.61%|
## |GMD      |    1.03%|
## |FPT      |    7.31%|
## |CMG      |    3.39%|
## |ELC      |    5.34%|
## |TCM      |    3.61%|
## |GIL      |    1.68%|
## |KMR      |    5.08%|
## 
## 
## Table: Thông số của Danh mục
## 
## |Thuộc.tính         | Giá.trị|
## |:------------------|-------:|
## |TSSL kỳ vọng (năm) |   7.15%|
## |Rủi ro - ĐLC (năm) |  16.35%|
## |Tỷ lệ Sharpe       |    0.16|
format_portfolio_table(max_sharpe_portfolio, "Bảng: Tỷ trọng Danh mục có Tỷ lệ Sharpe Tối đa")
## 
## 
## Table: Bảng: Tỷ trọng Danh mục có Tỷ lệ Sharpe Tối đa
## 
## |Cổ.phiếu | Tỷ.trọng|
## |:--------|--------:|
## |VCB      |    6.93%|
## |CTG      |   10.63%|
## |ACB      |    5.23%|
## |VIC      |   12.35%|
## |KDH      |    7.29%|
## |NLG      |    5.41%|
## |VNM      |    5.22%|
## |MSN      |    7.43%|
## |MWG      |    6.28%|
## |HPG      |     8.2%|
## |REE      |     3.6%|
## |GMD      |    2.81%|
## |FPT      |     7.1%|
## |CMG      |    4.75%|
## |ELC      |    2.79%|
## |TCM      |    0.35%|
## |GIL      |    3.57%|
## |KMR      |    0.05%|
## 
## 
## Table: Thông số của Danh mục
## 
## |Thuộc.tính         | Giá.trị|
## |:------------------|-------:|
## |TSSL kỳ vọng (năm) |   7.79%|
## |Rủi ro - ĐLC (năm) |  18.61%|
## |Tỷ lệ Sharpe       |    0.18|
# --- PHẦN 6: VẼ ĐỒ THỊ HOÀN CHỈNH ---
# (Phần này giữ nguyên như cũ, không cần thay đổi)
plot(frontier_df$Risk_annual, frontier_df$Return_annual, type = "l", col = "blue", lwd = 2,
     xlim = c(0, max(sqrt(diag(cov_matrix))) * 1.1), ylim = c(0, max(expected_returns_annual) * 1.1),
     xlab = "Rủi ro (Độ lệch chuẩn hàng năm)", ylab = "TSSL kỳ vọng (hàng năm)",
     main = "Đường biên hiệu quả và các Danh mục Tối ưu")
points(sqrt(diag(cov_matrix)), expected_returns_annual, pch = 19, col = "grey")
text(sqrt(diag(cov_matrix)), expected_returns_annual, labels = tickers, cex = 0.7, pos = 4, col = "grey")
points(mvp_portfolio$Risk_annual, mvp_portfolio$Return_annual, pch = 19, col = "green", cex = 1.5)
text(mvp_portfolio$Risk_annual, mvp_portfolio$Return_annual, "MVP", pos = 2, col = "green")
points(max_sharpe_portfolio$Risk_annual, max_sharpe_portfolio$Return_annual, pch = 19, col = "red", cex = 1.5)
text(max_sharpe_portfolio$Risk_annual, max_sharpe_portfolio$Return_annual, "Max Sharpe", pos = 2, col = "red")
legend("bottomright", legend = c("Đường biên hiệu quả", "Cổ phiếu riêng lẻ", "Danh mục MVP", "Danh mục Sharpe tối đa"),
       col = c("blue", "grey", "green", "red"), lty = c(1, NA, NA, NA), pch = c(NA, 19, 19, 19), cex = 0.8)

Diễn giải: Đường cong màu xanh lá cây là Đường biên hiệu quả. Mọi danh mục nằm trên đường này là tối ưu. Các điểm màu xanh dương là vị trí của từng cổ phiếu riêng lẻ. Có thể thấy, bằng cách kết hợp chúng, chúng ta có thể tạo ra các danh mục có rủi ro thấp hơn và/hoặc lợi nhuận cao hơn so với việc chỉ đầu tư vào một cổ phiếu.

5 Kết luận

Dựa trên mô hình CAPM và lý thuyết tối ưu hóa Markowitz, chúng tôi đã xây dựng thành công đường biên hiệu quả cho danh mục 15 cổ phiếu ngành Ngân hàng. Danh mục có rủi ro thấp nhất (MVP) đã được xác định với tỷ trọng cụ thể cho từng tài sản

library(ggplot2)
library(ggrepel)
## Warning: package 'ggrepel' was built under R version 4.3.3
# Data điểm từng cổ phiếu (ĐÃ SỬA LỖI)
df_points <- data.frame(
  Risk = sqrt(diag(cov_matrix)),
  Return = as.numeric(expected_returns_annual[names(betas)]),
  Ticker = names(betas)
)

# Đường biên hiệu quả
df_frontier <- data.frame(
  Risk = frontier_df$Risk_annual,
  Return = frontier_df$Return_annual
)

# MVP & Max Sharpe
df_special <- data.frame(
  Risk = c(mvp_portfolio$Risk_annual, max_sharpe_portfolio$Risk_annual),
  Return = c(mvp_portfolio$Return_annual, max_sharpe_portfolio$Return_annual),
  Label = c("MVP", "Max Sharpe")
)

# VẼ ĐỒ THỊ
ggplot() +
  geom_line(data = df_frontier,
            aes(x = Risk, y = Return),
            color = "#F39C12", linewidth = 1.5) +
  
  geom_point(data = df_points,
             aes(Risk, Return),
             size = 3,
             color = "#8E44AD") +
  
  geom_text_repel(data = df_points,
                  aes(Risk, Return, label = Ticker),
                  size = 4,
                  color = "#5B2C6F",
                  force = 2,
                  max.overlaps = Inf,
                  min.segment.length = 0) +
  
  geom_point(data = df_special,
             aes(Risk, Return, color = Label),
             size = 5) +
  
  geom_text_repel(data = df_special,
                  aes(Risk, Return, label = Label, color = Label),
                  size = 5,
                  fontface = "bold",
                  box.padding = 0.4,
                  point.padding = 0.6,
                  max.overlaps = Inf) +
  
  scale_color_manual(values = c(
    "MVP" = "forestgreen",
    "Max Sharpe" = "red3"
  )) +
  
  labs(
    title = "ĐƯỜNG BIÊN HIỆU QUẢ & DANH MỤC TỐI ƯU",
    x = "Rủi ro (Độ lệch chuẩn - Năm)",
    y = "Tỷ suất sinh lợi kỳ vọng (Năm)"
  ) +
  
  theme_minimal(base_size = 15) +
  theme(
    plot.title = element_text(hjust = 0.5,
                              color = "darkblue",
                              face = "bold",
                              size = 18),
    legend.title = element_blank(),
    legend.position = "right",
    panel.grid.minor = element_blank()
  )