Analisis dan Peramalan Volatilitas Return Saham BRIS Menggunakan Model GARCH
Install dan Load Library
1. Pengolahan Data
## [1] "BRIS.JK"
harga <- as.numeric(Cl(BRIS.JK))
tanggal <- index(Cl(BRIS.JK))
cat("Jumlah data harga :", length(harga), "\n")## Jumlah data harga : 1012
## Rentang tanggal : 2022-01-03 – 2026-03-30
## Contoh harga (5 pertama):
## [1] 1736.050 1750.679 1745.803 1701.914 1701.914
2. Menghitung Log Return
# r_t = ln(P_t / P_{t-1}) x 100 (dalam persen)
log_return <- diff(log(harga)) * 100
tanggal_return <- tanggal[-1]
cat("Jumlah observasi return:", length(log_return), "\n")## Jumlah observasi return: 1011
## Contoh return (5 pertama):
## [1] 0.8391661 -0.2789357 -2.5461074 0.0000000 0.8559257
3. Visualisasi Awal
df_harga <- data.frame(Tanggal = tanggal, Harga = harga)
df_return <- data.frame(Tanggal = tanggal_return, Return = log_return)
# Plot 1: Harga Penutupan
p1 <- ggplot(df_harga, aes(x = Tanggal, y = Harga)) +
geom_line(color = "#1565C0", linewidth = 0.6) +
scale_y_continuous(labels = comma) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(title = "Harga Penutupan Saham BRIS",
subtitle = paste("Periode:", format(min(tanggal), "%d %b %Y"),
"–", format(max(tanggal), "%d %b %Y")),
x = "Tanggal", y = "Harga (IDR)") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"))
print(p1)# Plot 2: Log Return Harian
p2 <- ggplot(df_return, aes(x = Tanggal, y = Return)) +
geom_line(color = "#C62828", linewidth = 0.5) +
geom_hline(yintercept = 0, linetype = "dashed", color = "black") +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(title = "Log Return Harian Saham BRIS (%)",
x = "Tanggal", y = "Log Return (%)") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"))
print(p2)# Plot 3: Histogram Return
p3 <- ggplot(df_return, aes(x = Return)) +
geom_histogram(aes(y = after_stat(density)),
bins = 50, fill = "#1E88E5",
color = "white", alpha = 0.8) +
stat_function(fun = dnorm,
args = list(mean = mean(log_return), sd = sd(log_return)),
color = "red", linewidth = 1, linetype = "dashed") +
labs(title = "Histogram Log Return BRIS",
subtitle = "Garis merah = distribusi normal",
x = "Log Return (%)", y = "Densitas") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"))
print(p3)4. Statistik Deskriptif Return
skew_val <- skewness(log_return, na.rm = TRUE)
kurt_val <- kurtosis(log_return, na.rm = TRUE)
desc_stats <- data.frame(
Statistik = c("Jumlah Obs.", "Mean (%)", "Minimum (%)",
"Maksimum (%)", "Standar Deviasi (%)",
"Skewness", "Kurtosis"),
Nilai = c(
length(log_return),
round(mean(log_return, na.rm = TRUE), 6),
round(min(log_return, na.rm = TRUE), 6),
round(max(log_return, na.rm = TRUE), 6),
round(sd(log_return, na.rm = TRUE), 6),
round(skew_val, 4),
round(kurt_val, 4)
)
)
print(desc_stats, row.names = FALSE)## Statistik Nilai
## Jumlah Obs. 1011.000000
## Mean (%) 0.021612
## Minimum (%) -11.778304
## Maksimum (%) 19.157481
## Standar Deviasi (%) 2.510672
## Skewness 0.713300
## Kurtosis 9.594700
##
## Interpretasi:
if (abs(skew_val) > 0.5) {
arah <- ifelse(skew_val > 0, "kanan (positif)", "kiri (negatif)")
cat(" - Return menceng ke", arah, "→ distribusi tidak simetris\n")
} else {
cat(" - Distribusi relatif simetris\n")
}## - Return menceng ke kanan (positif) → distribusi tidak simetris
if (kurt_val > 3) {
cat(" - Kurtosis > 3 → distribusi leptokurtik (ekor tebal / fat-tailed)\n")
cat(" Cocok menggunakan distribusi Student-t dalam model GARCH\n")
} else {
cat(" - Kurtosis mendekati 3 → distribusi mendekati normal\n")
}## - Kurtosis > 3 → distribusi leptokurtik (ekor tebal / fat-tailed)
## Cocok menggunakan distribusi Student-t dalam model GARCH
5. Uji Normalitas (Jarque-Bera)
## Uji Normalitas Jarque-Bera:
## Statistik: 1917.716
## p-value : 0e+00
if (jb_test$p.value < 0.05) {
cat(" Kesimpulan: Data TIDAK berdistribusi normal\n")
} else {
cat(" Kesimpulan: Data berdistribusi normal\n")
}## Kesimpulan: Data TIDAK berdistribusi normal
Nilai statistik Jarque-Bera yang sangat besar serta p-value < 0,05 menunjukkan bahwa data return saham tidak berdistribusi normal. Hal ini mengindikasikan adanya penyimpangan dari distribusi normal yang disebabkan oleh nilai skewness dan kurtosis yang tinggi. Ketidaknormalan ini menunjukkan bahwa distribusi alternatif seperti Student-t lebih sesuai digunakan karena mampu mengakomodasi adanya kejadian ekstrem (fat tails) pada data return.
6. Uji Stasioneritas (ADF)
adf_harga <- adf.test(harga)
adf_return <- adf.test(log_return)
cat("ADF Test — Harga Penutupan:\n")## ADF Test — Harga Penutupan:
## Statistik: -1.8111 | p-value: 0.6583
## Kesimpulan: TIDAK STASIONER
## ADF Test — Log Return:
cat(" Statistik:", round(adf_return$statistic, 4),
"| p-value:", round(adf_return$p.value, 4), "\n")## Statistik: -9.857 | p-value: 0.01
## Kesimpulan: STASIONER
# Differencing otomatis jika return tidak stasioner
if (adf_return$p.value >= 0.05) {
cat("\nReturn tidak stasioner → dilakukan differencing...\n")
log_return <- diff(log_return)
tanggal_return <- tanggal_return[-1]
adf_diff <- adf.test(log_return)
cat("ADF setelah differencing:\n")
cat(" Statistik:", round(adf_diff$statistic, 4),
"| p-value:", round(adf_diff$p.value, 4), "\n")
}7. Uji Efek ARCH (ARCH-LM)
# 7. Uji Efek ARCH (ARCH-LM)
arch_test <- ArchTest(log_return, lags = 12)
cat("ARCH-LM Test (lag = 12):\n")## ARCH-LM Test (lag = 12):
## Chi-squared: 33.1036
## p-value : 9.326e-04
if (arch_test$p.value < 0.05) {
cat(" Kesimpulan: Terdapat efek ARCH → Pemodelan GARCH tepat digunakan\n")
} else {
cat(" Kesimpulan: Tidak ada efek ARCH signifikan\n")
}## Kesimpulan: Terdapat efek ARCH → Pemodelan GARCH tepat digunakan
8. Pembagian Data Training dan Testing
n <- length(log_return)
n_train <- round(n * 0.80)
n_test <- n - n_train
train_return <- log_return[1:n_train]
test_return <- log_return[(n_train + 1):n]
train_tanggal <- tanggal_return[1:n_train]
test_tanggal <- tanggal_return[(n_train + 1):n]
cat("Total observasi return :", n, "\n")## Total observasi return : 1011
cat("Data training (80%) :", n_train, "obs. |",
format(min(train_tanggal)), "–", format(max(train_tanggal)), "\n")## Data training (80%) : 809 obs. | 2022-01-04 – 2025-05-23
cat("Data testing (20%) :", n_test, "obs. |",
format(min(test_tanggal)), "–", format(max(test_tanggal)), "\n")## Data testing (20%) : 202 obs. | 2025-05-26 – 2026-03-30
9. Estimasi Model GARCH
# Fungsi bantu estimasi
estimasi_garch <- function(p, q, dist = "norm", data) {
spec <- ugarchspec(
variance.model = list(model = "sGARCH", garchOrder = c(p, q)),
mean.model = list(armaOrder = c(0, 0), include.mean = TRUE),
distribution.model = dist
)
fit <- tryCatch(
ugarchfit(spec = spec, data = data, solver = "hybrid"),
error = function(e) NULL
)
return(fit)
}
# Daftar model kandidat
model_config <- list(
list(p = 1, q = 1, dist = "norm", nama = "GARCH(1,1)-Normal"),
list(p = 1, q = 1, dist = "std", nama = "GARCH(1,1)-Student-t"),
list(p = 1, q = 2, dist = "norm", nama = "GARCH(1,2)-Normal"),
list(p = 1, q = 2, dist = "std", nama = "GARCH(1,2)-Student-t"),
list(p = 2, q = 1, dist = "norm", nama = "GARCH(2,1)-Normal"),
list(p = 2, q = 1, dist = "std", nama = "GARCH(2,1)-Student-t")
)
hasil_ic <- data.frame()
model_list <- list()
for (cfg in model_config) {
cat(" Estimasi:", cfg$nama, "... ")
fit <- estimasi_garch(cfg$p, cfg$q, cfg$dist, train_return)
if (!is.null(fit)) {
ic <- infocriteria(fit)
row <- data.frame(Model = cfg$nama,
AIC = round(ic[1], 6),
BIC = round(ic[2], 6),
p = cfg$p, q = cfg$q, dist = cfg$dist)
hasil_ic <- rbind(hasil_ic, row)
model_list[[cfg$nama]] <- fit
cat("OK\n")
} else {
cat("GAGAL\n")
}
}## Estimasi: GARCH(1,1)-Normal ... OK
## Estimasi: GARCH(1,1)-Student-t ... OK
## Estimasi: GARCH(1,2)-Normal ... OK
## Estimasi: GARCH(1,2)-Student-t ... OK
## Estimasi: GARCH(2,1)-Normal ... OK
## Estimasi: GARCH(2,1)-Student-t ... OK
##
## Perbandingan AIC & BIC semua model:
## Model AIC BIC
## GARCH(1,1)-Normal 4.666580 4.689798
## GARCH(1,1)-Student-t 4.474703 4.503725
## GARCH(1,2)-Normal 4.668917 4.697939
## GARCH(1,2)-Student-t 4.477222 4.512049
## GARCH(2,1)-Normal 4.663922 4.692944
## GARCH(2,1)-Student-t 4.476991 4.511817
# Pilih model terbaik (AIC terkecil)
best_idx <- which.min(hasil_ic$AIC)
best_nama <- hasil_ic$Model[best_idx]
best_fit <- model_list[[best_nama]]
best_cfg <- model_config[[best_idx]] # simpan konfigurasi untuk rolling forecast
cat("\nModel Terbaik (AIC terkecil):", best_nama, "\n")##
## Model Terbaik (AIC terkecil): GARCH(1,1)-Student-t
## AIC = 4.474703 | BIC = 4.503725
10. Interpretasi Parameter Model Terbaik
## Ringkasan model terbaik: GARCH(1,1)-Student-t
## mu omega alpha1 beta1 shape
## -0.112671 0.866094 0.221180 0.722270 3.167801
##
## Log-Likelihood: -1805.017
mu <- ifelse("mu" %in% names(coef_best), coef_best["mu"], NA)
omega <- ifelse("omega" %in% names(coef_best), coef_best["omega"], NA)
alpha <- coef_best[grep("^alpha", names(coef_best))]
beta <- coef_best[grep("^beta", names(coef_best))]
cat("\nInterpretasi parameter:\n")##
## Interpretasi parameter:
## μ (Mean) = -0.112671 → rata-rata return harian
## ω (Omega) = 0.8660941 → komponen volatilitas jangka panjang
## α (Alpha) = 0.22118 → pengaruh shock/guncangan terhadap volatilitas
## β (Beta) = 0.72227 → persistensi volatilitas dari periode sebelumnya
11. Analisis Persistensi Volatilitas
## α + β = 0.943451
if (persistensi >= 0.95) {
cat(" Sangat mendekati 1 → Volatilitas sangat PERSISTEN\n")
cat(" Guncangan pada volatilitas akan bertahan sangat lama\n")
} else if (persistensi >= 0.90) {
cat(" Persistensi TINGGI → Volatilitas lambat kembali ke rata-rata\n")
} else {
cat(" Persistensi MODERAT → Volatilitas cukup cepat kembali ke rata-rata\n")
}## Persistensi TINGGI → Volatilitas lambat kembali ke rata-rata
half_life <- log(0.5) / log(persistensi)
cat("\n Half-life volatilitas ≈", round(half_life, 1), "hari perdagangan\n")##
## Half-life volatilitas ≈ 11.9 hari perdagangan
## (waktu yang dibutuhkan shock untuk memudar setengahnya)
12. Diagnostik Model
std_resid <- residuals(best_fit, standardize = TRUE)
lb_resid <- Box.test(std_resid, lag = 10, type = "Ljung-Box")
lb_sq <- Box.test(std_resid^2, lag = 10, type = "Ljung-Box")
arch_resid <- ArchTest(std_resid, lags = 12)
cat("Ljung-Box (residual standar) — lag 10:\n")## Ljung-Box (residual standar) — lag 10:
cat(" p-value:", round(lb_resid$p.value, 4),
ifelse(lb_resid$p.value > 0.05,
"→ Tidak ada autokorelasi ",
"→ Masih ada autokorelasi "), "\n\n")## p-value: 0.9734 → Tidak ada autokorelasi
## Ljung-Box (kuadrat residual) — lag 10:
cat(" p-value:", round(lb_sq$p.value, 4),
ifelse(lb_sq$p.value > 0.05,
"→ Tidak ada autokorelasi pada kuadrat ",
"→ Masih ada efek ARCH pada residual ️"), "\n\n")## p-value: 0.9388 → Tidak ada autokorelasi pada kuadrat
## ARCH-LM (residual standar) — lag 12:
cat(" p-value:", round(arch_resid$p.value, 4),
ifelse(arch_resid$p.value > 0.05,
"→ Tidak ada efek ARCH tersisa ",
"→ Masih ada efek ARCH ️"), "\n\n")## p-value: 0.9613 → Tidak ada efek ARCH tersisa
if (lb_resid$p.value > 0.05 &&
lb_sq$p.value > 0.05 &&
arch_resid$p.value > 0.05) {
cat(" Model VALID: residual bersih dari autokorelasi & efek ARCH\n")
} else {
cat(" Pertimbangkan model alternatif atau spesifikasi lebih lanjut\n")
}## Model VALID: residual bersih dari autokorelasi & efek ARCH
13. Peramalan (Forecasting)
# --- Rolling 1-step forecast pada data testing ---
# Gunakan ARMA(0,0) konsisten dengan saat estimasi
best_spec_roll <- ugarchspec(
variance.model = list(model = "sGARCH",
garchOrder = c(best_cfg$p, best_cfg$q)),
mean.model = list(armaOrder = c(0, 0), include.mean = TRUE),
distribution.model = best_cfg$dist
)
cat("Menjalankan rolling 1-step forecast pada data testing...\n")## Menjalankan rolling 1-step forecast pada data testing...
roll_fc <- ugarchroll(
spec = best_spec_roll,
data = log_return,
n.ahead = 1,
forecast.length = n_test,
refit.every = 20,
refit.window = "recursive",
solver = "hybrid",
calculate.VaR = FALSE
)
roll_df <- as.data.frame(roll_fc)
pred_vol <- roll_df$Sigma
actual_ret <- roll_df$Realized
cat("Selesai! Jumlah forecast:", length(pred_vol), "\n")## Selesai! Jumlah forecast: 202
# --- Forecast 10 hari ke depan (out-of-sample) ---
cat("\nForecast 10 hari ke depan (out-of-sample):\n")##
## Forecast 10 hari ke depan (out-of-sample):
fc_10 <- ugarchforecast(best_fit, n.ahead = 10)
vol_10hari <- as.numeric(sigma(fc_10))
ret_10hari <- as.numeric(fitted(fc_10))
# Buat tanggal hari kerja (hindari weekend)
tanggal_fc <- seq(max(tanggal_return) + 1, by = "day", length.out = 20)
tanggal_fc <- tanggal_fc[!weekdays(tanggal_fc) %in%
c("Saturday", "Sunday")][1:10]
fc10_df <- data.frame(
Tanggal = tanggal_fc,
Return = round(ret_10hari, 6),
Volatilitas = round(vol_10hari, 6),
Lower_95 = round(ret_10hari - 1.96 * vol_10hari, 6),
Upper_95 = round(ret_10hari + 1.96 * vol_10hari, 6)
)
cat("\nForecast 10 Hari + Interval Prediksi 95%:\n")##
## Forecast 10 Hari + Interval Prediksi 95%:
## Tanggal Return Volatilitas Lower_95 Upper_95
## 2026-03-31 -0.112671 2.171076 -4.367980 4.142637
## 2026-04-01 -0.112671 2.305020 -4.630510 4.405167
## 2026-04-02 -0.112671 2.424615 -4.864917 4.639574
## 2026-04-03 -0.112671 2.532275 -5.075929 4.850587
## 2026-04-06 -0.112671 2.629808 -5.267096 5.041753
## 2026-04-07 -0.112671 2.718621 -5.441168 5.215825
## 2026-04-08 -0.112671 2.799830 -5.600337 5.374994
## 2026-04-09 -0.112671 2.874343 -5.746384 5.521041
## 2026-04-10 -0.112671 2.942914 -5.880783 5.655440
## 2026-04-13 -0.112671 3.006174 -6.004772 5.779429
# --- Evaluasi Model ---
actual_vol <- abs(actual_ret)
rmse <- sqrt(mean((pred_vol - actual_vol)^2))
mae <- mean(abs(pred_vol - actual_vol))
# MAPE berbasis harga (Nusrang et al., 2025)
pred_mean <- roll_df$Mu
harga_awal <- harga[n_train + 1]
harga_ramal <- numeric(n_test)
harga_ramal[1] <- harga_awal * exp(pred_mean[1] / 100)
for (i in 2:n_test) {
harga_ramal[i] <- harga_ramal[i - 1] * exp(pred_mean[i] / 100)
}
harga_aktual <- harga[(n_train + 2):(n_train + n_test + 1)]
mape <- mean(abs((harga_aktual - harga_ramal) / harga_aktual)) * 100
cat("\nEvaluasi Model:\n")##
## Evaluasi Model:
## RMSE : 1.939408 (akurasi prediksi volatilitas)
## MAE : 1.667739 (akurasi prediksi volatilitas)
## MAPE : 5.9451 % (akurasi rekonstruksi harga)
# MAPE Volatilitas
# Proxy: sqrt(return^2) = |return|, tapi filter hari return = 0
idx_valid <- which(actual_vol > 0)
mape_vol <- mean(abs((actual_vol[idx_valid] - pred_vol[idx_valid]) /
actual_vol[idx_valid])) * 100
cat("\nEvaluasi Model:\n")##
## Evaluasi Model:
## RMSE : 1.939408
## MAE : 1.667739
## MAPE : 178.2447 %
15. Visualisasi Hasil
# 15. Visualisasi Hasil
actual_vol <- abs(actual_ret)
# --- Plot 1: Volatilitas GARCH periode training ---
vol_train <- as.numeric(sigma(best_fit))
df_vol_train <- data.frame(
Tanggal = train_tanggal,
Volatilitas = vol_train
)
p_vol <- ggplot(df_vol_train, aes(x = Tanggal, y = Volatilitas)) +
geom_line(color = "#E65100", linewidth = 0.6) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(title = paste("Volatilitas GARCH —", best_nama),
subtitle = "Periode Training",
x = "Tanggal", y = "Volatilitas Bersyarat (%)") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"))
print(p_vol)# --- Plot 2: Return vs Volatilitas ---
df_rv <- data.frame(
Tanggal = train_tanggal,
Return = train_return,
Volatilitas = vol_train
)
p_rv <- ggplot(df_rv, aes(x = Tanggal)) +
geom_line(aes(y = Return, color = "Return"), linewidth = 0.4) +
geom_line(aes(y = Volatilitas, color = "Volatilitas"), linewidth = 0.7) +
scale_color_manual(values = c("Return" = "#1565C0",
"Volatilitas" = "#B71C1C")) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(title = "Return vs Volatilitas GARCH — BRIS",
x = "Tanggal", y = "(%)", color = "") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
legend.position = "top")
print(p_rv)# --- Plot 3: Prediksi vs Aktual (data testing) ---
df_test_fc <- data.frame(
Tanggal = test_tanggal,
Aktual = actual_vol,
Prediksi = pred_vol
)
p_pred <- ggplot(df_test_fc, aes(x = Tanggal)) +
geom_line(aes(y = Aktual, color = "Aktual |r|"), linewidth = 0.5) +
geom_line(aes(y = Prediksi, color = "Prediksi σ"), linewidth = 0.7) +
scale_color_manual(values = c("Aktual |r|" = "#37474F",
"Prediksi σ" = "#E53935")) +
scale_x_date(date_breaks = "3 months", date_labels = "%b %Y") +
labs(title = "Prediksi vs Aktual Volatilitas — Data Testing",
subtitle = paste("Model:", best_nama),
x = "Tanggal", y = "Volatilitas / |Return| (%)", color = "") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
legend.position = "top",
axis.text.x = element_text(angle = 45, hjust = 1))
print(p_pred)# --- Plot 4: Interval Prediksi ±2σ pada data testing ---
df_test_fc$Upper <- pred_vol * 2
df_test_fc$Lower <- -pred_vol * 2
p_interval <- ggplot(df_test_fc, aes(x = Tanggal)) +
geom_ribbon(aes(ymin = Lower, ymax = Upper),
fill = "#FFCDD2", alpha = 0.6) +
geom_line(data = data.frame(Tanggal = test_tanggal, Return = actual_ret),
aes(y = Return), color = "#1A237E", linewidth = 0.5) +
geom_hline(yintercept = 0, linetype = "dashed") +
scale_x_date(date_breaks = "3 months", date_labels = "%b %Y") +
labs(title = "Interval Prediksi 95% (±2σ GARCH) — Data Testing",
subtitle = "Area merah muda = interval prediksi | Biru = return aktual",
x = "Tanggal", y = "Return (%)") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
axis.text.x = element_text(angle = 45, hjust = 1))
print(p_interval)# --- Plot 5: Forecast Return 10 Hari ke Depan ---
p_fc10 <- ggplot(fc10_df, aes(x = Tanggal)) +
geom_ribbon(aes(ymin = Lower_95, ymax = Upper_95),
fill = "#C8E6C9", alpha = 0.7) +
geom_line(aes(y = Return), color = "#2E7D32",
linewidth = 1) +
geom_point(aes(y = Return), color = "#1B5E20", size = 3) +
geom_hline(yintercept = 0, linetype = "dashed") +
scale_x_date(date_breaks = "1 day", date_labels = "%d %b") +
labs(title = "Forecast Return 10 Hari ke Depan",
subtitle = "Hijau = interval prediksi 95% | Garis = return prediksi",
x = "Tanggal", y = "Return (%)") +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"))
print(p_fc10)Garis hijau = return prediksi (konstan di -0,16%). Area hijau muda = interval kepercayaan 95% yang semakin melebar dari hari ke hari. Ini adalah implikasi natural GARCH: semakin jauh horizon prediksi, semakin besar ketidakpastiannya.