## ===========================================
## 0) PACKAGES, THEME, SEED
## ===========================================
# Blok suppressPackageStartupMessages untuk menekan pesan loading package
suppressPackageStartupMessages({
# Mendefinisikan vektor berisi nama-nama package yang dibutuhkan
pkgs <- c(
"quantmod", # Untuk download data finansial (getSymbols)
"dplyr", # Manipulasi data (filter, mutate, select, dll)
"tidyr", # Mengatur struktur data (pivot, gather, dll)
"moments", # Menghitung skewness dan kurtosis (untuk VaR Ekspansi Cornish-Fisher)
"ggplot2", # Visualisasi data (scatter plot, line chart, dll)
"ggrepel", # Label yang tidak overlap pada plot
"plotly", # Visualisasi interaktif
"heatmaply", # Heatmap interaktif (untuk correlation matrix)
"gt", # Membuat tabel yang cantik untuk laporan
"scales", # Format angka dan skala untuk visualisasi
"viridis", # Palet warna untuk visualisasi
"RColorBrewer", # Palet warna untuk visualisasi
"zoo", # Time series object dan fungsi
"xts", # Extensible time series (digunakan quantmod)
"stats", # Fungsi statistik dasar (cov, cor, dll)
"openxlsx", # Export hasil ke excel
"DT" # Interactive DataTable untuk R Markdown
)
# Mengecek package mana yang belum terinstall
# sapply: apply function ke setiap elemen pkgs
# requireNamespace: cek apakah package tersedia (TRUE/FALSE)
# !sapply: negasi, ambil yang FALSE (belum terinstall)
inst <- pkgs[!sapply(pkgs, requireNamespace, quietly = TRUE)]
# Jika ada package yang belum terinstall, install otomatis
if (length(inst)) install.packages(inst)
# Load semua package ke R session
# invisible: suppress output dari lapply
# lapply: apply library() ke setiap package
invisible(lapply(pkgs, library, character.only = TRUE))
})
## Warning: package 'quantmod' was built under R version 4.5.1
## Warning: package 'plotly' was built under R version 4.5.1
## Warning: package 'heatmaply' was built under R version 4.5.1
## Warning: package 'gt' was built under R version 4.5.1
# Set seed untuk reproducibility
# Semua proses random akan menghasilkan hasil yang sama setiap kali dijalankan
set.seed(42)
# Set theme default untuk semua plot ggplot2
# theme_minimal: theme bersih tanpa grid background berlebihan
# base_size = 12: ukuran font dasar 12pt
theme_set(theme_minimal(base_size = 12))
## =============================================
## 1) KONFIGURASI DATA & PARAMETER GLOBAL
## =============================================
## ---------------------------------------------
## A. PARAMETER DATA & PERIODE
## ---------------------------------------------
# Jumlah hari perdagangan dalam setahun untuk annualisasi
trading_days <- 252
# Daftar 17 saham yang akan dianalisis (Drop saham PGEO karena banyak missing data)
# Berasal dari indeks SMInfra18 evaluasi mayor April 2025
tickers <- c("AKRA","BBNI","BBRI","BMRI","EXCL","INTP",
"ISAT","JSMR","MEDC","MTEL","PGAS",
"PTPP","SMGR","SSIA","TLKM","TOWR","UNTR")
# Menambahkan suffix ".JK" untuk Jakarta Stock Exchange
# Diperlukan untuk download data dari Yahoo Finance via quantmod
yahoo_tickers <- paste0(tickers, ".JK")
# Periode data historis untuk analisis
from_date <- as.Date("2022-01-01") # Tanggal mulai: 1 Januari 2022
to_date <- as.Date("2025-09-30") # Tanggal akhir: 30 September 2025
## ---------------------------------------------
## B. CONSTRAINT PORTOFOLIO
## ---------------------------------------------
# CONSTRAINT 1: Batas bobot individual
alpha_w <- 0.10 # Bobot minimum: 10% (jika saham masuk portofolio)
beta_w <- 0.40 # Bobot maksimum: 40% (mencegah konsentrasi berlebihan)
# wi = 0 atau α ≤ wi ≤ β
# CONSTRAINT 2: Cardinality constraint
d_min <- 3 # Minimum 3 saham dalam portofolio (diversifikasi minimum)
d_max <- 5 # Maksimum 5 saham dalam portofolio (tidak terlalu kompleks)
# dmin ≤ dnz ≤ dmax, dimana dnz = jumlah bobot non-zero
# CONSTRAINT 3: Must-have asset
must_have_name <- "TLKM" # TLKM wajib ada di setiap portofolio
# IMPLICIT CONSTRAINT:
# Σwi = 1 (total bobot = 100%)
# wi ≥ 0 (no short selling)
## ---------------------------------------------
## C. PARAMETER ALGORITMA MOCv-ABC
## ---------------------------------------------
# PARAMETER 1: Scout Bee Limit
limit_factor <- 10 # Multiplier untuk dimensi
# limit = limit_factor * d
# Jika solusi tidak improve dalam 'limit' iterasi,
# solusi akan di-reinisialisasi (Scout Bee Phase)
# PARAMETER 2: Sigma untuk Onlooker Bee
sigma_abc <- 0.5 # Scaling factor untuk covariance-guided exploration
# Zi = m + σBDr
# Nilai lebih besar = eksplorasi lebih luas
# PARAMETER 3: Maximum Iterations
max_iter <- 200 # Jumlah maksimum iterasi
# PARAMETER 4: Debug flag
debug_opt <- TRUE # TRUE = print progress setiap iterasi
# FALSE = hanya hasil akhir
# Berguna untuk monitoring konvergensi
## ---------------------------------------------
## D. PARAMETER EVALUASI KINERJA PORTOFOLIO
## ---------------------------------------------
# PARAMETER 1: Risk-Free Rate untuk Sharpe Ratio
rf <- 0.0475 # 4.75% per tahun (BI Rate)
# Sharpe Ratio = (Rp - Rf) / σp
# Digunakan untuk memilih portofolio terbaik
# PARAMETER 2: VaR Cornish-Fisher
cl <- 0.95 # Confidence Level = 95%
# Artinya kita 95% yakin kerugian yang dialami tidak akan melebihi VaR
alpha <- 1 - cl # Alpha = 5% (tail risk yang diukur)
V0 <- 1 # Nilai investasi awal
hp <- 1 # Holding period = 1 hari
## =============================================
## 2) AMBIL DATA SAHAM & BENTUK PANEL
## =============================================
## -------------------------------------------
## A. DOWNLOAD DATA HARGA SAHAM
## -------------------------------------------
# Loop setiap ticker untuk download data dari Yahoo Finance
prices_list <- lapply(yahoo_tickers, function(sym) {
# suppressWarnings: menekan warning dari getSymbols (misal: data tidak lengkap)
# getSymbols: fungsi dari quantmod untuk download data OHLCV
# - sym: ticker symbol (misal: "AKRA.JK")
# - from/to: periode data
# Output: xts object dengan kolom OHLCV (Open, High, Low, Close, Volume, Adjusted)
suppressWarnings(
getSymbols(sym, from = from_date, to = to_date, auto.assign = FALSE)
)
})
## -------------------------------------------
## B. EKSTRAKSI ADJUSTED CLOSE PRICE
## -------------------------------------------
# Ad() adalah fungsi dari quantmod untuk extract kolom "Adjusted" price
# Adjusted price sudah memperhitungkan stock split dan dividend
adj_list <- lapply(prices_list, Ad)
## -------------------------------------------
## C. MERGE SEMUA SAHAM MENJADI 1 XTS OBJECT
## -------------------------------------------
# do.call(merge, list): gabungkan semua xts dalam list menjadi 1 xts
# Hasilnya xts dengan 18 kolom (1 untuk setiap saham)
prices_xts <- do.call(merge, adj_list)
# Ganti nama kolom
colnames(prices_xts) <- tickers
# na.omit: buang baris yang ada missing value (NA)
prices_xts <- na.omit(prices_xts)
## -------------------------------------------
## D. HITUNG LOG-RETURN HARIAN
## -------------------------------------------
# Log-return: r_t = ln(P_t / P_{t-1}) = ln(P_t) - ln(P_{t-1})
rets_xts <- na.omit(diff(log(prices_xts)))
## -------------------------------------------
## E. DATA QUALITY CHECK: HAPUS SAHAM TANPA DATA
## -------------------------------------------
# Cek apakah ada kolom (saham) yang semua nilai-nya NA
# Bisa terjadi jika data download gagal atau saham delisting
na_all <- sapply(rets_xts, function(x) all(is.na(x)))
# Jika ada saham dengan all-NA, hapus dari dataset
if (any(na_all)) {
keep <- which(!na_all) # Index kolom yang valid
rets_xts <- rets_xts[, keep, drop = FALSE] # Keep kolom valid
tickers <- tickers[keep] # Update daftar ticker
# Print pesan notifikasi
message("Menghapus aset tanpa data lengkap. Sisa aset:", length(tickers), " -> ", paste(tickers, collapse = ", "))
}
## -------------------------------------------
## F. TRANSFORMASI KE LONG FORMAT (untuk EDA & Plotting)
## -------------------------------------------
# 📊 CONVERT RETURNS KE LONG FORMAT
# Wide format (xts): date | AKRA | BBNI | BBRI | ...
# Long format (df): date | ticker | ret
rets_df <- rets_xts |>
as.data.frame() |> # xts -> data.frame
tibble::rownames_to_column("date") |> # Date jadi kolom (dari rownames)
mutate(date = as.Date(date)) |> # Convert ke Date object
pivot_longer( # Wide -> Long transformation
-date, # Keep kolom date
names_to = "ticker", # Nama kolom -> "ticker"
values_to = "ret" # Value -> "ret"
)
# Hasil: tibble dengan kolom: date | ticker | ret
# Total rows = n_dates × n_tickers
# 📊 CONVERT PRICES KE LONG FORMAT (analog dengan returns)
prices_df <- prices_xts |>
as.data.frame() |>
tibble::rownames_to_column("date") |>
mutate(date = as.Date(date)) |>
pivot_longer(-date, names_to = "ticker", values_to = "price")
# Hasil: tibble dengan kolom: date | ticker | price
## -------------------------------------------
## G. EXPORT DATA KE EXCEL (untuk Dokumentasi & Backup)
## -------------------------------------------
# Nama file output
output_file_prices_returns <- "data_harga_dan_return.xlsx"
# Buat workbook Excel baru (objek untuk menulis Excel)
wb <- createWorkbook()
## ======================
## 📋 SHEET 1: HARGA SAHAM (Wide Format)
## ======================
# Tambah worksheet bernama "Harga_Saham"
addWorksheet(wb, "Harga_Saham")
# Transform ke wide format untuk Excel (lebih mudah dibaca)
# Long: date | ticker | price → Wide: date | AKRA | BBNI | BBRI | ...
prices_export <- prices_df |>
tidyr::pivot_wider(
names_from = ticker, # Kolom ticker jadi nama kolom baru
values_from = price # Value price jadi isi cell
) |>
dplyr::arrange(date) # Sort berdasarkan tanggal
# Tulis data ke Excel dengan format table
writeDataTable(
wb,
sheet = "Harga_Saham", # Nama sheet
x = prices_export, # Data yang ditulis
tableStyle = "TableStyleLight9", # Style Excel table (auto-filter, stripes)
withFilter = TRUE # Enable filter di header
)
## ======================
## 📋 SHEET 2: RETURN SAHAM (Wide Format)
## ======================
addWorksheet(wb, "Return_Saham")
# Transform return ke wide format
rets_export <- rets_df |>
tidyr::pivot_wider(names_from = ticker, values_from = ret) |>
dplyr::arrange(date)
# Tulis data return ke Excel
writeDataTable(
wb,
sheet = "Return_Saham",
x = rets_export,
tableStyle = "TableStyleLight9",
withFilter = TRUE
)
## ======================
## 💾 SIMPAN WORKBOOK KE FILE
## ======================
saveWorkbook(wb, file = output_file_prices_returns, overwrite = TRUE)
# Print konfirmasi dengan full path
cat("✅ File Excel berhasil disimpan ke:\n",
normalizePath(output_file_prices_returns), "\n")
## ✅ File Excel berhasil disimpan ke:
## D:\KULIAH\BISMILLAH SKRIPSI\data_harga_dan_return.xlsx
## ======================
## 👁️ PREVIEW DATA (Opsional)
## ======================
# Tampilkan 5 baris pertama dari masing-masing dataset
# Untuk verifikasi bahwa data sudah benar
print(head(prices_export, 5)) # Preview harga
## # A tibble: 5 × 18
## date AKRA BBNI BBRI BMRI EXCL INTP ISAT JSMR MEDC MTEL PGAS
## <date> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 2022-01-03 656. 2772. 3285. 2767. 2706. 10293. 1403. 3720. 389. 728. 961.
## 2 2022-01-04 632. 2896. 3269. 2816. 2689. 10095. 1365. 3682. 395. 728. 961.
## 3 2022-01-05 619. 2865. 3308. 2757. 2646. 9941. 1343. 3645. 407. 728. 937.
## 4 2022-01-06 626. 2865. 3269. 2757. 2543. 9809. 1376. 3636. 411. 724. 927.
## 5 2022-01-07 645. 2917. 3293. 2767. 2586. 9633. 1376. 3692. 414. 724. 930.
## # ℹ 6 more variables: PTPP <dbl>, SMGR <dbl>, SSIA <dbl>, TLKM <dbl>,
## # TOWR <dbl>, UNTR <dbl>
print(head(rets_export, 5)) # Preview return
## # A tibble: 5 × 18
## date AKRA BBNI BBRI BMRI EXCL INTP ISAT
## <date> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 2022-01-04 -0.0374 0.0436 -0.00480 0.0176 -0.00635 -0.0194 -0.0276
## 2 2022-01-05 -0.0192 -0.0107 0.0119 -0.0211 -0.0161 -0.0154 -0.0161
## 3 2022-01-06 0.00966 0 -0.0119 0 -0.0396 -0.0134 0.0241
## 4 2022-01-07 0.0308 0.0178 0.00719 0.00355 0.0167 -0.0181 0
## 5 2022-01-10 0.00233 -0.00354 -0.00239 0 -0.00664 -0.00229 -0.0160
## # ℹ 10 more variables: JSMR <dbl>, MEDC <dbl>, MTEL <dbl>, PGAS <dbl>,
## # PTPP <dbl>, SMGR <dbl>, SSIA <dbl>, TLKM <dbl>, TOWR <dbl>, UNTR <dbl>
# Cek periode data yang benar-benar tersedia
actual_start <- start(prices_xts)
actual_end <- end(prices_xts)
n_obs <- nrow(prices_xts)
cat("\n📅 RINGKASAN DATA HARGA:\n")
##
## 📅 RINGKASAN DATA HARGA:
cat(" Periode diminta : ", from_date, "s.d.", to_date, "\n")
## Periode diminta : 18993 s.d. 20361
cat(" Periode tersedia: ", actual_start, "s.d.", actual_end, "\n")
## Periode tersedia: 18995 s.d. 20360
cat(" Jumlah observasi: ", n_obs, "hari\n")
## Jumlah observasi: 894 hari
cat(" Jumlah saham : ", ncol(prices_xts), "saham\n")
## Jumlah saham : 17 saham
cat(" Missing data : ", sum(is.na(prices_xts)), "cells\n\n")
## Missing data : 0 cells
# Cek periode data yang benar-benar tersedia
rets_start <- start(rets_xts)
rets_end <- end(rets_xts)
rets_obs <- nrow(rets_xts)
cat("\n📅 RINGKASAN DATA RETURN:\n")
##
## 📅 RINGKASAN DATA RETURN:
cat(" Periode diminta : ", from_date, "s.d.", to_date, "\n")
## Periode diminta : 18993 s.d. 20361
cat(" Periode tersedia: ", rets_start, "s.d.", rets_end, "\n")
## Periode tersedia: 18996 s.d. 20360
cat(" Jumlah observasi: ", rets_obs, "hari\n")
## Jumlah observasi: 893 hari
cat(" Jumlah saham : ", ncol(rets_xts), "saham\n")
## Jumlah saham : 17 saham
cat(" Missing data : ", sum(is.na(rets_xts)), "cells\n\n")
## Missing data : 0 cells
## =======================================================
## 3) EDA INTERAKTIF (Harga, Distribusi, Deskriptif, Korelasi, Rolling Vol)
## =======================================================
## -------------------------------------------
## Helper Function: Membagi Saham ke Dalam Grup Visualisasi
## -------------------------------------------
# Fungsi pembantu untuk membagi daftar ticker saham menjadi grup-grup kecil
# Tujuannya agar visualisasi tidak terlalu padat dan tetap mudah dibaca
make_groups <- function(x, size = 9) {
# x : vektor nama ticker saham (misal: c("AKRA", "BBNI", ...))
# size : jumlah maksimal saham per grup (default 9)
# Membuat indeks grup untuk setiap ticker:
# - seq_len(ceiling(length(x) / size)): membuat urutan grup (1, 2, 3, ...)
# - each = size: setiap grup diulang sebanyak 'size' kali
# - length.out = length(x): memastikan panjang indeks sama dengan jumlah ticker
group_indices <- rep(seq_len(ceiling(length(x) / size)), each = size, length.out = length(x))
# split(x, group_indices): membagi vektor ticker menjadi list per grup
split(x, group_indices)
}
# Membagi ticker menjadi grup-grup berisi maksimal 6 saham
groups <- make_groups(tickers, size = 6)
## -------------------------------------------
## 3a). Visualisasi Harga Saham
## -------------------------------------------
# Fungsi utama untuk membuat plot harga saham interaktif per grup
plot_prices_by_group <- function(g_ids = 1:length(groups)) {
# g_ids: vektor indeks grup yang ingin divisualisasikan (default: semua grup)
# Inisialisasi list kosong untuk menyimpan plot hasil
pls <- vector("list", length(g_ids))
# Loop untuk setiap grup yang dipilih
for (ii in seq_along(g_ids)) {
# Mengambil daftar ticker untuk grup ke-ii
g <- groups[[ g_ids[ii] ]]
# Filter data harga hanya untuk ticker dalam grup ini
df_sub <- prices_df |> dplyr::filter(ticker %in% g)
# Buat palet viridis sebanyak jumlah ticker di grup
pal <- viridis(length(g), option = "D")
names(pal) <- g
# Membuat plot time series harga saham dengan ggplot2
p <- ggplot(df_sub, aes(date, price, color = ticker)) +
# Garis utama harga saham per ticker
geom_line(linewidth = 0.6, alpha = 0.9, show.legend = FALSE) +
# Membagi plot menjadi panel per ticker (facet)
facet_wrap(~ ticker, ncol = 3, scales = "free_y") +
# Menambahkan garis trend untuk tiap saham
geom_smooth(method = "loess", se = FALSE, linewidth = 0.2, color = "black") +
scale_color_manual(values = pal) +
# Judul dan label sumbu
labs(title = "Time Series Harga Saham", x = NULL, y = "Harga") +
# Kustomisasi tema:
theme(axis.text.y = element_text(size = 7),
axis.text.x = element_text(angle = 45, hjust = 1, size = 8),
plot.title = element_text(size = 16, face = "bold", hjust = 0.5))
# Mengubah plot ggplot2 menjadi plotly
# Fitur: hover, zoom, pan, export, dsb
pls[[ii]] <- plotly::ggplotly(p)
}
# Mengembalikan list plot interaktif untuk setiap grup
pls
}
# Membuat plot interaktif untuk grup 1-3
price_plots <- plot_prices_by_group(1:3)
## `geom_smooth()` using formula = 'y ~ x'
## `geom_smooth()` using formula = 'y ~ x'
## `geom_smooth()` using formula = 'y ~ x'
# Menampilkan plot untuk grup pertama
price_plots[[1]]
# Menampilkan plot untuk grup kedua
price_plots[[2]]
price_plots[[3]]
## -------------------------------------------
## 3b). Visualisasi Distribusi Return
## -------------------------------------------
# Fungsi untuk membuat plot distribusi return (density) per grup saham
plot_density_by_group <- function(g_ids = 1:length(groups)) {
pls <- vector("list", length(g_ids)) # list kosong untuk plot per grup
# Hitung statistik deskriptif per ticker (mean, sd, quantile)
summ_by_tkr_all <- rets_df |>
group_by(ticker) |>
summarise(
mean_d = mean(ret, na.rm = TRUE), # Rata-rata return harian
sd_d = sd(ret, na.rm = TRUE), # Standar deviasi return harian
x_min = quantile(ret, 0.005, na.rm = TRUE), # Batas bawah (hindari outlier ekstrem)
x_max = quantile(ret, 0.995, na.rm = TRUE), # Batas atas
.groups = "drop"
)
# Buat data kurva normal teoritis per ticker (untuk overlay)
norm_df_all <- summ_by_tkr_all |>
rowwise() |>
mutate(x = list(seq(x_min, x_max, length.out = 400)), # Titik x untuk kurva normal
y = list(dnorm(x, mean = mean_d, sd = sd_d)) # Density normal
) |>
tidyr::unnest(c(x, y)) |> ungroup()
for (ii in seq_along(g_ids)) {
tick_sub <- groups[[ g_ids[ii] ]] # Ticker dalam grup ke-ii
rets_sub <- rets_df |> filter(ticker %in% tick_sub) # Data return grup ini
summ_sub <- summ_by_tkr_all |> filter(ticker %in% tick_sub) # Statistik grup
norm_sub <- norm_df_all |> filter(ticker %in% tick_sub) # Kurva normal grup
# Buat palet viridis
pal <- viridis(length(tick_sub), option = "D")
names(pal) <- tick_sub
# Plot density empiris dan overlay kurva normal
p <- ggplot() +
geom_density(data = rets_sub, aes(ret, after_stat(density), color = ticker),
linewidth = 0.8, alpha = 0.9, show.legend = FALSE) + # Density empiris
geom_line(data = norm_sub, aes(x, y), color = "black", linewidth = 0.4) + # Kurva normal
geom_vline(xintercept = 0, color = "grey40", linewidth = 0.4) + # Garis di nol
geom_vline(data = summ_sub, aes(xintercept = mean_d),
color = "black", linewidth = 0.5, linetype = "dashed") + # Garis mean
facet_wrap(~ ticker, ncol = 3, scales = "free_y") +
scale_color_manual(values = pal) +
labs(title = "Distribusi Return Harian", x = "Return", y = "Kepadatan") +
theme(plot.title = element_text(size = 16, face = "bold", hjust = 0.5),
strip.text = element_text(face = "bold"),
panel.grid.minor = element_blank(),
legend.position = "none")
pls[[ii]] <- plotly::ggplotly(p)
}
pls
}
density_plots <- plot_density_by_group(1:3)
density_plots[[1]]
density_plots[[2]]
density_plots[[3]]
## -------------------------------------------
## 3c). Statistik Deskriptif & Uji Normalitas
## -------------------------------------------
# -----------------------------------------------------------------------------
# Fungsi custom untuk uji normalitas Jarque-Bera pada vektor return
jb_test <- function(x) {
x <- x[is.finite(x)] # Hapus nilai NA, Inf, -Inf agar perhitungan valid
n <- length(x) # Hitung jumlah data setelah pembersihan
if (n < 8) return(NA_real_) # Jika data kurang dari 8, return NA (JB tidak valid)
s <- moments::skewness(x) # Hitung skewness
k <- moments::kurtosis(x) # Hitung kurtosis
JB <- n/6 * (s^2 + ((k-3)^2 / 4)) # Hitung statistik JB sesuai rumus standar
1 - pchisq(JB, df = 2) # Hitung p-value dari distribusi chi-square (df = 2)
}
# -----------------------------------------------------------------------------
# Hitung statistik deskriptif per saham
stats_df <- rets_df |>
group_by(ticker) |>
summarise(
mean_d = mean(ret, na.rm = TRUE), # Rata-rata return harian
var_d = var(ret, na.rm = TRUE), # Variansi harian
sd_d = sd(ret, na.rm = TRUE), # Standar deviasi harian (volatilitas)
skew = moments::skewness(ret, na.rm = TRUE), # Skewness harian
kurt = moments::kurtosis(ret, na.rm = TRUE), # Kurtosis harian
jb_p = jb_test(ret), # p-value uji normalitas JB
.groups = "drop" # Drop grouping untuk operasi selanjutnya
) |>
mutate(
mean_ann = mean_d * trading_days, # Annualisasi mean
sd_ann = sd_d * sqrt(trading_days), # Annualisasi SD
var_ann = sd_ann^2, # Varians tahunan
rank_mean = rank(-mean_ann, ties.method = "min"), # Ranking return (semakin tinggi semakin baik)
rank_sd = rank(sd_ann, ties.method = "min") # Ranking risiko (semakin rendah semakin baik)
) |>
arrange(rank_sd, rank_mean) # Urutkan dari risk terendah, lalu return tinggi
# -----------------------------------------------------------------------------
# Tampilkan hasil statistik deskriptif
stats_gt <- stats_df |>
select(ticker, mean_ann, sd_ann, skew, kurt, jb_p, rank_mean, rank_sd) |> # Pilih kolom penting
gt(rowname_col = "ticker") |> # Ticker sebagai rowname
fmt_number(columns = c(mean_ann, sd_ann), decimals = 4) |> # Format mean & SD 4 desimal
fmt_number(columns = c(skew, kurt), decimals = 2) |> # Format skew & kurt 2 desimal
fmt_number(columns = c(jb_p, rank_mean, rank_sd), decimals = 2) |> # Format p-value & ranking 2 desimal
tab_header(title = md("**Statistik Deskriptif Return**")) |> # Judul tabel
cols_label(mean_ann = "Mean (ann.)", sd_ann="SD (ann.)", skew="Skew", kurt="Kurt",
jb_p="JB p-val", rank_mean="Rank Mean", rank_sd="Rank SD") |> # Label kolom
data_color(columns = c(rank_sd), # color coding untuk risk ranking
colors = scales::col_numeric(c("#2DC937","#E7B416","#CC3232"), domain = NULL)) |>
tab_source_note(md("Warna hijau = lebih baik (SD rendah). JB p-val kecil → indikasi non-normal."))
## Warning: Since gt v0.9.0, the `colors` argument has been deprecated.
## • Please use the `fn` argument instead.
## This warning is displayed once every 8 hours.
stats_gt
| Statistik Deskriptif Return |
|
Mean (ann.) |
SD (ann.) |
Skew |
Kurt |
JB p-val |
Rank Mean |
Rank SD |
| TLKM |
−0.0263 |
0.2756 |
−0.08 |
4.65 |
0.00 |
12.00 |
1.00 |
| MTEL |
−0.0641 |
0.2778 |
−0.32 |
10.13 |
0.00 |
13.00 |
2.00 |
| BBRI |
0.0542 |
0.2871 |
−0.03 |
6.15 |
0.00 |
9.00 |
3.00 |
| BBNI |
0.1152 |
0.2986 |
0.09 |
5.28 |
0.00 |
7.00 |
4.00 |
| BMRI |
0.1309 |
0.2995 |
−0.13 |
6.41 |
0.00 |
6.00 |
5.00 |
| JSMR |
−0.0124 |
0.3076 |
0.37 |
4.90 |
0.00 |
10.00 |
6.00 |
| PGAS |
0.1601 |
0.3080 |
0.31 |
5.80 |
0.00 |
5.00 |
7.00 |
| UNTR |
0.1901 |
0.3281 |
−0.08 |
11.02 |
0.00 |
3.00 |
8.00 |
| INTP |
−0.1212 |
0.3294 |
0.18 |
7.85 |
0.00 |
14.00 |
9.00 |
| EXCL |
−0.0157 |
0.3368 |
0.32 |
5.91 |
0.00 |
11.00 |
10.00 |
| TOWR |
−0.1568 |
0.3484 |
0.75 |
8.35 |
0.00 |
15.00 |
11.00 |
| SMGR |
−0.2255 |
0.3744 |
0.16 |
8.30 |
0.00 |
16.00 |
12.00 |
| AKRA |
0.1741 |
0.4009 |
0.54 |
9.01 |
0.00 |
4.00 |
13.00 |
| ISAT |
0.0672 |
0.4188 |
−0.11 |
8.89 |
0.00 |
8.00 |
14.00 |
| MEDC |
0.3524 |
0.4963 |
0.63 |
6.08 |
0.00 |
2.00 |
15.00 |
| PTPP |
−0.2600 |
0.5159 |
1.50 |
11.56 |
0.00 |
17.00 |
16.00 |
| SSIA |
0.3688 |
0.5521 |
0.93 |
10.45 |
0.00 |
1.00 |
17.00 |
| Warna hijau = lebih baik (SD rendah). JB p-val kecil → indikasi non-normal. |
## 3c.1) Simpan Expected Return & Risiko (SD) tiap saham ke Excel
# Pilih kolom ticker, mean_ann (expected return tahunan), dan sd_ann (risiko tahunan) dari stats_df
risk_return_df <- stats_df |>
dplyr::select(ticker, mean_ann, sd_ann) |>
dplyr::rename(Expected_Return = mean_ann, Risk_SD = sd_ann)
# - stats_df: Data frame berisi statistik deskriptif tiap saham
# - select(): Ambil hanya kolom yang dibutuhkan
# - rename(): Ubah nama kolom
# Tentukan nama file output Excel
output_file <- "saham_risk_return.xlsx"
# Ekspor data risk-return ke file Excel
openxlsx::write.xlsx(risk_return_df, file = output_file,
sheetName = "RiskReturn", overwrite = TRUE)
# - openxlsx::write.xlsx: Fungsi ekspor ke Excel
# - overwrite = TRUE: File lama akan ditimpa jika sudah ada
# Tampilkan konfirmasi lokasi file yang berhasil disimpan
cat("✅ File disimpan ke:", normalizePath(output_file), "\n")
## ✅ File disimpan ke: D:\KULIAH\BISMILLAH SKRIPSI\saham_risk_return.xlsx
## 3c.2) Top-5 Expected Return & Top-5 Risiko Terendah
# Ambil 5 saham dengan expected return tahunan tertinggi
top5_mean_ann <- stats_df |>
slice_max(order_by = mean_ann, n = 5) |>
arrange(mean_ann)
# - slice_max(): Pilih 5 baris dengan mean_ann terbesar
# - arrange(): Urutkan dari return terendah ke tertinggi
# Buat bar chart horizontal untuk top-5 expected return
p_top_mean_ann <- ggplot(top5_mean_ann, aes(reorder(ticker, mean_ann), mean_ann)) +
geom_col(fill = "#2E86DE") + coord_flip() +
geom_text(aes(label = round(mean_ann, 3)),
hjust = -0.1, size = 3.2) + # Label angka di ujung bar
expand_limits(y = max(top5_mean_ann$mean_ann) * 1.15) + # Tambah ruang di atas bar
labs(title = "Top-5 Expected Return Tertinggi", x = NULL, y = "Mean")
plotly::ggplotly(p_top_mean_ann)
# Ambil 5 saham dengan risiko tahunan (SD) terendah
top5_low_sd_ann <- stats_df |>
slice_min(order_by = sd_ann, n = 5) |>
arrange(sd_ann)
# Buat bar char horizontal untuk top-5 risiko terendah
p_low_sd_ann <- ggplot(top5_low_sd_ann, aes(reorder(ticker, -sd_ann), sd_ann)) +
geom_col(fill = "#27AE60") + coord_flip() +
geom_text(aes(label = round(sd_ann, 4)), hjust = -0.1, size = 3.2) +
expand_limits(y = max(top5_low_sd_ann$sd_ann) * 1.15) +
labs(title = "Top-5 Risiko Terendah", x = NULL, y = "SD")
plotly::ggplotly(p_low_sd_ann)
## -------------------------------------------
## 3d). Heatmap Matriks Varian-Kovarian & Korelasi
## -------------------------------------------
## 3d.1) Heatmap Matriks Varian-Kovarian
# 1. Transformasi data return ke format wide
wide_ret <- rets_df |>
tidyr::pivot_wider(names_from = ticker, values_from = ret) |> # Ubah long ke wide
dplyr::select(-date) # Hilangkan kolom tanggal
# 2. Hitung matriks kovarians harian antar saham
cov_daily <- stats::cov(wide_ret, use = "pairwise.complete.obs", method = "pearson")
# - 'pariwise.complete.obs': gunakan semua data yang tersedia untuk setiap pasangan saham
# - 'pearson': metode standar untuk kovarians
# 3. Hitung matriks korelasi harian antar saham
cor_mat <- stats::cor(wide_ret, use = "pairwise.complete.obs", method = "pearson")
# 4. Annualisasi matriks kovarians
cov_ann <- cov_daily * trading_days
# 5. Tampilkan matriks kovarians tahunan dalam tabel interaktif
datatable(
round(cov_ann, 4), # Matriks kovarians dibulatkan 4 desimal
extensions = "Buttons", # Fitur export
options = list(
dom = "Bfrtip", # Layout tombol export
buttons = c("copy", "csv", "excel"),
scrollX = TRUE, # Scroll horizontal jika tabel lebar
pageLength = 10 # Jumlah baris per halaman
),
caption = htmltools::tags$caption(
style = "caption-side: top; text-align: left;",
"Tabel Varian–Kovarian (Annualized)"
)
)
datatable(
round(cor_mat, 4), # Matriks kovarians dibulatkan 4 desimal
extensions = "Buttons", # Fitur export
options = list(
dom = "Bfrtip", # Layout tombol export
buttons = c("copy", "csv", "excel"),
scrollX = TRUE, # Scroll horizontal jika tabel lebar
pageLength = 10 # Jumlah baris per halaman
),
caption = htmltools::tags$caption(
style = "caption-side: top; text-align: left;",
"Tabel Korelasi Saham"
)
)
## 3d.2) Heatmap Korelasi + Dendrogram
# -----------------------------------------------------------------------------
# Fungsi untuk menghitung matriks p-value korelasi antar saham
pval_mat <- function(M) {
k <- ncol(M)
P <- matrix(NA_real_, k, k, dimnames = list(colnames(M), colnames(M)))
for (i in 1:k) for (j in i:k) {
ct <- suppressWarnings(cor.test(M[, i], M[, j], use = "pairwise.complete.obs"))
P[i, j] <- P[j, i] <- ct$p.value
}
P
}
# -----------------------------------------------------------------------------
# Hitung matriks korelasi Pearson antar saham (simetris, diagonal = 1)
cor_Pear <- cor(wide_ret, use = "pairwise.complete.obs", method = "pearson")
# Hitung matriks p-value untuk korelasi (opsional, untuk masking signifikansi)
p_Pear <- pval_mat(as.data.frame(wide_ret))
# Pilih matriks korelasi yang akan divisualisasikan (bisa masking p-value jika mau)
cor_sig <- cor_Pear
# -----------------------------------------------------------------------------
# Lookup sektor untuk anotasi warna pada heatmap
sector_lookup <- c(
AKRA="Energi", BBNI="Keuangan", BBRI="Keuangan", BMRI="Keuangan",
EXCL="Infrastruktur", INTP="Barang Baku", ISAT="Infrastruktur",
JSMR="Infrastruktur", MEDC="Energi", MTEL="Infrastruktur",
PGAS="Energi", PGEO="Infrastruktur", PTPP="Infrastruktur",
SMGR="Barang Baku", SSIA="Infrastruktur", TLKM="Infrastruktur",
TOWR="Infrastruktur", UNTR="Perindustrian"
)
side_anno <- data.frame(Sector = sector_lookup[colnames(cor_sig)])
row.names(side_anno) <- colnames(cor_sig)
custom_text <- cor_sig
custom_text[lower.tri(custom_text, diag = FALSE)] <- NA
text_matrix <- matrix("", nrow = nrow(cor_sig), ncol = ncol(cor_sig))
for (i in 1:nrow(cor_sig)) {
for (j in i:ncol(cor_sig)) { # j mulai dari i
text_matrix[i, j] <- sprintf("%.2f", cor_sig[i, j])
}
}
sector_colors <- c(
"Energi" = "#E74C3C", # Merah
"Keuangan" = "#3498DB", # Biru
"Infrastruktur" = "#2ECC71", # Hijau
"Barang Baku" = "#F39C12", # Oranye
"Perindustrian" = "#9B59B6" # Ungu
)
side_anno$SectorColor <- sector_colors[side_anno$Sector]
# -----------------------------------------------------------------------------
# HEATMAPLY: Visualisasi heatmap korelasi dengan dendrogram simetris
cor_heatmap <- heatmaply::heatmaply(
cor_sig,
dendrogram = "both", # KOREKSI: Cluster baris & kolom agar simetris
seriate = "OLO", # Optimal Leaf Ordering untuk urutan cluster
symm = TRUE, # KOREKSI: Enforce simetri matriks
row_dend_right = TRUE, # Dendrogram di kiri (baris)
column_dend_top = TRUE, # Dendrogram di atas (kolom)
limits = c(-1, 1), # Skala warna -1 s/d 1
colors = colorRampPalette(rev(RColorBrewer::brewer.pal(11, "RdBu")))(256),
grid_color = "black", grid_width = 0.0001,
label_names = c("Saham i", "Saham j", "Korelasi"),
main = "Heatmap Korelasi Saham dengan Clustering Hierarkis",
showticklabels = c(TRUE, TRUE), na.value = "grey90",
row_side_colors = side_anno[, "Sector", drop = FALSE], # Anotasi sektor di baris
#col_side_colors = side_anno[, "Sector", drop = FALSE], # Anotasi sektor di kolom
custom_hovertext = text_matrix,
cellnote = text_matrix,
cellnote_textposition = "middle center",
cellnote_size = 6,
cellnote_color = "black",
fontsize_row = 8,
fontsize_col = 8,
plot_method = "plotly"
)
cor_heatmap
## 3e) Rolling volatility 30 hari
roll_sd_xts <- zoo::rollapply(rets_xts, 30, sd, by.column = TRUE, align = "right")
roll_sd_df <- roll_sd_xts |>
as.data.frame() |> tibble::rownames_to_column("date") |>
mutate(date = as.Date(date)) |>
pivot_longer(-date, names_to = "ticker", values_to = "roll_sd")
plot_rollvol_by_group <- function(g_ids = 1:length(groups)) {
pls <- vector("list", length(g_ids))
for (ii in seq_along(g_ids)) {
tick_sub <- groups[[ g_ids[ii] ]]
roll_sub <- roll_sd_df |> filter(ticker %in% tick_sub)
p <- ggplot(roll_sub, aes(date, roll_sd, color = ticker)) +
geom_line(linewidth = 0.4, alpha = 0.9, show.legend = FALSE) +
facet_wrap(~ ticker, ncol = 3, scales = "free_y") +
labs(title = "Rolling 30-hari Volatilitas", x = NULL, y = "SD") +
theme(axis.text.y = element_text(size = 7),
axis.text.x = element_text(angle = 45, hjust = 1, size = 8),
plot.title = element_text(size = 16, face = "bold", hjust = 0.5))
pls[[ii]] <- plotly::ggplotly(p)
}
pls
}
roll_plots <- plot_rollvol_by_group(1:3)
roll_plots[[1]]
roll_plots[[2]]
roll_plots[[3]]
## =============================================
## 4) PARAMETER OPTIMASI (annualized μ & Σ)
## =============================================
mu <- colMeans(rets_xts, na.rm = TRUE) * trading_days
# Ambil rata-rata return harian tiap saham, lalu annualisasi
# mu: vektor expected return tahunan untuk setiap saham
S <- cov(rets_xts, use = "pairwise.complete.obs") * trading_days
# Hitung matriks kovarians harian antar saham, lalu annualisasi
# S: matriks kovarians tahunan (jumlah saham x jumlah saham)
stopifnot(is.numeric(mu), is.matrix(S), length(mu) == ncol(S), ncol(S) == nrow(S))
# Validasi: mu harus vektor numerik, S matriks persegi, dimensi konsisten
d <- length(mu)
# d: jumlah saham (dimensi portofolio)
## =============================================
## 5) UTILITAS CCMV + ABC + NSGA-II
## =============================================
# Proyeksi bobot ke simplex terbatas (sum=1, L<=w<=U)
project_box_simplex <- function(w, L, U, s = 1) {
k <- length(w) # Jumlah aset dalam portofolio
L <- rep_len(L, k); U <- rep_len(U, k) # Pastikan L dan U sepanjang k
w <- pmin(pmax(w, L), U) # Batasi setiap bobot di antara L dan U (box constraint)
S <- sum(w) # Hitung jumlah bobot saat ini
if (abs(S - s) < 1e-12) return(w) # Jika sum(w) sudah sama dengan s (default 1), return w
free <- rep(TRUE, k); iter <- 0 # Inisialisasi aset "bebas" dan counter iterasi
while (abs(S - s) > 1e-12 && iter < 1000) { # Iterasi hingga sum(w) ≈ s atau max iterasi
iter <- iter + 1
idx <- which(free); if (!length(idx)) break # Indeks aset yang masih "bebas"
delta <- (S - s) / length(idx) # Koreksi rata-rata untuk aset bebas
w[idx] <- w[idx] - delta # Koreksi bobot agar sum(w) mendekati s
hitL <- which(w < L + 1e-15); hitU <- which(w > U - 1e-15) # Cek yang melewati batas
if (length(hitL)) { w[hitL] <- L[hitL]; free[hitL] <- FALSE } # Kunci di L jika < L
if (length(hitU)) { w[hitU] <- U[hitU]; free[hitU] <- FALSE } # Kunci di U jika > U
S <- sum(w); if (!any(free)) break # Update sum dan cek jika semua sudah fix
}
# Koreksi minor jika sum(w) masih belum pas
if (abs(sum(w) - s) > 1e-8) {
idx <- which((w > L + 1e-12) & (w < U - 1e-12))
if (length(idx) > 0) {
adj <- (sum(w) - s) / length(idx)
w[idx] <- pmin(pmax(w[idx] - adj, L[idx]), U[idx])
}
}
w # Return bobot yang sudah feasible
}
# Bersihkan solusi: tegakkan kardinalitas & aset wajib
clean_portfolio <- function(w, d, alpha, beta, d_min, d_max, must_have_idx_or_NULL) {
stopifnot(length(w) == d) # Validasi panjang bobot = jumlah aset
w[!is.finite(w)] <- 0; w[w < 1e-12] <- 0 # Set bobot tidak valid atau sangat kecil ke nol
active <- which(w > 0) # Indeks aset aktif (bobot > 0)
if (!is.null(must_have_idx_or_NULL)) { # Jika ada aset wajib
if (!(must_have_idx_or_NULL %in% active)) active <- c(must_have_idx_or_NULL, active)
}
nz <- length(active) # Jumlah aset aktif
if (nz < d_min) { # Jika kurang dari minimum
cand <- setdiff(seq_len(d), active)
add <- if (length(cand) >= (d_min - nz)) sample(cand, d_min - nz) else cand
active <- c(active, add)
} else if (nz > d_max) { # Jika lebih dari maksimum
drop_cand <- if (is.null(must_have_idx_or_NULL)) active else setdiff(active, must_have_idx_or_NULL)
if (length(drop_cand) > 0) {
drop <- sample(drop_cand, nz - d_max) # acak agar ragam pola meningkat
active <- setdiff(active, drop)
}
}
w[-active] <- 0 # Set bobot non-aktif ke nol
if (length(active) > 0) {
w_active <- w[active]; w_active[!is.finite(w_active)] <- 0
if (sum(w_active) <= 0) w_active <- rep(1/length(active), length(active))
w_active <- project_box_simplex(w_active, L = alpha, U = beta, s = 1)
w[active] <- w_active
}
w[abs(w) < 1e-12] <- 0
w
}
# Tujuan multi: f1=min SD, f2=min(-Return) → max Return
obj_vec <- function(w, mu, S) {
risk_sd <- sqrt(max(0, as.numeric(t(w) %*% S %*% w))) # Hitung risiko portofolio (SD)
ret <- sum(w * mu) # Hitung expected return portofolio
c(f1 = risk_sd, f2 = -ret) # f1: minimasi risiko; f2; minimasi negatif return
}
# Pareto rules + sorting + crowding
dominates <- function(a, b)
all(a <= b) && any(a < b)
# Cek apakah solusi a mendominasi solusi b:
# - Semua tujuan a <= b (tidak lebih buruk di semua tujuan)
# - Ada minimal satu tujuan a < b (lebih baik di setidaknya satu tujuan)
# Ini adalah definisi dominasi Pareto standar pada optimasi multi-objektif
fast_non_dominated_sort <- function(F) {
F <- as.matrix(F) # Pastikan F adalah matriks (baris: solusi, kolom: tujuan)
n <- nrow(F) # Jumlah solusi
if (n == 0) return(list(fronts = list(), rank = integer(0)))
Slist <- vector("list", n) # Slist[p]: solusi yang didominasi oleh solusi p
n_dom <- integer(n) # n_dom[p]: berapa solusi yang mendominasi p
ranks <- integer(n) # ranks[p]: ranking Pareto solusi p (front ke berapa)
fronts <- list() # List front Pareto
front1 <- integer(0) # Front 1 (solusi yang tidak didominasi siapapun)
for (p in 1:n) {
Slist[[p]] <- integer(0); n_dom[p] <- 0
for (q in 1:n) if (p != q) { # Pengujian untuk p tidak sama dengan q (tidak membandingkan dengan solusi itu sendiri)
if (dominates(F[p,], F[q,])) Slist[[p]] <- c(Slist[[p]], q) # Jika p mendominasi q, masukkan q ke Slist[[p]]
else if (dominates(F[q,], F[p,])) n_dom[p] <- n_dom[p] + 1 # Jika q mendominasi p, n_dom[p] + 1 (artinya bertambah satu solusi yang mendominasi p)
}
if (n_dom[p] == 0) { ranks[p] <- 1; front1 <- c(front1, p) } # Jika n_dom[p] = 0 (artinya tidak ada solusi yang mendominasi p) tetapkan rank p = 1 dan masukkan ke dalam front 1
}
fronts[[1]] <- front1
# Lanjutkan untuk front berikutnya
i <- 1
while (length(fronts[[i]]) > 0) {
Q <- integer(0)
for (p in fronts[[i]]) for (q in Slist[[p]]) { # Contoh: untuk setiap p pada front 1 dan q pada Slist[p]
n_dom[q] <- n_dom[q] - 1
if (n_dom[q] == 0) { ranks[q] <- i + 1; Q <- c(Q, q) } # Jika n_dom[q] = 0 maka tetapkan rank q = 2 (i + 1)
}
i <- i + 1; fronts[[i]] <- Q # tetapkan i = 2 dan masukkan Q ke dalam front 2 (karena n_dom[q] sudah 0) dan kembali ke iterasi awal
}
fronts[length(fronts)] <- NULL # Hapus front kosong terakhir
list(fronts = fronts, rank = ranks)
# Output: list front Pareto (indeks solusi per front) dan ranking Pareto
}
# Fungsi ini adalah implementasi klasik fast non-dominated sorting pada NSGA-II
crowding_distance <- function(Fvals, idxs) {
if (is.null(idxs) || length(idxs) == 0) return(numeric(0)) # Jika tidak ada solusi, return vektor kosong
if (length(idxs) == 1) return(Inf) # Jika hanya satu solusi, crowding distance = Inf
Fvals <- as.matrix(Fvals)
k <- length(idxs) # Jumlah solusi di front
m <- ncol(Fvals) # Jumlah tujuan/objektif
dist <- rep(0, k) # Inisialisasi crowding distance
for (j in 1:m) { # Untuk setiap tujuan
ord <- order(Fvals[idxs, j]) # Urutkan solusi berdasarkan tujuan j
dist[ord[1]] <- Inf; dist[ord[k]] <- Inf # Solusi ekstrem diberi Inf (selalu dipilih)
v <- Fvals[idxs[ord], j]
denom <- max(v) - min(v)
if (!is.finite(denom) || denom == 0) denom <- 1e-12 # Hindari pembagian nol
for (t in 2:(k-1))
dist[ord[t]] <- dist[ord[t]] + (v[t+1] - v[t-1]) / denom
# Tambahkan crowding distance normalisasi antar tetangga
}
dist
}
.active_pattern <- function(W, eps = 1e-8) {
if (is.null(W) || nrow(W) == 0) return(character(0))
apply(W, 1, function(w) paste(as.integer(w > eps), collapse = ""))
}
# Untuk setiap solusi (baris W), buat string biner yang menandakan aset aktif (bobot > eps)
# Contoh: w = [0.2, 0, 0.3, 0.5] → "1011"
# Ini memungkinkan identifikasi pola portofolio unik di populasi.
.pattern_div_score <- function(cands_W, chosen_patterns) {
if (length(chosen_patterns) == 0) return(rep(1, nrow(cands_W)))
pat_c <- .active_pattern(cands_W) # Pola aktif kandidat solusi
tab <- table(chosen_patterns) # Hitung frekuensi pola yang sudah terpilih
sapply(pat_c, function(p) 1 / (1 + ifelse(is.na(tab[p]), 0, tab[p])))
# Skor tinggi untuk pola yang jarang/unik, skor rendah untuk pola yang sering muncul
# Mendorong diversitas portofolio di ruang keputusan (decision space diversity).
}
# Seleksi NSGA-II + bonus diversitas di ruang keputusan
nsga2_select <- function(P, Fvals, target_size,
cd_threshold = 0.01, use_pattern_tiebreak = TRUE) {
stopifnot(is.matrix(P), is.matrix(Fvals), nrow(P) == nrow(Fvals), target_size > 0)
P <- as.matrix(P); Fvals <- as.matrix(Fvals)
fs <- fast_non_dominated_sort(Fvals); fronts <- fs$fronts
nextP <- matrix(numeric(0), 0, ncol(P)) # Populasi terpilih berikutnya
nextF <- matrix(numeric(0), 0, ncol(Fvals)) # Nilai objektif solusi terpilih
chosen_patterns <- character(0) # Pola aktif yang sudah terpilih
for (f in fronts) {
if (is.null(f) || length(f) == 0) next
remain <- target_size - nrow(nextP) # Sisa slot populasi
if (remain <= 0) break
if (length(f) <= remain) {
valid_indices <- f[f > 0 & f <= nrow(P)]
if (length(valid_indices) == 0) next
# Jika solusi di front ini cukup/tidak melebihi kapasitas, ambil semua
nextP <- rbind(nextP, P[valid_indices, , drop = FALSE])
nextF <- rbind(nextF, Fvals[valid_indices, , drop = FALSE])
pattern_subset <- P[valid_indices, , drop = FALSE]
if (nrow(pattern_subset) > 0) {
chosen_patterns <- c(chosen_patterns, .active_pattern(pattern_subset))
}
} else {
# Step 1: Hitung crowding distance (primary)
dist_obj <- crowding_distance(Fvals, f)
if (is.null(dist_obj) || length(dist_obj) != length(f)) {
warning("crowding_distance error, pakai uniform distance")
dist_obj <- rep(1, length(f))
}
# Step 2: Hitung pattern diversity (SECONDARY)
if (use_pattern_tiebreak && length(f) > 0) {
candW <- P[f, , drop = FALSE]
pat_cands <- .active_pattern(candW)
if (length(pat_cands) > 0) {
div_dec <- sapply(pat_cands, function(p) {
if (length(chosen_patterns) == 0) return(1)
1 / (1 + sum(chosen_patterns == p, na.rm = TRUE))
})
} else {
div_dec <- rep(0, length(f))
}
} else {
div_dec <- rep(0, length(f))
}
# Validasi panjang vektor sebelum data.frame
if (length(f) != length(dist_obj) || length(f) != length(div_dec)) {
min_len <- min(length(f), length(dist_obj), length(div_dec))
f <- f[1:min_len]; dist_obj <- dist_obj[1:min_len]; div_dec <- div_dec[1:min_len]
}
# Step 3: Buat data frame untuk sorting
selection_df <- data.frame(
idx = f,
cd = dist_obj,
pds = div_dec,
stringsAsFactors = FALSE
)
# Step 4: Lexicographic sort
# Primary: CD (descending)
# Secondary: PDS (descending) - hanya untuk CD yang sama
if (nrow(selection_df) == 0) next
selection_df <- selection_df[order(-selection_df$cd, -selection_df$pds), ]
# Step 5: Ambil top-k
take_idx <- head(selection_df$idx, remain)
valid_take_idx <- take_idx[take_idx > 0 & take_idx <= nrow(P)]
if (length(valid_take_idx) == 0) next
nextP <- rbind(nextP, P[valid_take_idx, , drop = FALSE])
nextF <- rbind(nextF, Fvals[valid_take_idx, , drop = FALSE])
pattern_subset <- P[valid_take_idx, , drop = FALSE]
if (nrow(pattern_subset) > 0) {
chosen_patterns <- c(chosen_patterns, .active_pattern(pattern_subset))
}
break
}
}
# Jika populasi terpilih melebihi kapasitas, potong
if (nrow(nextP) > target_size) {
keep <- seq_len(target_size)
nextP <- nextP[keep, , drop = FALSE]
nextF <- nextF[keep, , drop = FALSE]
}
rownames(nextP) <- NULL; rownames(nextF) <- NULL
stopifnot(nrow(nextP) == nrow(nextF), nrow(nextP) <= target_size)
list(P = nextP, Fval = nextF, fronts = fronts)
}
# Seleksi NSGA-II:
# - Ambil solusi dari front Pareto terbaik, jika perlu seleksi dengan crowding distance + pattern diversity
# - Menjaga diversity baik di ruang objektif (risk-return) maupun ruang keputusan (komposisi aset)
## =============================================
## 6) LOOP UTAMA MOCv-ABC
## =============================================
run_mcabc <- function(mu, S, tickers,
alpha = 0.1, beta = 0.4, d_min = 3, d_max = 5,
must_have_name = NULL, must_have_idx = NULL,
sigma = 0.5, limit_factor = 10, max_iter = 700,
seed = 123, debug = FALSE) {
set.seed(seed) # Set seed random agar hasil eksperimen reproducible
d <- length(mu) # Jumlah aset (dimensi portofolio)
stopifnot(ncol(S) == d, nrow(S) == d) # Validasi dimensi matriks kovarians S
# ==========================================
# DEBUGGING INFRASTRUCTURE
# ==========================================
if (debug) {
debug_log <- list(
iteration = integer(),
phase = character(),
metric = character(),
value = numeric(),
detail = character()
)
log_event <- function(iter, phase, metric, value, detail = "") {
debug_log$iteration <<- c(debug_log$iteration, iter)
debug_log$phase <<- c(debug_log$phase, phase)
debug_log$metric <<- c(debug_log$metric, metric)
debug_log$value <<- c(debug_log$value, value)
debug_log$detail <<- c(debug_log$detail, detail)
}
cat("═══════════════════════════════════════════════════════════════\n")
cat(" M-CABC EXECUTION WITH DEBUG MODE \n")
cat("═══════════════════════════════════════════════════════════════\n")
}
# ---------- assert dim ----------
assert_dims <- function(P, Fvals, stage, n_employed, d) {
rP <- NROW(P); cP <- NCOL(P); rF <- NROW(Fvals); cF <- NCOL(Fvals)
if (!(rP == n_employed && cP == d))
stop(sprintf("[%s] dim(P)=%s, expected %d x %d", stage, paste(dim(P), collapse="x"), n_employed, d))
if (!(rF == n_employed && cF == 2))
stop(sprintf("[%s] dim(Fvals)=%s, expected %d x 2", stage, paste(dim(Fvals), collapse="x"), n_employed))
}
# Fungsi utilitas untuk memastikan dimensi populasi dan nilai objektif benar
# Jika tidak sesuai, akan error dan menghentikan eksekusi
# ---------- indeks aset wajib ----------
m_idx <- NULL # Inisialisasi indeks aset wajib
if (!is.null(must_have_name) && nzchar(must_have_name)) {
pos <- match(must_have_name, tickers) # Cari posisi aset wajib berdasarkan nama
if (!is.na(pos)) m_idx <- pos
} else if (!is.null(must_have_idx)) {
if (must_have_idx >= 1 && must_have_idx <= d) m_idx <- must_have_idx # Atau berdasarkan indeks langsung
}
if (debug && !is.null(m_idx)) {
cat(sprintf("✓ Must-have asset: %s (index %d)\n",
tickers[m_idx], m_idx))
}
k_missing_count <- c(`3` = 0, `4` = 0, `5` = 0)
# ---------- ukuran koloni ----------
hive_size <- d * 10L # Ukuran koloni (standar: 10x jumlah aset)
n_employed <- as.integer(floor(hive_size / 2)) # Jumlah employed bees
n_onlooker <- hive_size - n_employed # Jumlah onlooker bees
limit <- as.integer(limit_factor * d) # Batas stagnasi (untuk scout bee)
if (debug) {
cat("───────────────────────────────────────────────────────────────\n")
cat(sprintf("✓ Problem dimension : %d assets\n", d))
cat(sprintf("✓ Hive size : %d\n", hive_size))
cat(sprintf("✓ Employed bees : %d\n", n_employed))
cat(sprintf("✓ Onlooker bees : %d\n", n_onlooker))
cat(sprintf("✓ Scout limit : %d iterations\n", limit))
cat(sprintf("✓ Sigma : %.2f\n", sigma))
cat(sprintf("✓ Cardinality range : [%d, %d]\n", d_min, d_max))
cat(sprintf("✓ Weight bounds : [%.2f, %.2f]\n", alpha, beta))
cat("───────────────────────────────────────────────────────────────\n\n")
}
# ---------- ELITE PER-K & HELPER ----------
elite_by_k <- list(`3` = NULL, `4` = NULL, `5` = NULL)
# Menyimpan portofolio elite (Sharpe tertinggi) untuk setiap jumlah aset aktif (k = 3,4,5)
.metrics_from_W <- function(W) {
ER <- as.numeric(W %*% mu) # Hitung expected return
VAR <- apply(W, 1, function(w) as.numeric(t(w) %*% S %*% w)) # Hitung varians portofolio
SD <- sqrt(VAR) # Hitung standar deviasi (risiko)
Sharpe <- (ER - rf) / pmax(SD, 1e-12) # Hitung sharpe ratio
data.frame(ER, SD, Sharpe) # Return data frame metrik
}
.update_elite <- function(W, elite) {
if (is.null(W) || nrow(W) == 0) return(elite) # Jika tidak ada kandidat, return elite lama
kW <- rowSums(W > 1e-8) # Hitung jumlah aset aktif tiap portofolio
for (kk in c(3L,4L,5L)) { # Untuk setiap k yang di-cover
idx <- which(kW == kk) # Cari portofolio dengan k aset aktif
if (length(idx)) {
M <- .metrics_from_W(W[idx,,drop=FALSE]) # Hitung metriknya
id <- idx[which.max(M$Sharpe)] # Ambil yang sharpe ratio-nya tertinggi
cand <- W[id,,drop=FALSE]
if (is.null(elite[[as.character(kk)]])) {
elite[[as.character(kk)]] <- cand # Jika belum ada, simpan
} else {
old <- .metrics_from_W(elite[[as.character(kk)]])$Sharpe
new <- .metrics_from_W(cand)$Sharpe
if (new > old + 1e-12) elite[[as.character(kk)]] <- cand # Update jika lebih baik dari elite sebelumnya
}
}
}
elite # Return elite yang sudah diupdate
}
# ---------- inisialisasi populasi ----------
init_population <- function(size) {
Pclean <- matrix(NA_real_, nrow = size, ncol = d)
for (i in seq_len(size)) {
# Step 1: Generate random weights dalam bounds [alpha, beta]
w <- alpha + runif(d) * (beta - alpha)
# Step 2: Pre-normalize untuk mendekatkan ke feasible region
w <- w / sum(w) # Enforce sum = 1
# Step 3: Clean untuk enforce semua constraint
Pclean[i, ] <- clean_portfolio(w, d, alpha, beta, d_min, d_max, m_idx)
}
Pclean
}
# ==========================================
# INITIALIZATION
# ==========================================
if (debug) cat("⏳ Initializing population...\n")
P <- init_population(n_employed) # Populasi employed bees awal
Fvals <- t(apply(P, 1, function(w) obj_vec(w, mu, S))) # Hitung nilai objektif awal (risk, -return)
pareto_result <- fast_non_dominated_sort(Fvals)
best_rank_seen <- pareto_result$rank
stagn <- integer(n_employed) # Counter stagnasi untuk setiap employed bee
hist_min_risk <- numeric(max_iter) # History min risiko per iterasi
hist_max_ret <- numeric(max_iter) # History max return per iterasi
# Debug: Initial population stats
if (debug) {
k_init <- table(rowSums(P > 1e-8))
n_fronts <- length(pareto_result$fronts)
f1_size <- length(pareto_result$fronts[[1]])
cat("✓ Population initialized\n")
cat(sprintf(" ├─ Cardinality distribution: %s\n",
paste(sprintf("k=%s:%d", names(k_init), as.integer(k_init)), collapse=", ")))
cat(sprintf(" ├─ Pareto fronts: %d\n", n_fronts))
cat(sprintf(" ├─ Front 1 size: %d (%.1f%%)\n", f1_size, 100*f1_size/n_employed))
cat(sprintf(" ├─ Min risk: %.6f\n", min(Fvals[,1])))
cat(sprintf(" └─ Max return: %.6f\n\n", max(-Fvals[,2])))
log_event(0, "INIT", "n_fronts", n_fronts)
log_event(0, "INIT", "f1_size", f1_size)
log_event(0, "INIT", "min_risk", min(Fvals[,1]))
log_event(0, "INIT", "max_return", max(-Fvals[,2]))
}
# ==========================================
# MAIN LOOP
# ==========================================
if (debug) {
cat("═══════════════════════════════════════════════════════════════\n")
cat(" STARTING ITERATIONS \n")
cat("═══════════════════════════════════════════════════════════════\n\n")
}
for (g in seq_len(max_iter)) { # Loop utama iterasi algoritma
if (debug && (g == 1 || g %% 50 == 0 || g == max_iter)) {
cat(sprintf("\n╔═══ ITERATION %d/%d ═══╗\n", g, max_iter))
}
## =======================================
## EMPLOYED BEES PHASE
## =======================================
phi <- runif(1, -1, 1) # Parameter random eksplorasi
varphi <- runif(1, 0, 1.5) # Parameter random eksploitasi
# Pareto sorting
tmp <- fast_non_dominated_sort(Fvals) # Pareto sorting populasi saat ini
F1 <- tmp$fronts[[1]]
if (length(F1) == 0) F1 <- 1L # Ambil front 1 (Pareto optimal)
BEST <- as.numeric(P[sample(F1, 1), , drop = FALSE])
if (debug && (g == 1 || g %% 50 == 0)) {
best_idx <- sample(F1, 1)
best_metrics <- .metrics_from_W(matrix(P[best_idx,], nrow=1))
cat(sprintf("├─ EMPLOYED PHASE\n"))
cat(sprintf("│ ├─ phi = %.3f, varphi = %.3f\n", phi, varphi))
cat(sprintf("│ ├─ Front 1 size: %d\n", length(F1)))
cat(sprintf("│ └─ BEST solution: idx=%d, SR=%.3f, risk=%.4f, ret=%.4f\n",
best_idx, best_metrics$Sharpe, best_metrics$SD, best_metrics$ER))
}
P_emp <- P # Salin populasi employed bees
for (i in seq_len(n_employed)) { # Untuk setiap employed bee (solusi)
j <- sample(setdiff(seq_len(n_employed), i), 1) # Pilih solusi lain secara acak (bukan diri sendiri)
dm <- sample.int(d, 1) # Pilih satu dimensi (aset) secara acak
Xi <- P[i, ]; Xj <- P[j, ]
Xi_new <- Xi
Xi_new[dm] <- Xi[dm] + phi*(Xi[dm]-Xj[dm]) + varphi*(BEST[dm]-Xi[dm]) # Update posisi bee: eksplorasi + eksploitasi (buat solusi tetangga)
Xi_new <- clean_portfolio(Xi_new, d, alpha, beta, d_min, d_max, m_idx) # Bersihkan agar feasible
P_emp[i, ] <- Xi_new # Simpan solusi baru
}
if (debug && (g == 1 || g %% 50 == 0)) {
log_event(g, "EMPLOYED", "phi", phi)
log_event(g, "EMPLOYED", "varphi", varphi)
log_event(g, "EMPLOYED", "f1_size", length(F1))
}
## =======================================
## ONLOOKER BEES PHASE
## =======================================
fronts <- tmp$fronts
# Step 1: Tentukan jumlah front yang akan digunakan
max_fronts_to_use <- ceiling(log2(n_onlooker))
n_available_fronts <- length(fronts)
n_fronts_to_use <- min(max_fronts_to_use, n_available_fronts)
# Step 2: Hitung alokasi untuk setiap front
# Formula: nof_j = [o / 2^j]
alloc_counts <- integer(n_fronts_to_use)
for (j in 1:n_fronts_to_use) {
alloc_counts[j] <- ceiling(n_onlooker / (2^j))
}
# Step 3: Normalisasi agar total = n_onlooker
total_alloc <- sum(alloc_counts)
if (total_alloc > n_onlooker) {
# Kurangi dari front terakhir
excess <- total_alloc - n_onlooker
alloc_counts[n_fronts_to_use] <- max(1, alloc_counts[n_fronts_to_use] - excess)
} else if (total_alloc < n_onlooker) {
# Tambahkan ke front pertama (yang paling banyak)
deficit <- n_onlooker - total_alloc
alloc_counts[1] <- alloc_counts[1] + deficit
}
# Step 4: Generate allocation vector
alloc <- rep(seq_len(n_fronts_to_use), times = alloc_counts)
if (debug && (g == 1 || g %% 50 == 0)) {
cat(sprintf("├─ ONLOOKER PHASE\n"))
cat(sprintf("│ ├─ Fronts used: %d (max: %d)\n", n_fronts_to_use, max_fronts_to_use))
cat(sprintf("│ └─ Allocation: %s\n",
paste(sprintf("F%d:%d", 1:n_fronts_to_use, alloc_counts), collapse=", ")))
log_event(g, "ONLOOKER", "n_fronts_used", n_fronts_to_use)
log_event(g, "ONLOOKER", "f1_onlookers", alloc_counts[1])
}
# Step 5: Generate onlooker bees
P_onl <- matrix(NA_real_, nrow = n_onlooker, ncol = d)
for (idx in seq_len(n_onlooker)) {
grp <- alloc[idx]
ids <- fronts[[grp]]
if (length(ids) <= 1) {
m <- colMeans(P)
C <- stats::cov(P)
} else {
m <- colMeans(P[ids, , drop = FALSE])
C <- stats::cov(P[ids, , drop = FALSE])
}
eig <- eigen(C, symmetric = TRUE)
Z <- as.numeric(m + sigma * (eig$vectors %*% diag(sqrt(pmax(0, eig$values))) %*% runif(d)))
P_onl[idx, ] <- clean_portfolio(Z, d, alpha, beta, d_min, d_max, m_idx)
}
## =======================================
## SCOUT BEES PHASE
## =======================================
F_emp <- t(apply(P_emp, 1, function(w) obj_vec(w, mu, S))) # Hitung nilai objektif solusi tetangga employed bees
# Step 1: Gabungkan populasi lama dan baru untuk comparison
P_for_rank <- rbind(P, P_emp)
F_for_rank <- rbind(Fvals, F_emp)
# Step2 2: Hitung Pareto rank
rank_result <- fast_non_dominated_sort(F_for_rank)
all_ranks <- rank_result$rank
# Step 3: Pisahkan rank
current_ranks <- all_ranks[(n_employed + 1):(2 * n_employed)]
old_ranks <- all_ranks[1:n_employed]
# Step 4: Check improvement (rank membaik atau sama)
improved <- current_ranks <= best_rank_seen
stagn[improved] <- 0L
stagn[!improved] <- stagn[!improved] + 1L
# Step 5: Update best rank seen
best_rank_seen <- pmin(best_rank_seen, current_ranks)
# Step 6: Scout bee (re-initialize stagnant)
idx_stag <- which(stagn >= limit)
if (debug && (g == 1 || g %% 50 == 0)) {
n_improved <- sum(improved)
max_stag <- max(stagn)
cat(sprintf("├─ SCOUT PHASE\n"))
cat(sprintf("│ ├─ Improved solutions: %d/%d (%.1f%%)\n",
n_improved, n_employed, 100*n_improved/n_employed))
cat(sprintf("│ ├─ Max stagnation: %d (limit: %d)\n", max_stag, limit))
cat(sprintf("│ └─ Re-initialized: %d bees\n", length(idx_stag)))
log_event(g, "SCOUT", "n_improved", n_improved)
log_event(g, "SCOUT", "n_reinit", length(idx_stag))
log_event(g, "SCOUT", "max_stagnation", max_stag)
}
if (length(idx_stag)) {
for (ii in idx_stag) {
# Gunakan fungsi inisialisasi
w <- alpha + runif(d) * (beta - alpha)
w <- w / sum(w)
P_emp[ii, ] <- clean_portfolio(w, d, alpha, beta, d_min, d_max, m_idx)
}
stagn[idx_stag] <- 0L
# Re-evaluate
F_emp <- t(apply(P_emp, 1, function(w) obj_vec(w, mu, S)))
# Update ranks setelah re-inisialisasi
P_for_rank <- rbind(P, P_emp)
F_for_rank <- rbind(Fvals, F_emp)
rank_result <- fast_non_dominated_sort(F_for_rank)
current_ranks <- rank_result$rank[(n_employed + 1):(2 * n_employed)]
best_rank_seen <- pmin(best_rank_seen, current_ranks)
}
## =======================================
## TARGETED SCOUT (k-coverage)
## =======================================
ensure_k <- c(3L, 4L, 5L) # Daftar jumlah aset aktif yang wajib ada di populasi
k_now <- rowSums(P_emp > 1e-8) # Hitung jumlah aset aktif tiap solusi
missing_k <- setdiff(ensure_k, unique(k_now)) # Cari k yang belum ada di populasi
if (length(missing_k) > 0) {
# Update counter
for (kk in missing_k) {
k_missing_count[as.character(kk)] <- k_missing_count[as.character(kk)] + 1
}
# Adaptive Rate
n_missing <- length(missing_k)
base_rate <- 0.03 * n_missing
max_missing_count <- max(k_missing_count[as.character(missing_k)])
adaptive_multiplier <- min(3.0, 1.0 + (max_missing_count / 10.0))
injection_rate <- min(0.15, base_rate * adaptive_multiplier)
n_inject <- max(2L, floor(injection_rate * n_employed))
if (debug && (g == 1 || g %% 50 == 0)) {
cat(sprintf("├─ TARGETED SCOUT (ADAPTIVE)\n"))
cat(sprintf("│ ├─ Missing k=%s (count: %s)\n",
paste(missing_k, collapse=","),
paste(k_missing_count[as.character(missing_k)], collapse=",")))
cat(sprintf("│ ├─ Multiplier: %.2fx → Rate: %.1f%%\n",
adaptive_multiplier, injection_rate*100))
cat(sprintf("│ └─ Injecting: %d solutions\n", n_inject))
log_event(g, "TARGETED", "n_missing_k", n_missing)
log_event(g, "TARGETED", "n_injected", n_inject)
}
for (kk in missing_k) {
idx_reseed <- sample(seq_len(n_employed), n_inject)
for (ii in idx_reseed) {
w <- alpha + runif(d) * (beta - alpha)
w <- w / sum(w)
P_emp[ii, ] <- clean_portfolio(w, d, alpha, beta, kk, kk, m_idx)
}
}
F_emp <- t(apply(P_emp, 1, function(w) obj_vec(w, mu, S)))
}
elite_by_k <- .update_elite(P_emp, elite_by_k)
## =======================================
## NSGA-II SELECTION
## =======================================
F_onl <- t(apply(P_onl, 1, function(w) obj_vec(w, mu, S))) # Hitung nilai objektif solusi onlooker bees
elite_stack <- do.call(rbind, elite_by_k[!sapply(elite_by_k, is.null)]) # Gabungkan semua solusi
if (!is.null(elite_stack) && nrow(elite_stack) > 0) { # Jika ada solusi elite
F_elite <- t(apply(elite_stack, 1, function(w) obj_vec(w, mu, S))) # Hitung nilai objektifnya
P_comb <- rbind(P, P_emp, P_onl, elite_stack) # Gabungkan semua solusi (lama, employed, onlooker, elite)
F_comb <- rbind(Fvals, F_emp, F_onl, F_elite)
n_elite <- nrow(elite_stack)
} else {
P_comb <- rbind(P, P_emp, P_onl) # Gabungkan tanpa elite
F_comb <- rbind(Fvals, F_emp, F_onl)
n_elite <- 0
}
if (debug && (g == 1 || g %% 50 == 0)) {
cat(sprintf("├─ SELECTION\n"))
cat(sprintf("│ ├─ Combined pool: %d solutions\n", nrow(P_comb)))
cat(sprintf("│ │ ├─ Old generation: %d\n", n_employed))
cat(sprintf("│ │ ├─ Employed: %d\n", n_employed))
cat(sprintf("│ │ ├─ Onlooker: %d\n", n_onlooker))
cat(sprintf("│ │ └─ Elite: %d\n", n_elite))
log_event(g, "SELECTION", "pool_size", nrow(P_comb))
log_event(g, "SELECTION", "n_elite", n_elite)
}
sel <- nsga2_select(P_comb, F_comb, n_employed) # Seleksi generasi berikutnya dengan NSGA-II (Pareto + crowding + pattern_diversity tie breaker)
P_selected <- as.matrix(sel$P)
Fvals_selected <- as.matrix(sel$Fval)
# ============================================
# FORCE CARDINALITY COVERAGE
# ============================================
k_selected <- rowSums(P_selected > 1e-8)
missing_k_final <- setdiff(ensure_k, unique(k_selected))
if (length(missing_k_final) > 0 && !is.null(elite_stack)) {
# Identifikasi elite untuk missing k
elite_indices_to_force <- which(sapply(names(elite_by_k), function(kk) {
as.integer(kk) %in% missing_k_final && !is.null(elite_by_k[[kk]])
}))
if (length(elite_indices_to_force) > 0) {
# Ambil elite yang missing
elite_to_force <- do.call(rbind, elite_by_k[elite_indices_to_force])
F_elite_to_force <- t(apply(elite_to_force, 1, function(w) obj_vec(w, mu, S)))
# Hitung rank elite
P_test <- rbind(P_selected, elite_to_force)
F_test <- rbind(Fvals_selected, F_elite_to_force)
rank_test <- fast_non_dominated_sort(F_test)$rank
elite_ranks <- rank_test[(n_employed + 1):nrow(P_test)]
# Replace worst solutions dengan elite
# Strategy: Replace solutions dengan rank tertinggi (worst)
current_ranks <- rank_test[1:n_employed]
worst_rank <- max(current_ranks)
worst_indices <- which(current_ranks == worst_rank)
n_to_replace <- min(length(elite_indices_to_force), length(worst_indices))
if (n_to_replace > 0) {
# Replace dari belakang (worst crowding distance di rank terburuk)
replace_idx <- tail(worst_indices, n_to_replace)
P_selected[replace_idx, ] <- elite_to_force[1:n_to_replace, ]
Fvals_selected[replace_idx, ] <- F_elite_to_force[1:n_to_replace, ]
}
}
}
P <- P_selected
Fvals <- Fvals_selected
# ==========================================
# ITERATION SUMMARY
# ==========================================
hist_min_risk[g] <- min(Fvals[,1]) # Simpan min risiko iterasi ini
hist_max_ret[g] <- max(-Fvals[,2]) # Simpan max return iterasi ini
if (debug && (g == 1 || g %% 50 == 0 || g == max_iter)) {
k_final <- table(rowSums(P > 1e-8))
final_fronts <- fast_non_dominated_sort(Fvals)
# Elite stats
elite_sharpe <- sapply(elite_by_k, function(e) {
if (is.null(e)) return(NA)
.metrics_from_W(matrix(e, nrow=1))$Sharpe
})
cat(sprintf("└─ ITERATION SUMMARY\n"))
cat(sprintf(" ├─ Cardinality: %s\n",
paste(sprintf("k=%s:%d", names(k_final), as.integer(k_final)), collapse=", ")))
cat(sprintf(" ├─ Fronts: %d (F1: %d solutions)\n",
length(final_fronts$fronts), length(final_fronts$fronts[[1]])))
cat(sprintf(" ├─ Min risk: %.6f\n", hist_min_risk[g]))
cat(sprintf(" ├─ Max return: %.6f\n", hist_max_ret[g]))
cat(sprintf(" └─ Elite Sharpe: k3=%.3f, k4=%.3f, k5=%.3f\n",
ifelse(is.na(elite_sharpe[1]), 0, elite_sharpe[1]),
ifelse(is.na(elite_sharpe[2]), 0, elite_sharpe[2]),
ifelse(is.na(elite_sharpe[3]), 0, elite_sharpe[3])))
if (g == 1 || g %% 50 == 0) cat("╚═══════════════════════════════════════════════════════════════╝\n")
log_event(g, "SUMMARY", "min_risk", hist_min_risk[g])
log_event(g, "SUMMARY", "max_return", hist_max_ret[g])
log_event(g, "SUMMARY", "n_fronts", length(final_fronts$fronts))
log_event(g, "SUMMARY", "f1_size", length(final_fronts$fronts[[1]]))
}
}
# ==========================================
# FINAL REPORT
# ==========================================
if (debug) {
cat("\n═══════════════════════════════════════════════════════════════\n")
cat(" EXECUTION COMPLETED \n")
cat("═══════════════════════════════════════════════════════════════\n")
final_fronts <- fast_non_dominated_sort(Fvals)
k_final <- table(rowSums(P > 1e-8))
cat(sprintf("✓ Total iterations: %d\n", max_iter))
cat(sprintf("✓ Final population:\n"))
cat(sprintf(" ├─ Pareto fronts: %d\n", length(final_fronts$fronts)))
cat(sprintf(" ├─ Front 1 size: %d (%.1f%%)\n",
length(final_fronts$fronts[[1]]),
100*length(final_fronts$fronts[[1]])/n_employed))
cat(sprintf(" ├─ Cardinality: %s\n",
paste(sprintf("k=%s:%d", names(k_final), as.integer(k_final)), collapse=", ")))
cat(sprintf(" ├─ Best risk: %.6f\n", min(Fvals[,1])))
cat(sprintf(" └─ Best return: %.6f\n", max(-Fvals[,2])))
# Elite summary
cat("\n✓ Elite portfolios (best Sharpe per cardinality):\n")
for (kk in c(3,4,5)) {
e <- elite_by_k[[as.character(kk)]]
if (!is.null(e)) {
m <- .metrics_from_W(matrix(e, nrow=1))
active_assets <- which(e > 1e-8)
cat(sprintf(" ├─ k=%d: Sharpe=%.4f, Risk=%.4f, Return=%.4f\n",
kk, m$Sharpe, m$SD, m$ER))
cat(sprintf(" │ Assets: %s\n",
paste(tickers[active_assets], collapse=", ")))
} else {
cat(sprintf(" ├─ k=%d: Not found\n", kk))
}
}
cat("═══════════════════════════════════════════════════════════════\n\n")
# Save debug log
debug_df <- as.data.frame(debug_log)
attr(P, "debug_log") <- debug_df
}
# Return
result <- list(
P = P,
Fvals = Fvals,
tickers = tickers,
elite_by_k = elite_by_k,
history = data.frame(
iter = seq_len(max_iter),
min_risk = hist_min_risk,
max_return = hist_max_ret
)
)
if (debug) {
result$debug_log <- as.data.frame(debug_log)
}
return(result)
}
## =============================================
## 7) JALANKAN OPTIMASI & VISUALISASI FRONTIER
## =============================================
mcabc_cov <- run_mcabc(
mu = mu, S = S, tickers = tickers,
alpha = alpha_w, beta = beta_w,
d_min = d_min, d_max = d_max,
must_have_name = must_have_name,
sigma = sigma_abc,
limit_factor = limit_factor,
max_iter = max_iter,
debug = TRUE
)
## ═══════════════════════════════════════════════════════════════
## M-CABC EXECUTION WITH DEBUG MODE
## ═══════════════════════════════════════════════════════════════
## ✓ Must-have asset: TLKM (index 15)
## ───────────────────────────────────────────────────────────────
## ✓ Problem dimension : 17 assets
## ✓ Hive size : 170
## ✓ Employed bees : 85
## ✓ Onlooker bees : 85
## ✓ Scout limit : 170 iterations
## ✓ Sigma : 0.50
## ✓ Cardinality range : [3, 5]
## ✓ Weight bounds : [0.10, 0.40]
## ───────────────────────────────────────────────────────────────
##
## ⏳ Initializing population...
## ✓ Population initialized
## ├─ Cardinality distribution: k=5:85
## ├─ Pareto fronts: 17
## ├─ Front 1 size: 7 (8.2%)
## ├─ Min risk: 0.175542
## └─ Max return: 0.203194
##
## ═══════════════════════════════════════════════════════════════
## STARTING ITERATIONS
## ═══════════════════════════════════════════════════════════════
##
##
## ╔═══ ITERATION 1/200 ═══╗
## ├─ EMPLOYED PHASE
## │ ├─ phi = 0.150, varphi = 0.037
## │ ├─ Front 1 size: 7
## │ └─ BEST solution: idx=34, SR=-0.001, risk=0.1790, ret=0.0473
## ├─ ONLOOKER PHASE
## │ ├─ Fronts used: 7 (max: 7)
## │ └─ Allocation: F1:43, F2:22, F3:11, F4:6, F5:3, F6:2, F7:1
## ├─ SCOUT PHASE
## │ ├─ Improved solutions: 20/85 (23.5%)
## │ ├─ Max stagnation: 1 (limit: 170)
## │ └─ Re-initialized: 0 bees
## ├─ TARGETED SCOUT (ADAPTIVE)
## │ ├─ Missing k=3,4 (count: 1,1)
## │ ├─ Multiplier: 1.10x → Rate: 6.6%
## │ └─ Injecting: 5 solutions
## ├─ SELECTION
## │ ├─ Combined pool: 258 solutions
## │ │ ├─ Old generation: 85
## │ │ ├─ Employed: 85
## │ │ ├─ Onlooker: 85
## │ │ └─ Elite: 3
## └─ ITERATION SUMMARY
## ├─ Cardinality: k=3:2, k=4:1, k=5:82
## ├─ Fronts: 7 (F1: 14 solutions)
## ├─ Min risk: 0.173130
## ├─ Max return: 0.203194
## └─ Elite Sharpe: k3=0.421, k4=-0.149, k5=0.711
## ╚═══════════════════════════════════════════════════════════════╝
##
## ╔═══ ITERATION 50/200 ═══╗
## ├─ EMPLOYED PHASE
## │ ├─ phi = 0.896, varphi = 1.474
## │ ├─ Front 1 size: 84
## │ └─ BEST solution: idx=69, SR=0.800, risk=0.2560, ret=0.2523
## ├─ ONLOOKER PHASE
## │ ├─ Fronts used: 2 (max: 7)
## │ └─ Allocation: F1:63, F2:22
## ├─ SCOUT PHASE
## │ ├─ Improved solutions: 66/85 (77.6%)
## │ ├─ Max stagnation: 7 (limit: 170)
## │ └─ Re-initialized: 0 bees
## ├─ TARGETED SCOUT (ADAPTIVE)
## │ ├─ Missing k=3 (count: 14)
## │ ├─ Multiplier: 2.40x → Rate: 7.2%
## │ └─ Injecting: 6 solutions
## ├─ SELECTION
## │ ├─ Combined pool: 258 solutions
## │ │ ├─ Old generation: 85
## │ │ ├─ Employed: 85
## │ │ ├─ Onlooker: 85
## │ │ └─ Elite: 3
## └─ ITERATION SUMMARY
## ├─ Cardinality: k=3:1, k=4:2, k=5:82
## ├─ Fronts: 2 (F1: 84 solutions)
## ├─ Min risk: 0.169115
## ├─ Max return: 0.283916
## └─ Elite Sharpe: k3=0.718, k4=0.802, k5=0.816
## ╚═══════════════════════════════════════════════════════════════╝
##
## ╔═══ ITERATION 100/200 ═══╗
## ├─ EMPLOYED PHASE
## │ ├─ phi = 0.265, varphi = 0.288
## │ ├─ Front 1 size: 84
## │ └─ BEST solution: idx=2, SR=0.803, risk=0.3104, ret=0.2967
## ├─ ONLOOKER PHASE
## │ ├─ Fronts used: 2 (max: 7)
## │ └─ Allocation: F1:63, F2:22
## ├─ SCOUT PHASE
## │ ├─ Improved solutions: 74/85 (87.1%)
## │ ├─ Max stagnation: 57 (limit: 170)
## │ └─ Re-initialized: 0 bees
## ├─ TARGETED SCOUT (ADAPTIVE)
## │ ├─ Missing k=3 (count: 24)
## │ ├─ Multiplier: 3.00x → Rate: 9.0%
## │ └─ Injecting: 7 solutions
## ├─ SELECTION
## │ ├─ Combined pool: 258 solutions
## │ │ ├─ Old generation: 85
## │ │ ├─ Employed: 85
## │ │ ├─ Onlooker: 85
## │ │ └─ Elite: 3
## └─ ITERATION SUMMARY
## ├─ Cardinality: k=3:1, k=4:7, k=5:77
## ├─ Fronts: 2 (F1: 84 solutions)
## ├─ Min risk: 0.168992
## ├─ Max return: 0.299741
## └─ Elite Sharpe: k3=0.753, k4=0.815, k5=0.817
## ╚═══════════════════════════════════════════════════════════════╝
##
## ╔═══ ITERATION 150/200 ═══╗
## ├─ EMPLOYED PHASE
## │ ├─ phi = 0.729, varphi = 0.357
## │ ├─ Front 1 size: 84
## │ └─ BEST solution: idx=18, SR=0.754, risk=0.2240, ret=0.2164
## ├─ ONLOOKER PHASE
## │ ├─ Fronts used: 2 (max: 7)
## │ └─ Allocation: F1:63, F2:22
## ├─ SCOUT PHASE
## │ ├─ Improved solutions: 65/85 (76.5%)
## │ ├─ Max stagnation: 107 (limit: 170)
## │ └─ Re-initialized: 0 bees
## ├─ SELECTION
## │ ├─ Combined pool: 258 solutions
## │ │ ├─ Old generation: 85
## │ │ ├─ Employed: 85
## │ │ ├─ Onlooker: 85
## │ │ └─ Elite: 3
## └─ ITERATION SUMMARY
## ├─ Cardinality: k=3:1, k=4:12, k=5:72
## ├─ Fronts: 2 (F1: 84 solutions)
## ├─ Min risk: 0.168957
## ├─ Max return: 0.301112
## └─ Elite Sharpe: k3=0.753, k4=0.815, k5=0.817
## ╚═══════════════════════════════════════════════════════════════╝
##
## ╔═══ ITERATION 200/200 ═══╗
## ├─ EMPLOYED PHASE
## │ ├─ phi = 0.909, varphi = 0.411
## │ ├─ Front 1 size: 84
## │ └─ BEST solution: idx=67, SR=0.810, risk=0.3147, ret=0.3025
## ├─ ONLOOKER PHASE
## │ ├─ Fronts used: 2 (max: 7)
## │ └─ Allocation: F1:63, F2:22
## ├─ SCOUT PHASE
## │ ├─ Improved solutions: 66/85 (77.6%)
## │ ├─ Max stagnation: 157 (limit: 170)
## │ └─ Re-initialized: 0 bees
## ├─ SELECTION
## │ ├─ Combined pool: 258 solutions
## │ │ ├─ Old generation: 85
## │ │ ├─ Employed: 85
## │ │ ├─ Onlooker: 85
## │ │ └─ Elite: 3
## └─ ITERATION SUMMARY
## ├─ Cardinality: k=3:1, k=4:13, k=5:71
## ├─ Fronts: 2 (F1: 84 solutions)
## ├─ Min risk: 0.168943
## ├─ Max return: 0.303047
## └─ Elite Sharpe: k3=0.753, k4=0.815, k5=0.817
## ╚═══════════════════════════════════════════════════════════════╝
##
## ═══════════════════════════════════════════════════════════════
## EXECUTION COMPLETED
## ═══════════════════════════════════════════════════════════════
## ✓ Total iterations: 200
## ✓ Final population:
## ├─ Pareto fronts: 2
## ├─ Front 1 size: 84 (98.8%)
## ├─ Cardinality: k=3:1, k=4:13, k=5:71
## ├─ Best risk: 0.168943
## └─ Best return: 0.303047
##
## ✓ Elite portfolios (best Sharpe per cardinality):
## ├─ k=3: Sharpe=0.7532, Risk=0.3130, Return=0.2832
## │ Assets: MEDC, SSIA, TLKM
## ├─ k=4: Sharpe=0.8151, Risk=0.2948, Return=0.2878
## │ Assets: MEDC, SSIA, TLKM, UNTR
## ├─ k=5: Sharpe=0.8170, Risk=0.2773, Return=0.2741
## │ Assets: MEDC, PGAS, SSIA, TLKM, UNTR
## ═══════════════════════════════════════════════════════════════
P <- mcabc_cov$P; Fvals <- mcabc_cov$Fvals
# Plot frontier (semua solusi, highlight Pareto)
fs <- fast_non_dominated_sort(Fvals)
F1 <- fs$fronts[[1]]
cat("Jumlah solusi di Front-1:", length(F1), "\n")
## Jumlah solusi di Front-1: 84
dfF <- as.data.frame(Fvals); colnames(dfF) <- c("risk_sd","neg_ret")
dfF$return <- -dfF$neg_ret; dfF$pareto <- FALSE; dfF$pareto[F1] <- TRUE
p_front <- ggplot(dfF, aes(risk_sd, return, color = pareto)) +
geom_point(alpha = .8) +
scale_color_manual(values = c("#B0B0B0","#2E86DE")) +
labs(title = "Risk-Return Frontier (SD vs Return)",
x = "Risk (SD, annualized)", y = "Return (annualized)", color = NULL)
plotly::ggplotly(p_front)
# Frekuensi keikutsertaan aset di F1
P_F1 <- P[F1, , drop = FALSE]
freq <- colSums(P_F1 > 1e-8); pct <- 100 * freq / nrow(P_F1)
freq_df <- tibble::tibble(ticker = tickers, freq = freq, pct = pct) |> dplyr::arrange(desc(pct))
print(freq_df)
## # A tibble: 17 × 3
## ticker freq pct
## <chr> <dbl> <dbl>
## 1 TLKM 84 100
## 2 UNTR 83 98.8
## 3 SSIA 67 79.8
## 4 MEDC 52 61.9
## 5 PGAS 46 54.8
## 6 BBNI 26 31.0
## 7 BMRI 26 31.0
## 8 MTEL 17 20.2
## 9 JSMR 4 4.76
## 10 AKRA 2 2.38
## 11 BBRI 0 0
## 12 EXCL 0 0
## 13 INTP 0 0
## 14 ISAT 0 0
## 15 PTPP 0 0
## 16 SMGR 0 0
## 17 TOWR 0 0
top5_freq <- head(freq_df, 5)
p_freq <- ggplot(top5_freq, aes(reorder(ticker, pct), pct)) +
geom_col(fill = "#2E86DE") + coord_flip() +
labs(title = "Top-5 Saham Paling Sering di Front-1", x = NULL, y = "Frekuensi (%)")
plotly::ggplotly(p_freq)
# Ringkasan jumlah solusi per-k (populasi & Pareto-1)
k_each <- rowSums(P > 1e-8)
count_pop <- as.data.frame(table(k = k_each)); names(count_pop) <- c("k","count_in_population"); count_pop$k <- as.integer(as.character(count_pop$k))
count_f1 <- as.data.frame(table(k = k_each[F1])); names(count_f1) <- c("k","count_in_F1"); count_f1$k <- as.integer(as.character(count_f1$k))
counts_summary <- dplyr::full_join(count_pop, count_f1, by = "k") |>
dplyr::arrange(k) |>
dplyr::mutate(dplyr::across(dplyr::starts_with("count"), ~replace(., is.na(.), 0L)))
print(counts_summary)
## k count_in_population count_in_F1
## 1 3 1 0
## 2 4 13 13
## 3 5 71 71
debug_df <- mcabc_cov$debug_log
ggplot(debug_df %>% filter(metric == "min_risk"),
aes(x = iteration, y = value)) +
geom_line() +
labs(title = "Risk Convergence",
x = "Iteration", y = "Minimum Risk") +
theme_minimal()

ggplot(debug_df %>% filter(metric == "max_return"),
aes(x = iteration, y = value)) +
geom_line() +
labs(title = "Return Convergence",
x = "Iteration", y = "Maximum Return") +
theme_minimal()

mean_risk_history <- mcabc_cov$history
mean_risk_history
## iter min_risk max_return
## 1 1 0.1731297 0.2031939
## 2 2 0.1714163 0.2109224
## 3 3 0.1714163 0.2109224
## 4 4 0.1701895 0.2109224
## 5 5 0.1701895 0.2118447
## 6 6 0.1701895 0.2118447
## 7 7 0.1701895 0.2136776
## 8 8 0.1701895 0.2136776
## 9 9 0.1701895 0.2208829
## 10 10 0.1701895 0.2214201
## 11 11 0.1701895 0.2214201
## 12 12 0.1701895 0.2214201
## 13 13 0.1698612 0.2214201
## 14 14 0.1698612 0.2236294
## 15 15 0.1698612 0.2236294
## 16 16 0.1698612 0.2236294
## 17 17 0.1698612 0.2239790
## 18 18 0.1697364 0.2408740
## 19 19 0.1693840 0.2422052
## 20 20 0.1693840 0.2424305
## 21 21 0.1693840 0.2424305
## 22 22 0.1693840 0.2424305
## 23 23 0.1693840 0.2431988
## 24 24 0.1693840 0.2431988
## 25 25 0.1693840 0.2464948
## 26 26 0.1691642 0.2643594
## 27 27 0.1691642 0.2643594
## 28 28 0.1691642 0.2686975
## 29 29 0.1691642 0.2686975
## 30 30 0.1691642 0.2690507
## 31 31 0.1691642 0.2690507
## 32 32 0.1691338 0.2696328
## 33 33 0.1691338 0.2696328
## 34 34 0.1691338 0.2706578
## 35 35 0.1691338 0.2783484
## 36 36 0.1691338 0.2783484
## 37 37 0.1691338 0.2783484
## 38 38 0.1691338 0.2783484
## 39 39 0.1691338 0.2783484
## 40 40 0.1691151 0.2821111
## 41 41 0.1691151 0.2821111
## 42 42 0.1691151 0.2821111
## 43 43 0.1691151 0.2821111
## 44 44 0.1691151 0.2821111
## 45 45 0.1691151 0.2839163
## 46 46 0.1691151 0.2839163
## 47 47 0.1691151 0.2839163
## 48 48 0.1691151 0.2839163
## 49 49 0.1691151 0.2839163
## 50 50 0.1691151 0.2839163
## 51 51 0.1691151 0.2839163
## 52 52 0.1691151 0.2839163
## 53 53 0.1691151 0.2911587
## 54 54 0.1691151 0.2911587
## 55 55 0.1691151 0.2911587
## 56 56 0.1691151 0.2911587
## 57 57 0.1691151 0.2911587
## 58 58 0.1691151 0.2911587
## 59 59 0.1691151 0.2911587
## 60 60 0.1691151 0.2911587
## 61 61 0.1691148 0.2911587
## 62 62 0.1691148 0.2911587
## 63 63 0.1691148 0.2911587
## 64 64 0.1691148 0.2911587
## 65 65 0.1691148 0.2911587
## 66 66 0.1691148 0.2911587
## 67 67 0.1691148 0.2911587
## 68 68 0.1691148 0.2911587
## 69 69 0.1691148 0.2911587
## 70 70 0.1691148 0.2911587
## 71 71 0.1691148 0.2919384
## 72 72 0.1691148 0.2919384
## 73 73 0.1691148 0.2919384
## 74 74 0.1691148 0.2943328
## 75 75 0.1691148 0.2943328
## 76 76 0.1691148 0.2943328
## 77 77 0.1691148 0.2943328
## 78 78 0.1691148 0.2943328
## 79 79 0.1691148 0.2943328
## 80 80 0.1691148 0.2943328
## 81 81 0.1691148 0.2943328
## 82 82 0.1690296 0.2943328
## 83 83 0.1690296 0.2943328
## 84 84 0.1689966 0.2943328
## 85 85 0.1689966 0.2943328
## 86 86 0.1689966 0.2943328
## 87 87 0.1689966 0.2943328
## 88 88 0.1689966 0.2943328
## 89 89 0.1689966 0.2943328
## 90 90 0.1689966 0.2943328
## 91 91 0.1689966 0.2943328
## 92 92 0.1689966 0.2943328
## 93 93 0.1689966 0.2959032
## 94 94 0.1689966 0.2959032
## 95 95 0.1689966 0.2959032
## 96 96 0.1689966 0.2959032
## 97 97 0.1689966 0.2959032
## 98 98 0.1689966 0.2966774
## 99 99 0.1689966 0.2966774
## 100 100 0.1689922 0.2997415
## 101 101 0.1689922 0.2997415
## 102 102 0.1689922 0.2997415
## 103 103 0.1689922 0.2997415
## 104 104 0.1689922 0.2997415
## 105 105 0.1689922 0.2997415
## 106 106 0.1689922 0.2997415
## 107 107 0.1689922 0.2997415
## 108 108 0.1689922 0.2997415
## 109 109 0.1689922 0.2997415
## 110 110 0.1689922 0.2997415
## 111 111 0.1689922 0.2997415
## 112 112 0.1689922 0.2997415
## 113 113 0.1689922 0.2997415
## 114 114 0.1689922 0.2997415
## 115 115 0.1689922 0.2997415
## 116 116 0.1689922 0.2997415
## 117 117 0.1689922 0.2997415
## 118 118 0.1689922 0.2997415
## 119 119 0.1689922 0.2997415
## 120 120 0.1689922 0.2997415
## 121 121 0.1689922 0.2997415
## 122 122 0.1689922 0.2997415
## 123 123 0.1689922 0.2997415
## 124 124 0.1689922 0.2997415
## 125 125 0.1689922 0.2997415
## 126 126 0.1689922 0.2997415
## 127 127 0.1689922 0.2997415
## 128 128 0.1689922 0.3006859
## 129 129 0.1689653 0.3006859
## 130 130 0.1689653 0.3006859
## 131 131 0.1689653 0.3006859
## 132 132 0.1689653 0.3006859
## 133 133 0.1689653 0.3006859
## 134 134 0.1689653 0.3006859
## 135 135 0.1689653 0.3006859
## 136 136 0.1689653 0.3006859
## 137 137 0.1689653 0.3007455
## 138 138 0.1689653 0.3007455
## 139 139 0.1689566 0.3007455
## 140 140 0.1689566 0.3007455
## 141 141 0.1689566 0.3007455
## 142 142 0.1689566 0.3010206
## 143 143 0.1689566 0.3010206
## 144 144 0.1689566 0.3010206
## 145 145 0.1689566 0.3010206
## 146 146 0.1689566 0.3010206
## 147 147 0.1689566 0.3010206
## 148 148 0.1689566 0.3010206
## 149 149 0.1689566 0.3010206
## 150 150 0.1689566 0.3011122
## 151 151 0.1689566 0.3011122
## 152 152 0.1689566 0.3011122
## 153 153 0.1689566 0.3011122
## 154 154 0.1689566 0.3011122
## 155 155 0.1689566 0.3011122
## 156 156 0.1689566 0.3022828
## 157 157 0.1689566 0.3022828
## 158 158 0.1689566 0.3022828
## 159 159 0.1689566 0.3022828
## 160 160 0.1689566 0.3022828
## 161 161 0.1689566 0.3022828
## 162 162 0.1689566 0.3022828
## 163 163 0.1689566 0.3022828
## 164 164 0.1689566 0.3022828
## 165 165 0.1689566 0.3022828
## 166 166 0.1689566 0.3022828
## 167 167 0.1689566 0.3022828
## 168 168 0.1689566 0.3022828
## 169 169 0.1689566 0.3022828
## 170 170 0.1689566 0.3022828
## 171 171 0.1689430 0.3022828
## 172 172 0.1689430 0.3022828
## 173 173 0.1689430 0.3022828
## 174 174 0.1689430 0.3022828
## 175 175 0.1689430 0.3024687
## 176 176 0.1689430 0.3024687
## 177 177 0.1689430 0.3024687
## 178 178 0.1689430 0.3024687
## 179 179 0.1689430 0.3024687
## 180 180 0.1689430 0.3024687
## 181 181 0.1689430 0.3024687
## 182 182 0.1689430 0.3024687
## 183 183 0.1689430 0.3024687
## 184 184 0.1689430 0.3024687
## 185 185 0.1689430 0.3024687
## 186 186 0.1689430 0.3024687
## 187 187 0.1689430 0.3024687
## 188 188 0.1689430 0.3024687
## 189 189 0.1689430 0.3024687
## 190 190 0.1689430 0.3024687
## 191 191 0.1689430 0.3024687
## 192 192 0.1689430 0.3024687
## 193 193 0.1689430 0.3024687
## 194 194 0.1689430 0.3030473
## 195 195 0.1689430 0.3030473
## 196 196 0.1689430 0.3030473
## 197 197 0.1689430 0.3030473
## 198 198 0.1689430 0.3030473
## 199 199 0.1689430 0.3030473
## 200 200 0.1689430 0.3030473
## =============================================
## 8) EMPAT PORTOFOLIO TERPILIH:
## (1) Cluster manual + (2-4) MOCv-ABC k=3/4/5 (Sharpe tertinggi)
## =============================================
# Helper metrik & pemilihan berdasarkan Sharpe
portfolio_metrics <- function(W, mu, S, rf) {
ER <- as.numeric(W %*% mu)
VAR <- apply(W, 1, function(w) as.numeric(t(w) %*% S %*% w))
SD <- sqrt(VAR)
Sharpe <- (ER - rf) / pmax(SD, 1e-12)
data.frame(ER = ER, SD = SD, Sharpe = Sharpe, row.names = NULL)
}
pick_best_by_k <- function(P, mu, S, rf, idx_rows) {
if (length(idx_rows) == 0) return(NULL)
Wk <- P[idx_rows, , drop = FALSE]; M <- portfolio_metrics(Wk, mu, S, rf)
i_local <- which.max(M$Sharpe)
list(w = Wk[i_local, ], ER = M$ER[i_local], SD = M$SD[i_local], Sharpe = M$Sharpe[i_local],
idx_global = idx_rows[i_local])
}
# (A) Portofolio #1 dari hasil cluster/dendrogram (input manual saham)
cluster_manual <- c("PGAS", "EXCL", "MTEL", "TLKM", "BBRI") # EDIT sesuai hasil dendrogrammu
stopifnot(all(cluster_manual %in% tickers))
sub_idx <- match(cluster_manual, tickers)
mu_sub <- mu[sub_idx]; S_sub <- S[sub_idx, sub_idx, drop = FALSE]
tkr_sub <- tickers[sub_idx]; k_sub <- length(tkr_sub)
stopifnot(alpha_w * k_sub <= 1 + 1e-12, beta_w * k_sub >= 1 - 1e-12)
res_cluster <- run_mcabc(mu = mu_sub, S = S_sub, tickers = tkr_sub,
alpha = alpha_w, beta = beta_w,
d_min = k_sub, d_max = k_sub,
must_have_name = "", sigma = sigma_abc,
limit_factor = limit_factor, max_iter = max_iter, debug = FALSE)
P_cluster <- res_cluster$P
met_c <- portfolio_metrics(P_cluster, mu_sub, S_sub, rf)
best_c_id <- which.max(met_c$Sharpe)
w_best_sub <- as.numeric(P_cluster[best_c_id, ])
# KUNCI: paksa proyeksi ulang tepat di simplex [α,β] dan sum=1
w_best_sub <- project_box_simplex(
w = w_best_sub, L = alpha_w, U = beta_w, s = 1
)
# Jika masih ada nol karena numerik, aktifkan semuanya lalu proyeksi lagi
if (sum(w_best_sub > 1e-12) < length(w_best_sub)) {
w_best_sub[] <- pmax(w_best_sub, alpha_w)
w_best_sub <- project_box_simplex(w_best_sub, L = alpha_w, U = beta_w, s = 1)
}
stopifnot(all(w_best_sub > 0))
# Ekspansi ke universe penuh
w_cluster <- numeric(length(tickers))
names(w_cluster) <- tickers
w_cluster[tkr_sub] <- w_best_sub
# (B) Portofolio #2-#4 dari sekali run MOCv-ABC (k=3,4,5; Sharpe terbesar)
P_all <- mcabc_cov$P; k_count <- rowSums(P_all > 1e-8)
idx_k3 <- which(k_count == 3L); idx_k4 <- which(k_count == 4L); idx_k5 <- which(k_count == 5L)
best_k3 <- pick_best_by_k(P_all, mu, S, rf, idx_k3)
best_k4 <- pick_best_by_k(P_all, mu, S, rf, idx_k4)
best_k5 <- pick_best_by_k(P_all, mu, S, rf, idx_k5)
if (is.null(best_k3)) message("Tidak ada k=3 pada run ini—pertimbangkan menambah iterasi/seed.")
if (is.null(best_k4)) message("Tidak ada k=4 pada run ini—pertimbangkan menambah iterasi/seed.")
if (is.null(best_k5)) message("Tidak ada k=5 pada run ini—pertimbangkan menambah iterasi/seed.")
W_list <- list(
`Cluster` = w_cluster,
`ABC_k3` = if (!is.null(best_k3)) best_k3$w else rep(NA_real_, length(tickers)),
`ABC_k4` = if (!is.null(best_k4)) best_k4$w else rep(NA_real_, length(tickers)),
`ABC_k5` = if (!is.null(best_k5)) best_k5$w else rep(NA_real_, length(tickers))
)
W4 <- do.call(rbind, W_list); colnames(W4) <- tickers
M4 <- portfolio_metrics(W4, mu, S, rf)
opt4_summary <- data.frame(Portfolio = rownames(W4), M4, row.names = NULL)
print(opt4_summary)
## Portfolio ER SD Sharpe
## 1 Cluster 0.0592954 0.1862985 0.06331452
## 2 ABC_k3 0.2832434 0.3129769 0.75322950
## 3 ABC_k4 0.2877790 0.2947821 0.81510733
## 4 ABC_k5 0.2740621 0.2773238 0.81695868
# Return harian 4 portofolio (untuk VaR & backtest)
common <- intersect(colnames(rets_xts), colnames(W4))
rets_use <- rets_xts[, common, drop = FALSE]
R4_mat <- as.matrix(rets_use) %*% as.matrix(t(W4[, common, drop = FALSE]))
opt4_daily_log_returns <- xts::xts(R4_mat, order.by = index(rets_use))
colnames(opt4_daily_log_returns) <- rownames(W4)
# Scatterplot Risiko–Return (Plotly, label rapi)
text_positions <- c("top center","bottom center","top right","bottom right")
pos_vec <- rep(text_positions, length.out = nrow(opt4_summary))
y_offset <- (max(opt4_summary$ER) - min(opt4_summary$ER)) * 0.03
opt4_summary$label_y <- opt4_summary$ER + y_offset * c(1,-1,1,-1)
opt4_summary$label_hover <- paste0(
"<b>", opt4_summary$Portfolio, "</b><br>",
"Expected Return: ", sprintf("%.4f", opt4_summary$ER), "<br>",
"Risk (SD): ", sprintf("%.4f", opt4_summary$SD), "<br>",
"Sharpe Ratio: ", sprintf("%.4f", opt4_summary$Sharpe)
)
p_opt4_plotly_clean <- plot_ly(
data = opt4_summary, x = ~SD, y = ~ER, type = "scatter", mode = "markers+text",
text = ~Portfolio, textposition = pos_vec,
textfont = list(size = 12, color = "black", family = "Arial"),
marker = list(size = 14, color = ~Sharpe, colorscale = list(c(0,1), c("#d7191c","#1a9641")),
line = list(color = "black", width = 1)),
hovertemplate = opt4_summary$label_hover, showlegend = FALSE
) %>%
layout(
title = list(text = "Scatterplot Risiko–Return", x = 0.03, y = 0.97,
xanchor = "left", font = list(size = 18)),
xaxis = list(title = "Risiko (SD, annualized)", tickformat = ".2f",
gridcolor = "rgba(200,200,200,0.3)", zeroline = TRUE),
yaxis = list(title = "Expected Return (annualized)", tickformat = ".2f",
gridcolor = "rgba(200,200,200,0.3)", zeroline = TRUE),
margin = list(t = 90, r = 40, b = 60, l = 70),
paper_bgcolor = "white", plot_bgcolor = "white"
)
p_opt4_plotly_clean
## =======================================================
## TABEL SAHAM TERPILIH & BOBOT OPTIMAL (4 PORTOFOLIO)
## =======================================================
# Pastikan paket gt aktif
if (!requireNamespace("gt", quietly = TRUE)) install.packages("gt")
library(gt)
# Konversi W4 (4xN matrix) menjadi bentuk long agar mudah ditampilkan
w_long <- W4 |>
as.data.frame() |>
tibble::rownames_to_column("Portfolio") |>
tidyr::pivot_longer(-Portfolio, names_to = "Saham", values_to = "Bobot") |>
dplyr::filter(Bobot > 1e-6) |> # tampilkan hanya saham aktif
dplyr::arrange(Portfolio, desc(Bobot)) |>
dplyr::mutate(Bobot = round(Bobot, 4))
# Gabungkan dengan return & SD individual untuk informasi tambahan (opsional)
w_long <- w_long |>
left_join(stats_df |> select(ticker, mean_ann, sd_ann),
by = c("Saham" = "ticker")) |>
rename(`Return (ann.)` = mean_ann, `SD (ann.)` = sd_ann)
# Buat tabel gt
gt_port_weights <- w_long |>
gt(groupname_col = "Portfolio") |>
fmt_number(columns = c(Bobot, `Return (ann.)`, `SD (ann.)`), decimals = 4) |>
cols_label(
Saham = "Kode Saham",
Bobot = html("Bobot<br/>(Optimal)"),
`Return (ann.)` = html("Return<br/>(Annualized)"),
`SD (ann.)` = html("Risiko (SD<br/>Annualized)")
) |>
data_color(
columns = Bobot,
colors = scales::col_numeric(
palette = c("#f7fbff", "#6baed6", "#08306b"),
domain = NULL
)
) |>
tab_header(
title = md("**Saham Terpilih dan Bobot Optimal per Portofolio**"),
subtitle = md("Menampilkan kombinasi saham aktif dan proporsi bobot hasil optimasi MOCv-ABC & Cluster Manual")
) |>
tab_options(
table.font.size = px(13),
data_row.padding = px(4)
)
gt_port_weights
| Saham Terpilih dan Bobot Optimal per Portofolio |
| Menampilkan kombinasi saham aktif dan proporsi bobot hasil optimasi MOCv-ABC & Cluster Manual |
| Kode Saham |
Bobot (Optimal) |
Return (Annualized) |
Risiko (SD Annualized) |
| ABC_k3 |
| MEDC |
0.4000 |
0.3524 |
0.4963 |
| SSIA |
0.4000 |
0.3688 |
0.5521 |
| TLKM |
0.2000 |
−0.0263 |
0.2756 |
| ABC_k4 |
| MEDC |
0.3737 |
0.3524 |
0.4963 |
| SSIA |
0.3282 |
0.3688 |
0.5521 |
| UNTR |
0.1981 |
0.1901 |
0.3281 |
| TLKM |
0.1000 |
−0.0263 |
0.2756 |
| ABC_k5 |
| MEDC |
0.3279 |
0.3524 |
0.4963 |
| SSIA |
0.3099 |
0.3688 |
0.5521 |
| UNTR |
0.1623 |
0.1901 |
0.3281 |
| PGAS |
0.1000 |
0.1601 |
0.3080 |
| TLKM |
0.1000 |
−0.0263 |
0.2756 |
| Cluster |
| PGAS |
0.3333 |
0.1601 |
0.3080 |
| BBRI |
0.3255 |
0.0542 |
0.2871 |
| TLKM |
0.1411 |
−0.0263 |
0.2756 |
| EXCL |
0.1000 |
−0.0157 |
0.3368 |
| MTEL |
0.1000 |
−0.0641 |
0.2778 |
## =============================================
## 9) EFFICIENT FRONTIER — overlay 4 portofolio terpilih
## Dua varian (A: dengan bound & wajib; B: hanya kardinalitas)
## Tiga plot per k (k=3,4,5) → total 6 plot
## =============================================
# Util metrik & kardinalitas
metrics_from_W <- function(W, mu, S, rf) {
ER <- as.numeric(W %*% mu)
VAR <- apply(W, 1, function(w) as.numeric(t(w) %*% S %*% w))
SD <- sqrt(VAR)
Sharpe <- (ER - rf) / pmax(SD, 1e-12)
data.frame(ER = ER, SD = SD, Sharpe = Sharpe)
}
k_card <- function(W, eps = 1e-8) rowSums(W > eps)
# Layer overlay untuk 4 portofolio (highlight k yang sama)
overlay_layer <- function(p, overlay_df, k_focus) {
overlay_df$alpha_pt <- ifelse(overlay_df$k == k_focus, 1, 0.35)
overlay_df$size_pt <- ifelse(overlay_df$k == k_focus, 3.8, 3.0)
overlay_df$stroke <- ifelse(overlay_df$k == k_focus, 1.2, 0.6)
p +
geom_point(data = overlay_df, aes(SD, ER), shape = 21, fill = "skyblue", color = "black",
size = overlay_df$size_pt, stroke = overlay_df$stroke, alpha = overlay_df$alpha_pt) +
ggrepel::geom_text_repel(
data = overlay_df, aes(SD, ER, label = Portfolio),
size = 3.0, max.overlaps = 50, seed = 42, box.padding = 0.25, point.padding = 0.3
)
}
pal_grad <- colorRampPalette(c("#d7191c","#fdae61","lightgreen","#1a9641"))(256)
k_overlay <- k_card(W4)
overlay_df <- cbind(opt4_summary, k = k_overlay) # kolom: Portfolio, ER, SD, Sharpe, k
# Varian A: batas bobot [alpha_w, beta_w], sum=1, PGAS wajib
set.seed(123)
d_all <- length(tickers)
m_idx <- match("PGAS", tickers); if (is.na(m_idx)) m_idx <- NULL
rand_weights_A <- function(k, n = 600L) {
stopifnot(alpha_w * k <= 1 + 1e-12, beta_w * k >= 1 - 1e-12)
W <- matrix(NA_real_, nrow = n, ncol = d_all); colnames(W) <- tickers
for (i in 1:n) {
w0 <- runif(d_all)
W[i, ] <- clean_portfolio(w0, d_all, alpha_w, beta_w, d_min = k, d_max = k, must_have_idx_or_NULL = m_idx)
}
W
}
make_frontier_plot_A <- function(k, xlim, ylim) {
W_rand <- rand_weights_A(k, n = 600)
df_rand <- metrics_from_W(W_rand, mu, S, rf)
p <- ggplot(df_rand, aes(SD, ER, color = Sharpe)) +
coord_cartesian(xlim = xlim, ylim = ylim) +
geom_point(alpha = 0.85, size = 1.8) +
scale_color_gradientn(colors = pal_grad) +
labs(title = paste0("Efficient Frontier — Varian A (k = ", k, ")"),
subtitle = "Random portfolios dengan batas bobot [α,β] & PGAS wajib",
x = "Risiko (SD, annualized)", y = "Expected Return (annualized)", color = "Sharpe") +
theme_minimal(base_size = 12)
overlay_layer(p, overlay_df, k_focus = k) |> plotly::ggplotly()
}
pA_k3 <- make_frontier_plot_A(3, xlim = c(0.15, 0.51), ylim = c(-0.3, 0.4))
## Warning in geom2trace.default(dots[[1L]][[1L]], dots[[2L]][[1L]], dots[[3L]][[1L]]): geom_GeomTextRepel() has yet to be implemented in plotly.
## If you'd like to see this geom implemented,
## Please open an issue with your example code at
## https://github.com/ropensci/plotly/issues
pA_k4 <- make_frontier_plot_A(4, xlim = c(0.15, 0.4), ylim = c(-0.3, 0.35))
## Warning in geom2trace.default(dots[[1L]][[1L]], dots[[2L]][[1L]], dots[[3L]][[1L]]): geom_GeomTextRepel() has yet to be implemented in plotly.
## If you'd like to see this geom implemented,
## Please open an issue with your example code at
## https://github.com/ropensci/plotly/issues
pA_k5 <- make_frontier_plot_A(5, xlim = c(0.15, 0.38), ylim = c(-0.2, 0.35))
## Warning in geom2trace.default(dots[[1L]][[1L]], dots[[2L]][[1L]], dots[[3L]][[1L]]): geom_GeomTextRepel() has yet to be implemented in plotly.
## If you'd like to see this geom implemented,
## Please open an issue with your example code at
## https://github.com/ropensci/plotly/issues
# Varian B: hanya kardinalitas = k (tanpa batas bobot & tanpa wajib)
set.seed(123)
rand_weights_B <- function(k, n = 600L) {
W <- matrix(0, nrow = n, ncol = d_all); colnames(W) <- tickers
for (i in 1:n) {
idx <- sample.int(d_all, k, replace = FALSE)
w <- runif(k); w <- w / sum(w)
W[i, idx] <- w
}
W
}
make_frontier_plot_B <- function(k, xlim, ylim) {
W_rand <- rand_weights_B(k, n = 600)
df_rand <- metrics_from_W(W_rand, mu, S, rf)
p <- ggplot(df_rand, aes(SD, ER, color = Sharpe)) +
coord_cartesian(xlim = xlim, ylim = ylim) +
geom_point(alpha = 0.85, size = 1.8) +
scale_color_gradientn(colors = pal_grad) +
labs(title = paste0("Efficient Frontier — Varian B (k = ", k, ")"),
subtitle = "Random portfolios kardinalitas saja (tanpa batas bobot & wajib)",
x = "Risiko (SD, annualized)", y = "Expected Return (annualized)", color = "Sharpe") +
theme_minimal(base_size = 12)
overlay_layer(p, overlay_df, k_focus = k) |> plotly::ggplotly()
}
pB_k3 <- make_frontier_plot_B(3, xlim = c(0.15, 0.51), ylim = c(-0.3, 0.4))
## Warning in geom2trace.default(dots[[1L]][[1L]], dots[[2L]][[1L]], dots[[3L]][[1L]]): geom_GeomTextRepel() has yet to be implemented in plotly.
## If you'd like to see this geom implemented,
## Please open an issue with your example code at
## https://github.com/ropensci/plotly/issues
pB_k4 <- make_frontier_plot_B(4, xlim = c(0.15, 0.4), ylim = c(-0.3, 0.35))
## Warning in geom2trace.default(dots[[1L]][[1L]], dots[[2L]][[1L]], dots[[3L]][[1L]]): geom_GeomTextRepel() has yet to be implemented in plotly.
## If you'd like to see this geom implemented,
## Please open an issue with your example code at
## https://github.com/ropensci/plotly/issues
pB_k5 <- make_frontier_plot_B(5, xlim = c(0.15, 0.38), ylim = c(-0.2, 0.35))
## Warning in geom2trace.default(dots[[1L]][[1L]], dots[[2L]][[1L]], dots[[3L]][[1L]]): geom_GeomTextRepel() has yet to be implemented in plotly.
## If you'd like to see this geom implemented,
## Please open an issue with your example code at
## https://github.com/ropensci/plotly/issues
# Tampilkan (opsional)
pA_k3; pB_k3
pA_k4; pB_k4
pA_k5; pB_k5
## =============================================
## 10) VAR CORNISH–FISHER (harian) UNTUK 4 PORTOFOLIO
## =============================================
# Statistik harian
Rport_daily <- as.matrix(opt4_daily_log_returns) # [T x 4]
port_names <- colnames(Rport_daily)
mu_d <- colMeans(Rport_daily, na.rm = TRUE)
sd_d <- apply(Rport_daily, 2, sd, na.rm = TRUE)
sk_d <- apply(Rport_daily, 2, function(x) moments::skewness(x, na.rm = TRUE))
kurt_d <- apply(Rport_daily, 2, function(x) moments::kurtosis(x, na.rm = TRUE))
ex_kurt_d <- kurt_d - 3
stats_top4_daily <- data.frame(
Portfolio = port_names, mu_d = mu_d, sd_d = sd_d, skew = sk_d, kurt = kurt_d, ex_kurt = ex_kurt_d
)
# CF quantile & VaR
cf_transform <- function(z, skew, ex_kurt) {
z + (1/6)*(z^2 - 1)*skew + (1/24)*(z^3 - 3*z)*ex_kurt + (1/36)*(2*z^3 - 5*z)*(skew^2)
}
cf_quantile <- function(alpha, skew, ex_kurt) {
a <- qnorm(alpha)
a + (skew/6)*(a^2 - 1) + (ex_kurt/24)*(a^3 - 3*a) - ((skew^2)/36)*(2*a^3 - 5*a)
}
var_cf_vector <- function(mu, sd, skew, ex_kurt, alpha, V0 = 1, hp = 1) {
a_cf <- cf_quantile(alpha, skew, ex_kurt)
VaR_CF <- -V0 * (mu - a_cf * sd) * hp
list(VaR_CF = VaR_CF, a_cf = a_cf)
}
res_list <- mapply(
var_cf_vector, mu = mu_d, sd = sd_d, skew = sk_d, ex_kurt = ex_kurt_d,
MoreArgs = list(alpha = alpha, V0 = V0, hp = hp), SIMPLIFY = FALSE
)
VaR_CF_vals <- sapply(res_list, function(x) x$VaR_CF)
a_cf_vals <- sapply(res_list, function(x) x$a_cf)
VaR_CF_summary <- data.frame(
Portfolio = port_names, CL = cl, alpha = alpha,
mu_d = mu_d, sd_d = sd_d, skew = sk_d, kurt = kurt_d, excess_k = ex_kurt_d,
a_cf = a_cf_vals, VaR_CF = VaR_CF_vals
)
VaR_CF_summary
## Portfolio CL alpha mu_d sd_d skew kurt
## Cluster Cluster 0.95 0.05 0.0002352992 0.01173571 -0.356812016 7.063192
## ABC_k3 ABC_k3 0.95 0.05 0.0011239817 0.01971569 0.304637208 5.251243
## ABC_k4 ABC_k4 0.95 0.05 0.0011419803 0.01856953 0.039122898 5.719642
## ABC_k5 ABC_k5 0.95 0.05 0.0010875478 0.01746975 -0.001683576 5.806608
## excess_k a_cf VaR_CF
## Cluster 4.063192 -1.661890 -0.01973876
## ABC_k3 2.251243 -1.511083 -0.03091603
## ABC_k4 2.719642 -1.578820 -0.03045991
## ABC_k5 2.806608 -1.588693 -0.02884162
## =============================================
# 11) BACKTEST VAR: KUPIEC + PLOT INTERAKTIF & TABEL GT
## =============================================
# Kupiec (unconditional coverage) — breach jika return < VaR (VaR negatif)
rets_mat <- as.matrix(opt4_daily_log_returns)
port_names <- colnames(rets_mat)
VaR_compare_vec <- VaR_CF_summary$VaR_CF[seq_along(port_names)]
kupiec_test <- function(returns, VaR, alpha = 0.05) {
x <- as.numeric(returns)
v <- as.numeric(VaR); if (length(v) == 1) v <- rep(v, length(x))
I <- as.integer(x < v) # breach jika return < VaR (ingat VaR negatif)
I <- I[is.finite(I)]
n <- length(I); xB <- sum(I)
if (n == 0) return(data.frame(n = 0, x = NA, prop = NA, LR_uc = NA, p_value = NA))
p_hat <- pmax(pmin(xB / n, 1 - 1e-12), 1e-12)
LR_uc <- -2 * ( (n - xB)*log(1 - alpha) + xB*log(alpha) - (n - xB)*log(1 - p_hat) - xB*log(p_hat) )
p_uc <- 1 - pchisq(LR_uc, df = 1)
data.frame(n = n, x = xB, prop = xB/n, LR_uc = LR_uc, p_value = p_uc)
}
results_list <- lapply(seq_along(port_names), function(i) {
ku <- kupiec_test(rets_mat[, i], VaR_compare_vec[i], alpha = alpha)
data.frame(Portfolio = port_names[i], CL = 1 - alpha,
Violations = ku$x, ViolationRate = ku$prop,
Kupiec_LR = ku$LR_uc, Kupiec_p = ku$p_value)
})
VaR_backtest_table <- do.call(rbind, results_list)
VaR_backtest_table
## Portfolio CL Violations ViolationRate Kupiec_LR Kupiec_p
## 1 Cluster 0.95 38 0.04255319 1.095613 0.29523094
## 2 ABC_k3 0.95 34 0.03807391 2.903565 0.08838391
## 3 ABC_k4 0.95 37 0.04143337 1.461484 0.22669410
## 4 ABC_k5 0.95 36 0.04031355 1.883753 0.16990822
# Data untuk plot/gt (built from scratch agar konsisten dengan Kupiec)
if (exists("plot_df", inherits = FALSE)) rm(plot_df)
ret_df <- opt4_daily_log_returns |>
as.data.frame() |> tibble::rownames_to_column("date") |>
dplyr::mutate(date = as.Date(date)) |>
tidyr::pivot_longer(-date, names_to = "Portfolio", values_to = "ret")
var_df <- VaR_CF_summary |> dplyr::transmute(Portfolio, VaR_line = VaR_CF) # NEGATIF
plot_df <- dplyr::left_join(ret_df, var_df, by = "Portfolio") |>
dplyr::mutate(breach = ret < VaR_line, breach_n = ave(breach, Portfolio, FUN = cumsum))
# validasi jumlah breach plot_df vs hasil kupiec
check_cnt <- plot_df |>
dplyr::group_by(Portfolio) |>
dplyr::summarise(breach = sum(breach), .groups = "drop")
print(check_cnt)
## # A tibble: 4 × 2
## Portfolio breach
## <chr> <int>
## 1 ABC_k3 34
## 2 ABC_k4 37
## 3 ABC_k5 36
## 4 Cluster 38
# Plot pelanggaran per-portofolio (plotly)
plot_var_breach <- function(port_name) {
dat <- plot_df |> filter(Portfolio == port_name) |> arrange(date)
var_line <- unique(dat$VaR_line); stopifnot(length(var_line) == 1)
p <- plot_ly()
p <- add_bars(p, data = dat, x = ~date, y = ~ret, name = "Return",
legendgroup = "g1",
marker = list(line = list(width = 0)),
hovertemplate = "<b>%{x}</b><br>Return: %{y:.4f}<extra>Return</extra>")
p <- add_lines(p, data = dat, x = ~date, y = ~VaR_line, name = "VaR (ambang)",
legendgroup = "g2", line = list(width = 2), hoverinfo = "skip")
p <- add_markers(p, data = subset(dat, breach), x = ~date, y = ~ret, name = "Pelanggaran",
legendgroup = "g3", marker = list(size = 8, symbol = "circle-open-dot", color = "red"),
customdata = ~breach_n,
hovertemplate = paste0("<b>%{x}</b><br>",
"Return: %{y:.4f}<br>",
"Ambang VaR: ", sprintf("%.4f", var_line), "<br>",
"<b>PELANGGARAN</b> # %{customdata}<extra>Pelanggaran</extra>"))
layout(p,
title = list(text = paste0("Plot Pelanggaran VaR — ", port_name),
x = 0.02, y = 0.98, xanchor = "left", yanchor = "top"),
margin = list(t = 90, r = 20, b = 40, l = 55),
legend = list(orientation = "h", x = 0.02, y = 1.05, xanchor = "left", yanchor = "bottom",
bgcolor = "rgba(255,255,255,0.6)"),
xaxis = list(title = NULL, automargin = TRUE, rangeslider = list(visible = TRUE)),
yaxis = list(title = "Return harian", automargin = TRUE),
barmode = "overlay")
}
plot_var_breach("Cluster")
plot_var_breach("ABC_k3")
plot_var_breach("ABC_k4")
plot_var_breach("ABC_k5")
# Tabel gt: daftar tanggal pelanggaran & ringkasannya
var_breach_table_gt <- function(port_name, digits = 4) {
dat_port <- plot_df |> dplyr::filter(Portfolio == port_name)
stopifnot(nrow(dat_port) > 0)
n_total <- nrow(dat_port)
n_breach <- sum(dat_port$breach, na.rm = TRUE)
rate <- n_breach / n_total
df_breach <- dat_port |>
dplyr::filter(breach) |>
arrange(date) |>
transmute(
Tanggal = date,
`Return` = ret,
`VaR` = VaR_line,
`Selisih`= ret - VaR_line,
`Ke-` = breach_n
)
if (nrow(df_breach) == 0) {
df_breach <- tibble::tibble(Tanggal = as.Date(character()),
`Return` = numeric(), `VaR` = numeric(),
`Selisih` = numeric(), `Ke-` = integer())
}
df_breach |>
gt() |>
fmt_number(columns = c(`Return`,`VaR`,`Selisih`), decimals = digits) |>
data_color(columns = c(`Return`), colors = col_bin(palette = c("#CC3232","#2DC937"), bins = c(-Inf,0,Inf))) |>
tab_header(
title = md(paste0("**Pelanggaran VaR ", port_name, "**")),
subtitle = md(paste0("Total observasi: **", n_total,
"** · Jumlah pelanggaran: **", n_breach,
"** · Violation rate: **", scales::percent(rate, accuracy = 0.01), "**"))
) |>
cols_label(`Ke-` = "Pelanggaran ke-") |>
tab_source_note(md("Catatan: Pelanggaran terjadi saat **return < VaR** (ambang return minimum harian)."))
}
var_breach_table_gt("Cluster")
| Pelanggaran VaR Cluster |
| Total observasi: 893 · Jumlah pelanggaran: 38 · Violation rate: 4.26% |
| Tanggal |
Return |
VaR |
Selisih |
Pelanggaran ke- |
| 2022-03-08 |
−0.0200 |
−0.0197 |
−0.0003 |
1 |
| 2022-03-10 |
−0.0230 |
−0.0197 |
−0.0033 |
2 |
| 2022-05-09 |
−0.0395 |
−0.0197 |
−0.0198 |
3 |
| 2022-05-12 |
−0.0329 |
−0.0197 |
−0.0132 |
4 |
| 2022-07-04 |
−0.0261 |
−0.0197 |
−0.0063 |
5 |
| 2022-07-06 |
−0.0200 |
−0.0197 |
−0.0002 |
6 |
| 2022-09-07 |
−0.0216 |
−0.0197 |
−0.0019 |
7 |
| 2023-01-04 |
−0.0255 |
−0.0197 |
−0.0057 |
8 |
| 2023-01-05 |
−0.0348 |
−0.0197 |
−0.0151 |
9 |
| 2023-03-14 |
−0.0304 |
−0.0197 |
−0.0107 |
10 |
| 2023-03-20 |
−0.0215 |
−0.0197 |
−0.0017 |
11 |
| 2023-09-07 |
−0.0274 |
−0.0197 |
−0.0077 |
12 |
| 2023-10-26 |
−0.0320 |
−0.0197 |
−0.0123 |
13 |
| 2023-10-30 |
−0.0256 |
−0.0197 |
−0.0059 |
14 |
| 2023-11-01 |
−0.0234 |
−0.0197 |
−0.0037 |
15 |
| 2024-04-16 |
−0.0341 |
−0.0197 |
−0.0143 |
16 |
| 2024-04-19 |
−0.0243 |
−0.0197 |
−0.0046 |
17 |
| 2024-04-26 |
−0.0325 |
−0.0197 |
−0.0127 |
18 |
| 2024-05-27 |
−0.0225 |
−0.0197 |
−0.0028 |
19 |
| 2024-06-14 |
−0.0278 |
−0.0197 |
−0.0080 |
20 |
| 2024-08-05 |
−0.0332 |
−0.0197 |
−0.0134 |
21 |
| 2024-08-27 |
−0.0280 |
−0.0197 |
−0.0083 |
22 |
| 2024-09-30 |
−0.0222 |
−0.0197 |
−0.0025 |
23 |
| 2024-10-02 |
−0.0226 |
−0.0197 |
−0.0028 |
24 |
| 2024-11-11 |
−0.0201 |
−0.0197 |
−0.0004 |
25 |
| 2024-12-17 |
−0.0209 |
−0.0197 |
−0.0012 |
26 |
| 2025-01-07 |
−0.0249 |
−0.0197 |
−0.0051 |
27 |
| 2025-01-14 |
−0.0204 |
−0.0197 |
−0.0007 |
28 |
| 2025-02-06 |
−0.0311 |
−0.0197 |
−0.0114 |
29 |
| 2025-02-25 |
−0.0265 |
−0.0197 |
−0.0068 |
30 |
| 2025-02-27 |
−0.0355 |
−0.0197 |
−0.0158 |
31 |
| 2025-02-28 |
−0.0434 |
−0.0197 |
−0.0237 |
32 |
| 2025-04-08 |
−0.0846 |
−0.0197 |
−0.0649 |
33 |
| 2025-05-08 |
−0.0224 |
−0.0197 |
−0.0027 |
34 |
| 2025-06-02 |
−0.0201 |
−0.0197 |
−0.0004 |
35 |
| 2025-06-19 |
−0.0214 |
−0.0197 |
−0.0017 |
36 |
| 2025-08-27 |
−0.0204 |
−0.0197 |
−0.0006 |
37 |
| 2025-09-08 |
−0.0226 |
−0.0197 |
−0.0029 |
38 |
| Catatan: Pelanggaran terjadi saat return < VaR (ambang return minimum harian). |
var_breach_table_gt("ABC_k3")
| Pelanggaran VaR ABC_k3 |
| Total observasi: 893 · Jumlah pelanggaran: 34 · Violation rate: 3.81% |
| Tanggal |
Return |
VaR |
Selisih |
Pelanggaran ke- |
| 2022-01-25 |
−0.0371 |
−0.0309 |
−0.0061 |
1 |
| 2022-02-16 |
−0.0351 |
−0.0309 |
−0.0042 |
2 |
| 2022-02-21 |
−0.0377 |
−0.0309 |
−0.0068 |
3 |
| 2022-07-01 |
−0.0504 |
−0.0309 |
−0.0195 |
4 |
| 2022-09-16 |
−0.0313 |
−0.0309 |
−0.0004 |
5 |
| 2022-10-18 |
−0.0327 |
−0.0309 |
−0.0018 |
6 |
| 2022-11-18 |
−0.0360 |
−0.0309 |
−0.0051 |
7 |
| 2023-01-05 |
−0.0375 |
−0.0309 |
−0.0066 |
8 |
| 2023-02-02 |
−0.0380 |
−0.0309 |
−0.0071 |
9 |
| 2023-02-09 |
−0.0391 |
−0.0309 |
−0.0082 |
10 |
| 2023-02-10 |
−0.0387 |
−0.0309 |
−0.0078 |
11 |
| 2023-02-17 |
−0.0415 |
−0.0309 |
−0.0106 |
12 |
| 2023-03-06 |
−0.0370 |
−0.0309 |
−0.0060 |
13 |
| 2023-03-14 |
−0.0474 |
−0.0309 |
−0.0165 |
14 |
| 2023-03-16 |
−0.0355 |
−0.0309 |
−0.0045 |
15 |
| 2023-03-20 |
−0.0412 |
−0.0309 |
−0.0103 |
16 |
| 2023-05-02 |
−0.0406 |
−0.0309 |
−0.0097 |
17 |
| 2023-08-01 |
−0.0416 |
−0.0309 |
−0.0106 |
18 |
| 2023-10-19 |
−0.0337 |
−0.0309 |
−0.0028 |
19 |
| 2023-10-30 |
−0.0544 |
−0.0309 |
−0.0235 |
20 |
| 2023-11-08 |
−0.0408 |
−0.0309 |
−0.0099 |
21 |
| 2024-04-19 |
−0.0319 |
−0.0309 |
−0.0010 |
22 |
| 2024-06-14 |
−0.0377 |
−0.0309 |
−0.0068 |
23 |
| 2024-08-05 |
−0.0763 |
−0.0309 |
−0.0454 |
24 |
| 2024-09-30 |
−0.0366 |
−0.0309 |
−0.0057 |
25 |
| 2024-11-18 |
−0.0335 |
−0.0309 |
−0.0026 |
26 |
| 2025-01-02 |
−0.0668 |
−0.0309 |
−0.0358 |
27 |
| 2025-01-30 |
−0.0398 |
−0.0309 |
−0.0089 |
28 |
| 2025-02-28 |
−0.0327 |
−0.0309 |
−0.0018 |
29 |
| 2025-03-18 |
−0.0406 |
−0.0309 |
−0.0097 |
30 |
| 2025-04-08 |
−0.0947 |
−0.0309 |
−0.0638 |
31 |
| 2025-06-24 |
−0.0472 |
−0.0309 |
−0.0163 |
32 |
| 2025-07-22 |
−0.0451 |
−0.0309 |
−0.0142 |
33 |
| 2025-09-08 |
−0.0404 |
−0.0309 |
−0.0095 |
34 |
| Catatan: Pelanggaran terjadi saat return < VaR (ambang return minimum harian). |
var_breach_table_gt("ABC_k4")
| Pelanggaran VaR ABC_k4 |
| Total observasi: 893 · Jumlah pelanggaran: 37 · Violation rate: 4.14% |
| Tanggal |
Return |
VaR |
Selisih |
Pelanggaran ke- |
| 2022-01-25 |
−0.0340 |
−0.0305 |
−0.0036 |
1 |
| 2022-02-16 |
−0.0338 |
−0.0305 |
−0.0033 |
2 |
| 2022-02-21 |
−0.0353 |
−0.0305 |
−0.0048 |
3 |
| 2022-03-14 |
−0.0388 |
−0.0305 |
−0.0083 |
4 |
| 2022-06-13 |
−0.0351 |
−0.0305 |
−0.0046 |
5 |
| 2022-07-01 |
−0.0584 |
−0.0305 |
−0.0279 |
6 |
| 2022-10-18 |
−0.0325 |
−0.0305 |
−0.0020 |
7 |
| 2022-11-10 |
−0.0329 |
−0.0305 |
−0.0025 |
8 |
| 2023-01-04 |
−0.0333 |
−0.0305 |
−0.0029 |
9 |
| 2023-02-02 |
−0.0378 |
−0.0305 |
−0.0073 |
10 |
| 2023-02-09 |
−0.0342 |
−0.0305 |
−0.0037 |
11 |
| 2023-02-10 |
−0.0337 |
−0.0305 |
−0.0033 |
12 |
| 2023-02-17 |
−0.0355 |
−0.0305 |
−0.0050 |
13 |
| 2023-03-06 |
−0.0350 |
−0.0305 |
−0.0046 |
14 |
| 2023-03-14 |
−0.0483 |
−0.0305 |
−0.0179 |
15 |
| 2023-03-16 |
−0.0354 |
−0.0305 |
−0.0049 |
16 |
| 2023-03-20 |
−0.0410 |
−0.0305 |
−0.0105 |
17 |
| 2023-05-02 |
−0.0489 |
−0.0305 |
−0.0184 |
18 |
| 2023-08-01 |
−0.0377 |
−0.0305 |
−0.0073 |
19 |
| 2023-10-19 |
−0.0308 |
−0.0305 |
−0.0003 |
20 |
| 2023-10-23 |
−0.0330 |
−0.0305 |
−0.0026 |
21 |
| 2023-10-30 |
−0.0575 |
−0.0305 |
−0.0271 |
22 |
| 2023-11-01 |
−0.0367 |
−0.0305 |
−0.0063 |
23 |
| 2023-11-08 |
−0.0401 |
−0.0305 |
−0.0097 |
24 |
| 2023-11-17 |
−0.0309 |
−0.0305 |
−0.0004 |
25 |
| 2024-06-14 |
−0.0357 |
−0.0305 |
−0.0052 |
26 |
| 2024-08-05 |
−0.0734 |
−0.0305 |
−0.0429 |
27 |
| 2024-09-30 |
−0.0340 |
−0.0305 |
−0.0035 |
28 |
| 2024-11-18 |
−0.0361 |
−0.0305 |
−0.0057 |
29 |
| 2025-01-02 |
−0.0618 |
−0.0305 |
−0.0314 |
30 |
| 2025-01-21 |
−0.0309 |
−0.0305 |
−0.0004 |
31 |
| 2025-01-30 |
−0.0388 |
−0.0305 |
−0.0083 |
32 |
| 2025-02-28 |
−0.0338 |
−0.0305 |
−0.0033 |
33 |
| 2025-03-18 |
−0.0405 |
−0.0305 |
−0.0100 |
34 |
| 2025-04-08 |
−0.1083 |
−0.0305 |
−0.0779 |
35 |
| 2025-06-24 |
−0.0407 |
−0.0305 |
−0.0102 |
36 |
| 2025-07-22 |
−0.0382 |
−0.0305 |
−0.0077 |
37 |
| Catatan: Pelanggaran terjadi saat return < VaR (ambang return minimum harian). |
var_breach_table_gt("ABC_k5")
| Pelanggaran VaR ABC_k5 |
| Total observasi: 893 · Jumlah pelanggaran: 36 · Violation rate: 4.03% |
| Tanggal |
Return |
VaR |
Selisih |
Pelanggaran ke- |
| 2022-01-25 |
−0.0317 |
−0.0288 |
−0.0029 |
1 |
| 2022-02-21 |
−0.0329 |
−0.0288 |
−0.0041 |
2 |
| 2022-03-14 |
−0.0314 |
−0.0288 |
−0.0026 |
3 |
| 2022-06-13 |
−0.0328 |
−0.0288 |
−0.0039 |
4 |
| 2022-07-01 |
−0.0511 |
−0.0288 |
−0.0223 |
5 |
| 2022-09-07 |
−0.0296 |
−0.0288 |
−0.0008 |
6 |
| 2022-10-18 |
−0.0293 |
−0.0288 |
−0.0005 |
7 |
| 2022-11-10 |
−0.0297 |
−0.0288 |
−0.0008 |
8 |
| 2023-01-04 |
−0.0333 |
−0.0288 |
−0.0045 |
9 |
| 2023-01-05 |
−0.0327 |
−0.0288 |
−0.0038 |
10 |
| 2023-02-02 |
−0.0322 |
−0.0288 |
−0.0033 |
11 |
| 2023-02-09 |
−0.0310 |
−0.0288 |
−0.0021 |
12 |
| 2023-02-10 |
−0.0310 |
−0.0288 |
−0.0022 |
13 |
| 2023-02-17 |
−0.0318 |
−0.0288 |
−0.0029 |
14 |
| 2023-03-06 |
−0.0305 |
−0.0288 |
−0.0017 |
15 |
| 2023-03-14 |
−0.0481 |
−0.0288 |
−0.0192 |
16 |
| 2023-03-16 |
−0.0352 |
−0.0288 |
−0.0064 |
17 |
| 2023-03-20 |
−0.0406 |
−0.0288 |
−0.0117 |
18 |
| 2023-05-02 |
−0.0460 |
−0.0288 |
−0.0172 |
19 |
| 2023-08-01 |
−0.0324 |
−0.0288 |
−0.0036 |
20 |
| 2023-10-19 |
−0.0295 |
−0.0288 |
−0.0007 |
21 |
| 2023-10-23 |
−0.0307 |
−0.0288 |
−0.0019 |
22 |
| 2023-10-30 |
−0.0553 |
−0.0288 |
−0.0265 |
23 |
| 2023-11-01 |
−0.0340 |
−0.0288 |
−0.0052 |
24 |
| 2023-11-08 |
−0.0419 |
−0.0288 |
−0.0130 |
25 |
| 2024-06-14 |
−0.0343 |
−0.0288 |
−0.0055 |
26 |
| 2024-08-05 |
−0.0704 |
−0.0288 |
−0.0415 |
27 |
| 2024-09-30 |
−0.0334 |
−0.0288 |
−0.0046 |
28 |
| 2024-11-18 |
−0.0335 |
−0.0288 |
−0.0047 |
29 |
| 2025-01-02 |
−0.0566 |
−0.0288 |
−0.0277 |
30 |
| 2025-01-30 |
−0.0360 |
−0.0288 |
−0.0072 |
31 |
| 2025-02-28 |
−0.0320 |
−0.0288 |
−0.0031 |
32 |
| 2025-03-18 |
−0.0381 |
−0.0288 |
−0.0093 |
33 |
| 2025-04-08 |
−0.1046 |
−0.0288 |
−0.0758 |
34 |
| 2025-06-24 |
−0.0382 |
−0.0288 |
−0.0094 |
35 |
| 2025-07-22 |
−0.0371 |
−0.0288 |
−0.0082 |
36 |
| Catatan: Pelanggaran terjadi saat return < VaR (ambang return minimum harian). |