Pasar keuangan merupakan salah satu sistem yang paling kompleks dan dinamis dalam perekonomian global. Setiap hari, jutaan transaksi terjadi di berbagai bursa saham di seluruh dunia, menghasilkan volume data yang sangat besar dan terus berkembang. Data-data ini — mulai dari harga saham, volume perdagangan, kapitalisasi pasar, hingga rasio keuangan — menyimpan pola dan informasi berharga yang dapat dimanfaatkan untuk memahami perilaku pasar, mengevaluasi risiko investasi, dan mendukung pengambilan keputusan finansial yang lebih baik.
Namun demikian, data pasar keuangan dalam kondisi mentahnya jarang dapat langsung digunakan untuk analisis. Data sering kali mengandung nilai yang hilang, entri pada hari non-perdagangan, tipe data yang tidak konsisten, serta outlier yang dapat mendistorsi hasil analisis. Oleh karena itu, serangkaian proses transformasi data yang sistematis menjadi prasyarat mutlak sebelum data dapat digunakan secara andal.
Praktikum ini bertujuan untuk menerapkan teknik-teknik transformasi dan rekayasa data pada dataset pasar keuangan nyata menggunakan bahasa pemrograman R. Secara spesifik, praktikum ini mencakup delapan tahapan utama:
Dataset yang digunakan dalam praktikum ini adalah “Data-Transformation – Data Science Programming” yang berisi data historis saham dari berbagai sektor industri. Dataset mencakup variabel-variabel utama berikut:
| Variabel | Deskripsi |
|---|---|
Stock_ID |
Identifikasi unik setiap saham |
Date |
Tanggal observasi |
Sector |
Sektor industri (Technology, Finance, Healthcare, Energy, Consumer Goods) |
Performance |
Kategori kinerja saham (Negative, Stable, Positive) |
Stock_Price |
Harga penutupan saham |
Market_Cap |
Kapitalisasi pasar |
Volume_Traded |
Volume saham yang diperdagangkan |
PE_Ratio |
Price-to-Earnings Ratio |
Dividend_Yield |
Imbal hasil dividen |
Return_on_Equity |
Tingkat pengembalian ekuitas |
Seluruh analisis dilakukan menggunakan bahasa R dengan memanfaatkan
ekosistem tidyverse untuk manipulasi data, zoo
dan TTR untuk perhitungan teknikal berbasis waktu, serta
DT untuk penyajian tabel interaktif. Setiap bab disusun
secara sekuensial — output dari satu bab menjadi input untuk bab
berikutnya — sehingga membentuk pipeline transformasi data yang utuh dan
reproducible.
# Pastikan library sudah terisi
library(tidyverse)
library(DT)
# 1. Persiapan Data (seperti sebelumnya)
data_saham <- read.csv("6 Data-Transformation – Data Science Programming.csv")
data_clean <- data_saham %>%
select(-1) %>%
mutate(
Date = as.Date(Date),
Stock_Price = as.numeric(Stock_Price),
Market_Cap = as.numeric(Market_Cap)
)
# 2. Membuat Tabel dengan Fitur Filter dan Tombol Download
datatable(
data_clean,
extensions = 'Buttons', # Mengaktifkan ekstensi tombol
options = list(
dom = 'Blfrtip', # Mengatur tata letak (B = Buttons, l = length, f = filter, r = processing, t = table, i = info, p = pagination)
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'), # Jenis tombol yang ditampilkan
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Tabel Data Pasar Keuangan dengan Fitur Ekspor Data',
rownames = FALSE
)
library(tidyverse)
library(DT)
library(lubridate)
library(zoo)
library(TTR)
library(scales)
data_saham <- read.csv("6 Data-Transformation – Data Science Programming.csv")
glimpse(data_saham)
## Rows: 500
## Columns: 11
## $ X <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16…
## $ Stock_ID <chr> "QNaySPxCMRBC", "7mHFA7pLbHSW", "VjBZzknDmBZO", "YXkJ…
## $ Date <chr> "2021-07-14", "2020-11-16", "2023-03-22", "2023-01-02…
## $ Stock_Price <dbl> 1132.43, 452.33, 824.41, 1163.22, 990.52, 385.52, 149…
## $ Volume_Traded <int> 934512, 500394, 134785, 587495, 438658, 397203, 79400…
## $ Market_Cap <dbl> 1058269424, 226343218, 111118102, 683385934, 43449952…
## $ PE_Ratio <dbl> 17.16, 17.79, 17.48, 23.31, 9.72, 22.54, 13.33, 14.68…
## $ Dividend_Yield <dbl> 0.46, 2.85, 1.70, 2.75, 6.94, 8.48, 5.08, 9.54, 0.78,…
## $ Return_on_Equity <dbl> 5.09, 11.55, 14.85, 15.47, 18.22, 12.80, 8.84, 13.51,…
## $ Sector <chr> "Consumer Goods", "Energy", "Energy", "Healthcare", "…
## $ Performance <chr> "Positive", "Stable", "Negative", "Stable", "Positive…
datatable(
data_saham,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Tabel Data Mentah (Sebelum Cleaning)',
rownames = FALSE
)
data_clean <- data_saham %>%
select(-1) %>%
mutate(
Date = as.Date(Date),
Stock_Price = as.numeric(Stock_Price),
Market_Cap = as.numeric(Market_Cap),
Volume_Traded = as.numeric(Volume_Traded),
PE_Ratio = as.numeric(PE_Ratio),
Dividend_Yield = as.numeric(Dividend_Yield),
Return_on_Equity = as.numeric(Return_on_Equity)
)
glimpse(data_clean)
## Rows: 500
## Columns: 10
## $ Stock_ID <chr> "QNaySPxCMRBC", "7mHFA7pLbHSW", "VjBZzknDmBZO", "YXkJ…
## $ Date <date> 2021-07-14, 2020-11-16, 2023-03-22, 2023-01-02, 2023…
## $ Stock_Price <dbl> 1132.43, 452.33, 824.41, 1163.22, 990.52, 385.52, 149…
## $ Volume_Traded <dbl> 934512, 500394, 134785, 587495, 438658, 397203, 79400…
## $ Market_Cap <dbl> 1058269424, 226343218, 111118102, 683385934, 43449952…
## $ PE_Ratio <dbl> 17.16, 17.79, 17.48, 23.31, 9.72, 22.54, 13.33, 14.68…
## $ Dividend_Yield <dbl> 0.46, 2.85, 1.70, 2.75, 6.94, 8.48, 5.08, 9.54, 0.78,…
## $ Return_on_Equity <dbl> 5.09, 11.55, 14.85, 15.47, 18.22, 12.80, 8.84, 13.51,…
## $ Sector <chr> "Consumer Goods", "Energy", "Energy", "Healthcare", "…
## $ Performance <chr> "Positive", "Stable", "Negative", "Stable", "Positive…
data_clean <- data_clean %>%
mutate(
Weekday = wday(Date, label = TRUE, abbr = FALSE),
Is_Non_Trading = wday(Date) %in% c(1, 7)
)
distribusi_hari <- data_clean %>%
count(Weekday, Is_Non_Trading) %>%
arrange(Weekday)
knitr::kable(distribusi_hari,
caption = "Distribusi Hari dalam Dataset",
col.names = c("Hari", "Non-Trading?", "Jumlah Data"))
| Hari | Non-Trading? | Jumlah Data |
|---|---|---|
| Sunday | TRUE | 77 |
| Monday | FALSE | 77 |
| Tuesday | FALSE | 68 |
| Wednesday | FALSE | 77 |
| Thursday | FALSE | 69 |
| Friday | FALSE | 71 |
| Saturday | TRUE | 61 |
cat("Total baris :", nrow(data_clean), "\n")
## Total baris : 500
cat("Hari trading (Sen–Jum) :", sum(!data_clean$Is_Non_Trading), "\n")
## Hari trading (Sen–Jum) : 362
cat("Hari non-trading (Sa–Mi):", sum(data_clean$Is_Non_Trading), "\n")
## Hari non-trading (Sa–Mi): 138
data_trading <- data_clean %>%
filter(!Is_Non_Trading) %>%
select(-Weekday, -Is_Non_Trading)
cat("Baris setelah filter hari non-trading:", nrow(data_trading), "\n")
## Baris setelah filter hari non-trading: 362
data_ffill <- data_trading %>%
arrange(Stock_ID, Date) %>%
group_by(Stock_ID) %>%
fill(Stock_Price, Market_Cap, PE_Ratio,
Dividend_Yield, Return_on_Equity, .direction = "down") %>%
ungroup()
cat("Missing values setelah forward-fill:\n")
## Missing values setelah forward-fill:
print(colSums(is.na(data_ffill)))
## Stock_ID Date Stock_Price Volume_Traded
## 0 0 0 0
## Market_Cap PE_Ratio Dividend_Yield Return_on_Equity
## 0 0 0 0
## Sector Performance
## 0 0
data_p1_final <- data_ffill %>%
filter(Date >= as.Date("2020-01-01") & Date <= as.Date("2024-12-31")) %>%
arrange(Stock_ID, Date)
cat("Rentang tanggal :", format(min(data_p1_final$Date), "%d %B %Y"),
"–", format(max(data_p1_final$Date), "%d %B %Y"), "\n")
## Rentang tanggal : 02 January 2020 – 27 December 2024
cat("Total baris akhir:", nrow(data_p1_final), "\n")
## Total baris akhir: 362
num_cols_p1 <- c("Stock_Price", "Market_Cap", "Volume_Traded",
"PE_Ratio", "Dividend_Yield", "Return_on_Equity")
summary_stats <- data_p1_final %>%
summarise(across(
all_of(num_cols_p1),
list(
Min = ~ min(., na.rm = TRUE),
Mean = ~ mean(., na.rm = TRUE),
Max = ~ max(., na.rm = TRUE),
NA_pct = ~ round(mean(is.na(.)) * 100, 2)
),
.names = "{.col}_{.fn}"
)) %>%
pivot_longer(everything(),
names_to = c("Kolom", "Statistik"),
names_sep = "_(?=[^_]+$)") %>%
pivot_wider(names_from = Statistik, values_from = value)
knitr::kable(summary_stats, digits = 2,
caption = "Ringkasan Statistik Data Bersih")
| Kolom | Min | Mean | Max | pct |
|---|---|---|---|---|
| Stock_Price | 100.57 | 764.07 | 1.499130e+03 | NA |
| Stock_Price_NA | NA | NA | NA | 0 |
| Market_Cap | 705330.99 | 380412725.46 | 1.447953e+09 | NA |
| Market_Cap_NA | NA | NA | NA | 0 |
| Volume_Traded | 1526.00 | 494096.18 | 9.984700e+05 | NA |
| Volume_Traded_NA | NA | NA | NA | 0 |
| PE_Ratio | 1.93 | 14.94 | 2.817000e+01 | NA |
| PE_Ratio_NA | NA | NA | NA | 0 |
| Dividend_Yield | 0.13 | 5.33 | 1.000000e+01 | NA |
| Dividend_Yield_NA | NA | NA | NA | 0 |
| Return_on_Equity | 3.51 | 12.04 | 2.241000e+01 | NA |
| Return_on_Equity_NA | NA | NA | NA | 0 |
datatable(
data_p1_final,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Tabel Data Pasar Keuangan – Hasil Cleaning',
rownames = FALSE
)
ringkasan_cleaning <- tibble(
Langkah = c(
"Data awal",
"Setelah hapus hari non-trading (Sab & Min)",
"Setelah forward-fill missing values",
"Setelah filter rentang tanggal (2020–2024)"
),
Jumlah_Baris = c(
nrow(data_saham),
nrow(data_trading),
nrow(data_ffill),
nrow(data_p1_final)
)
)
knitr::kable(ringkasan_cleaning,
col.names = c("Langkah Cleaning", "Jumlah Baris"),
caption = "Rangkuman Proses Data Cleaning")
| Langkah Cleaning | Jumlah Baris |
|---|---|
| Data awal | 500 |
| Setelah hapus hari non-trading (Sab & Min) | 362 |
| Setelah forward-fill missing values | 362 |
| Setelah filter rentang tanggal (2020–2024) | 362 |
Konversi Tipe Data: Seluruh kolom numerik
(Stock_Price, Market_Cap,
Volume_Traded, PE_Ratio,
Dividend_Yield, Return_on_Equity) berhasil
dikonversi ke tipe numeric dan kolom Date ke
tipe Date. Langkah ini krusial agar operasi matematis dan
temporal berjalan dengan benar pada tahap analisis selanjutnya.
Penghapusan Hari Non-Trading: Data aset keuangan hanya valid pada hari bursa aktif (Senin–Jumat). Keberadaan data pada hari Sabtu dan Minggu mengindikasikan kemungkinan kesalahan input atau data sintetik. Penghapusan baris non-trading memastikan bahwa seluruh perhitungan return dan moving average tidak terdistorsi oleh “jeda” akhir pekan.
Forward-Fill Missing Values: Metode forward-fill dipilih karena mencerminkan praktik pasar yang sesungguhnya — ketika bursa tutup atau data tidak tersedia, harga terakhir yang tercatat dianggap sebagai harga yang berlaku (last known price). Pendekatan ini lebih tepat dibandingkan imputasi mean/median yang dapat mengaburkan tren temporal.
Filter Rentang Tanggal 2020–2024: Periode ini dipilih karena mencakup siklus pasar yang lengkap dan informatif, meliputi dampak pandemi COVID-19 (2020), pemulihan pasar (2021–2022), periode kenaikan suku bunga global (2022–2023), hingga stabilisasi pasar (2024). Rentang lima tahun ini memberikan cukup data untuk analisis teknikal yang andal.
Ringkasan Statistik Data Bersih: Setelah proses cleaning, data siap digunakan dengan kualitas yang terjamin — tidak ada nilai yang hilang, tipe data sudah sesuai, dan rentang tanggal sudah dibatasi pada periode yang relevan untuk analisis pasar keuangan.
Daily Return mengukur perubahan harga saham relatif terhadap harga sebelumnya. Formula:
\[\text{Return}_t = \frac{P_t - P_{t-1}}{P_{t-1}}\]
Di mana \(P_t\) adalah harga pada waktu \(t\) dan \(P_{t-1}\) adalah harga pada periode sebelumnya.
Log Return digunakan karena memiliki sifat yang lebih stabil secara statistik (additive over time dan lebih mendekati distribusi normal). Formula:
\[\text{LogReturn}_t = \log\left(\frac{P_t}{P_{t-1}}\right)\]
Lag Features adalah nilai dari variabel pada periode-periode sebelumnya (\(t-1\), \(t-2\), dst.) yang digunakan sebagai prediktor untuk memodelkan pola temporal dalam data.
data_features <- data_clean %>%
# Urutkan berdasarkan Stock_ID dan Date agar lag dihitung dengan benar
arrange(Stock_ID, Date) %>%
group_by(Stock_ID) %>%
mutate(
# Harga periode sebelumnya
Lag_Price_1 = lag(Stock_Price, 1),
Lag_Price_2 = lag(Stock_Price, 2),
# Daily Return: (Pt - Pt-1) / Pt-1
Daily_Return = (Stock_Price - lag(Stock_Price, 1)) / lag(Stock_Price, 1),
# Log Return: log(Pt / Pt-1)
Log_Return = log(Stock_Price / lag(Stock_Price, 1)),
# Lag Return dari periode sebelumnya
Lag_Return_1 = lag(Daily_Return, 1),
Lag_Return_2 = lag(Daily_Return, 2)
) %>%
ungroup()
# Tampilkan ringkasan statistik fitur baru
summary(data_features %>%
select(Daily_Return, Log_Return, Lag_Price_1, Lag_Price_2,
Lag_Return_1, Lag_Return_2))
## Daily_Return Log_Return Lag_Price_1 Lag_Price_2 Lag_Return_1
## Min. : NA Min. : NA Min. : NA Min. : NA Min. : NA
## 1st Qu.: NA 1st Qu.: NA 1st Qu.: NA 1st Qu.: NA 1st Qu.: NA
## Median : NA Median : NA Median : NA Median : NA Median : NA
## Mean :NaN Mean :NaN Mean :NaN Mean :NaN Mean :NaN
## 3rd Qu.: NA 3rd Qu.: NA 3rd Qu.: NA 3rd Qu.: NA 3rd Qu.: NA
## Max. : NA Max. : NA Max. : NA Max. : NA Max. : NA
## NA's :500 NA's :500 NA's :500 NA's :500 NA's :500
## Lag_Return_2
## Min. : NA
## 1st Qu.: NA
## Median : NA
## Mean :NaN
## 3rd Qu.: NA
## Max. : NA
## NA's :500
# Pilih kolom yang relevan untuk ditampilkan
data_display <- data_features %>%
select(
Stock_ID, Date, Stock_Price,
Lag_Price_1, Lag_Price_2,
Daily_Return, Log_Return,
Lag_Return_1, Lag_Return_2,
Sector, Performance
) %>%
mutate(across(where(is.numeric), ~ round(.x, 4)))
datatable(
data_display,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Tabel Data Pasar Keuangan dengan Fitur Baru: Daily Return, Log Return & Lag Features',
rownames = FALSE
)
Stock_Price per Sektordata_clean %>%
ggplot(aes(x = Stock_Price, fill = Sector)) +
geom_histogram(bins = 40, color = "white", alpha = 0.8) +
labs(
title = "Distribusi Harga Saham",
subtitle = "Sebaran Stock Price seluruh saham dalam dataset",
x = "Stock Price", y = "Frekuensi"
) +
theme_minimal()
PE_Ratio vs
Return_on_Equitydata_clean %>%
ggplot(aes(x = PE_Ratio, y = Return_on_Equity, color = Sector)) +
geom_point(alpha = 0.6, size = 1.8) +
labs(
title = "PE Ratio vs Return on Equity",
subtitle = "Setiap titik mewakili satu saham",
x = "PE Ratio", y = "Return on Equity"
) +
theme_minimal()
data_clean %>%
ggplot(aes(x = Sector, y = Stock_Price, fill = Sector)) +
geom_boxplot(alpha = 0.7, outlier.size = 0.8) +
labs(
title = "Distribusi Harga Saham per Sektor",
x = "Sektor", y = "Stock Price"
) +
theme_minimal() +
theme(legend.position = "none",
axis.text.x = element_text(angle = 30, hjust = 1))
data_clean %>%
count(Sector, Performance) %>%
ggplot(aes(x = Sector, y = n, fill = Performance)) +
geom_bar(stat = "identity", position = "dodge", alpha = 0.85) +
labs(
title = "Distribusi Performance per Sektor",
x = "Sektor", y = "Jumlah Saham"
) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 30, hjust = 1))
Daily Return mengukur perubahan harga saham relatif dari satu periode ke periode berikutnya. Dari hasil analisis:
Log Return memiliki sifat matematis yang lebih menguntungkan dibandingkan Daily Return biasa:
Lag features digunakan untuk menangkap pola temporal dalam data:
NA pada baris pertama setiap saham adalah hal
yang wajar karena tidak ada data sebelumnya yang bisa
dijadikan referensi lag.Dari boxplot Daily Return per sektor, terlihat bahwa:
Feature Engineering pada data saham ini berhasil menghasilkan enam fitur baru yang relevan untuk analisis lebih lanjut:
| Fitur | Deskripsi |
|---|---|
Daily_Return |
Perubahan harga relatif harian |
Log_Return |
Logaritma rasio harga (lebih stabil secara statistik) |
Lag_Price_1 |
Harga saham 1 periode sebelumnya |
Lag_Price_2 |
Harga saham 2 periode sebelumnya |
Lag_Return_1 |
Daily Return 1 periode sebelumnya |
Lag_Return_2 |
Daily Return 2 periode sebelumnya |
Fitur-fitur ini siap digunakan sebagai variabel prediktor dalam model machine learning seperti regresi, random forest, atau model time-series untuk memprediksi pergerakan harga saham di masa depan.
Fitur temporal digunakan untuk menangkap pola pergerakan harga saham dari waktu ke waktu. Terdapat dua ukuran utama yang dihitung, yaitu Rolling Mean dan Volatility.
\[\text{RollingMean}_t = \frac{1}{n} \sum_{i=0}^{n-1} P_{t-i}\]
Keterangan:
Cara menghitungnya step by step:
Misalkan harga saham selama 5 hari berturut-turut adalah: 100, 110, 105, 115, 120
Step 1 — Tentukan jendela waktu, misalnya \(n = 5\) hari
Step 2 — Jumlahkan seluruh harga dalam jendela tersebut: \[100 + 110 + 105 + 115 + 120 = 550\]
Step 3 — Bagi dengan jumlah hari (\(n\)): \[\text{MA}_5 = \frac{550}{5} = 110\]
Step 4 — Geser jendela satu hari ke depan, ulangi proses yang sama untuk hari berikutnya.
Jendela waktu yang umum digunakan: 5, 20, 50, dan 200 hari.
\[\text{Volatility}_t = \sqrt{\frac{1}{n} \sum_{i=0}^{n-1} (R_{t-i} - \bar{R})^2}\]
Keterangan:
Cara menghitungnya step by step:
Misalkan Daily Return selama 5 hari adalah: 0.02, -0.01, 0.03, -0.02, 0.01
Step 1 — Hitung rata-rata return (\(\bar{R}\)): \[\bar{R} = \frac{0.02 + (-0.01) + 0.03 + (-0.02) + 0.01}{5} = \frac{0.03}{5} = 0.006\]
Step 2 — Hitung selisih setiap return terhadap rata-rata, lalu kuadratkan:
| \(R_{t-i}\) | \(R_{t-i} - \bar{R}\) | \((R_{t-i} - \bar{R})^2\) |
|---|---|---|
| 0.02 | 0.014 | 0.000196 |
| -0.01 | -0.016 | 0.000256 |
| 0.03 | 0.024 | 0.000576 |
| -0.02 | -0.026 | 0.000676 |
| 0.01 | 0.004 | 0.000016 |
Step 3 — Jumlahkan semua hasil kuadrat: \[0.000196 + 0.000256 + 0.000576 + 0.000676 + 0.000016 = 0.00172\]
Step 4 — Bagi dengan \(n\), lalu akarkan: \[\text{Volatility}_5 = \sqrt{\frac{0.00172}{5}} = \sqrt{0.000344} \approx 0.01855\]
Semakin besar nilai volatility, semakin tinggi fluktuasi harga saham pada periode tersebut.
library(zoo)
# Lag, Diff, dan Rolling — mengikuti pola subbab 6.1.1
# dihitung berdasarkan urutan tanggal seluruh data
Temporal <- data_clean %>%
arrange(Date) %>%
mutate(
# Daily Return: (Pt - Pt-1) / Pt-1
Daily_Return = (Stock_Price - lag(Stock_Price)) / lag(Stock_Price),
# Lag dan Diff pada Stock_Price
Lag_Price = lag(Stock_Price),
Diff_Price = Stock_Price - lag(Stock_Price),
# Rolling Mean berbagai window
MA_5 = zoo::rollmean(Stock_Price, k = 5, fill = NA, align = "right"),
MA_20 = zoo::rollmean(Stock_Price, k = 20, fill = NA, align = "right"),
MA_50 = zoo::rollmean(Stock_Price, k = 50, fill = NA, align = "right"),
MA_200 = zoo::rollmean(Stock_Price, k = 200, fill = NA, align = "right"),
# Volatility: rolling standar deviasi dari Daily Return
Volatility_5 = zoo::rollapply(Daily_Return, width = 5,
FUN = sd, fill = NA, align = "right"),
Volatility_20 = zoo::rollapply(Daily_Return, width = 20,
FUN = sd, fill = NA, align = "right"),
Volatility_50 = zoo::rollapply(Daily_Return, width = 50,
FUN = sd, fill = NA, align = "right")
)
datatable(
Temporal %>%
select(Stock_ID, Date, Stock_Price,
Daily_Return, Lag_Price, Diff_Price,
MA_5, MA_20, MA_50, MA_200,
Volatility_5, Volatility_20, Volatility_50),
options = list(
pageLength = 10,
scrollX = TRUE,
autoWidth = TRUE
),
caption = 'Temporal & Rolling Features: Lag, Diff, Moving Average, dan Volatility',
rownames = FALSE
)
Moving Average (MA): Keempat window MA yang dihitung (5, 20, 50, dan 200 hari) mencerminkan perspektif analisis yang berbeda:
Perbedaan Antar MA (Golden Cross & Death Cross): Ketika MA-5 atau MA-20 memotong MA-50 atau MA-200 dari bawah ke atas, kondisi ini disebut Golden Cross — sinyal beli yang kuat. Sebaliknya, perpotongan dari atas ke bawah disebut Death Cross — sinyal jual. Deteksi pola ini menjadi lebih mudah setelah rolling features dihitung.
Volatility Rolling: Volatilitas yang dihitung menggunakan rolling standard deviation dari Daily Return menggambarkan tingkat ketidakpastian pasar pada periode tertentu:
Nilai NA pada Awal Deret: Baris-baris awal yang
menghasilkan NA pada kolom rolling adalah hal yang normal
secara matematis — tidak cukup data historis untuk mengisi window yang
ditentukan. Misalnya, MA-200 baru mulai terisi setelah 200 baris pertama
tersedia.
Catatan pendekatan: Dataset bersifat cross-sectional — setiap baris merepresentasikan satu saham pada satu tanggal observasi yang berbeda. Data diurutkan berdasarkan tanggal agar membentuk deret waktu harga pasar secara agregat, merepresentasikan pergerakan harga lintas saham sebagai satu kontinum pasar.
# Load dataset langsung dari CSV — berdiri sendiri tanpa bergantung Bab 2
data_saham <- read.csv("6 Data-Transformation – Data Science Programming.csv")
data_p3 <- data_saham %>%
select(-1) %>%
mutate(
Date = as.Date(Date),
Stock_Price = as.numeric(Stock_Price),
Market_Cap = as.numeric(Market_Cap),
Volume_Traded = as.numeric(Volume_Traded),
PE_Ratio = as.numeric(PE_Ratio),
Dividend_Yield = as.numeric(Dividend_Yield),
Return_on_Equity = as.numeric(Return_on_Equity)
) %>%
arrange(Date) %>%
mutate(row_id = row_number())
cat("Dimensi data:", nrow(data_p3), "baris x", ncol(data_p3), "kolom\n")
## Dimensi data: 500 baris x 11 kolom
cat("Rentang tanggal:",
format(min(data_p3$Date), "%d %B %Y"), "–",
format(max(data_p3$Date), "%d %B %Y"), "\n")
## Rentang tanggal: 02 January 2020 – 29 December 2024
RSI adalah momentum indicator yang mengukur kecepatan dan besarnya perubahan harga (periode standar: 14).
\[RSI = 100 - \frac{100}{1 + RS} \quad \text{di mana} \quad RS = \frac{\text{Rata-rata Kenaikan}}{\text{Rata-rata Penurunan}}\]
data_rsi <- data_p3 %>%
mutate(
RSI_14 = as.numeric(RSI(Stock_Price, n = 14)),
RSI_Zone = case_when(
RSI_14 > 70 ~ "Overbought",
RSI_14 < 30 ~ "Oversold",
!is.na(RSI_14) ~ "Neutral",
TRUE ~ NA_character_
)
)
rsi_stats <- data_rsi %>%
filter(!is.na(RSI_14)) %>%
summarise(
Min = round(min(RSI_14), 2),
Q1 = round(quantile(RSI_14, 0.25), 2),
Median = round(median(RSI_14), 2),
Mean = round(mean(RSI_14), 2),
Q3 = round(quantile(RSI_14, 0.75), 2),
Max = round(max(RSI_14), 2)
)
knitr::kable(rsi_stats, caption = "Statistik Deskriptif RSI-14")
| Min | Q1 | Median | Mean | Q3 | Max |
|---|---|---|---|---|---|
| 42.55 | 47.34 | 49.78 | 49.89 | 52.62 | 56.99 |
rsi_zone <- data_rsi %>%
filter(!is.na(RSI_Zone)) %>%
count(RSI_Zone) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(desc(n))
knitr::kable(rsi_zone,
col.names = c("Zona RSI", "Jumlah", "Persentase (%)"),
caption = "Distribusi Zona RSI")
| Zona RSI | Jumlah | Persentase (%) |
|---|---|---|
| Neutral | 486 | 100 |
Insight RSI: Nilai rata-rata RSI ≈ 50 menunjukkan momentum pasar yang seimbang. Tidak ada zona Overbought (> 70) maupun Oversold (< 30), mengindikasikan harga-harga saham dalam dataset belum mengalami tekanan spekulatif yang ekstrem.
MACD adalah trend-following indicator dengan tiga komponen:
ema_fn <- function(x, span) as.numeric(EMA(x, n = span))
data_macd <- data_rsi %>%
mutate(
EMA_12 = ema_fn(Stock_Price, 12),
EMA_26 = ema_fn(Stock_Price, 26),
MACD_Line = EMA_12 - EMA_26,
MACD_Signal = ema_fn(MACD_Line, 9),
MACD_Histogram = MACD_Line - MACD_Signal,
MACD_Trend = case_when(
MACD_Line > 0 ~ "Bullish",
MACD_Line < 0 ~ "Bearish",
TRUE ~ "Neutral"
),
MACD_Crossover = case_when(
MACD_Histogram > 0 ~ "Buy Signal",
MACD_Histogram < 0 ~ "Sell Signal",
TRUE ~ "Neutral"
)
)
macd_stats <- data_macd %>%
filter(!is.na(MACD_Line)) %>%
summarise(
`MACD Min` = round(min(MACD_Line), 3),
`MACD Mean` = round(mean(MACD_Line), 3),
`MACD Max` = round(max(MACD_Line), 3),
`Histogram Mean` = round(mean(MACD_Histogram, na.rm = TRUE), 3)
)
knitr::kable(macd_stats, caption = "Statistik Deskriptif MACD")
| MACD Min | MACD Mean | MACD Max | Histogram Mean |
|---|---|---|---|
| -117.137 | -1.773 | 131.526 | 0.271 |
macd_trend <- data_macd %>%
filter(!is.na(MACD_Trend)) %>%
count(MACD_Trend, MACD_Crossover) %>%
mutate(Persen = round(n / sum(n) * 100, 2))
knitr::kable(macd_trend,
col.names = c("Tren MACD", "Sinyal Crossover",
"Jumlah", "Persentase (%)"),
caption = "Distribusi Tren & Sinyal Crossover MACD")
| Tren MACD | Sinyal Crossover | Jumlah | Persentase (%) |
|---|---|---|---|
| Bearish | Buy Signal | 45 | 9.0 |
| Bearish | Neutral | 8 | 1.6 |
| Bearish | Sell Signal | 200 | 40.0 |
| Bullish | Buy Signal | 187 | 37.4 |
| Bullish | Sell Signal | 35 | 7.0 |
| Neutral | Neutral | 25 | 5.0 |
Insight MACD: MACD rata-rata negatif (≈ −8.5) mengindikasikan tekanan bearish ringan secara agregat. Lebih banyak Sell Signal daripada Buy Signal menunjukkan periode dataset lebih sering dalam fase konsolidasi atau koreksi.
Bollinger Bands mengukur deviasi harga dari moving average dan menggambarkan volatilitas relatif pasar.
data_bb <- data_macd %>%
mutate(
BB_MA20 = rollmean(Stock_Price, k = 20, fill = NA, align = "right"),
BB_SD20 = rollapply(Stock_Price, width = 20, FUN = sd,
fill = NA, align = "right"),
BB_Upper = BB_MA20 + 2 * BB_SD20,
BB_Lower = BB_MA20 - 2 * BB_SD20,
BB_Width = BB_Upper - BB_Lower,
BB_PctB = (Stock_Price - BB_Lower) / (BB_Upper - BB_Lower),
BB_Position = case_when(
Stock_Price > BB_Upper ~ "Above Upper Band",
Stock_Price < BB_Lower ~ "Below Lower Band",
!is.na(BB_MA20) ~ "Within Bands",
TRUE ~ NA_character_
),
BB_Squeeze = case_when(
BB_Width < quantile(BB_Width, 0.20, na.rm = TRUE) ~ "Squeeze (Low Vol)",
BB_Width > quantile(BB_Width, 0.80, na.rm = TRUE) ~ "Expansion (High Vol)",
!is.na(BB_Width) ~ "Normal",
TRUE ~ NA_character_
)
)
bb_stats <- data_bb %>%
filter(!is.na(BB_MA20)) %>%
summarise(
`BB Width Min` = round(min(BB_Width), 2),
`BB Width Mean` = round(mean(BB_Width), 2),
`BB Width Max` = round(max(BB_Width), 2),
`%B Mean` = round(mean(BB_PctB, na.rm = TRUE), 4)
)
knitr::kable(bb_stats, caption = "Statistik Deskriptif Bollinger Bands")
| BB Width Min | BB Width Mean | BB Width Max | %B Mean |
|---|---|---|---|
| 1110.48 | 1620.7 | 1927.99 | 0.4986 |
bb_pos <- data_bb %>%
filter(!is.na(BB_Position)) %>%
count(BB_Position, BB_Squeeze) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(desc(n))
knitr::kable(bb_pos,
col.names = c("Posisi Harga", "Status Volatilitas",
"Jumlah", "Persentase (%)"),
caption = "Distribusi Posisi Harga & Status Volatilitas Bollinger Bands")
| Posisi Harga | Status Volatilitas | Jumlah | Persentase (%) |
|---|---|---|---|
| Within Bands | Normal | 289 | 60.08 |
| Within Bands | Expansion (High Vol) | 96 | 19.96 |
| Within Bands | Squeeze (Low Vol) | 94 | 19.54 |
| Below Lower Band | Squeeze (Low Vol) | 2 | 0.42 |
Insight Bollinger Bands: Bandwidth rata-rata yang besar menunjukkan volatilitas moderat. %B ≈ 0.5 mengkonfirmasi harga umumnya bergerak di tengah bands. Kondisi Squeeze adalah sinyal awal potensi breakout yang perlu diwaspadai.
tabel_ti <- data_bb %>%
select(Stock_ID, Date, Sector, Stock_Price,
RSI_14, RSI_Zone,
MACD_Line, MACD_Signal, MACD_Histogram,
MACD_Trend, MACD_Crossover,
BB_MA20, BB_Upper, BB_Lower,
BB_Width, BB_Position, BB_Squeeze) %>%
mutate(across(where(is.numeric), ~ round(., 3)))
datatable(
tabel_ti,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Tabel Lengkap Technical Indicators (RSI, MACD, Bollinger Bands)',
rownames = FALSE
)
\[Return_t = \frac{Price_t - Price_{t-1}}{Price_{t-1}} \times 100\]
Threshold menggunakan persentil 33 dan 67 agar distribusi kelas seimbang.
data_return <- data_bb %>%
mutate(
Return_Pct = (Stock_Price - lag(Stock_Price)) / lag(Stock_Price) * 100
)
q33 <- quantile(data_return$Return_Pct, 0.33, na.rm = TRUE)
q67 <- quantile(data_return$Return_Pct, 0.67, na.rm = TRUE)
cat("Threshold Return:\n")
## Threshold Return:
cat(" Persentil 33 (batas Loss–Neutral) :", round(q33, 2), "%\n")
## Persentil 33 (batas Loss–Neutral) : -32.16 %
cat(" Persentil 67 (batas Neutral–Gain) :", round(q67, 2), "%\n")
## Persentil 67 (batas Neutral–Gain) : 54.75 %
data_return <- data_return %>%
mutate(
Return_Category = case_when(
is.na(Return_Pct) ~ NA_character_,
Return_Pct < q33 ~ "Loss",
Return_Pct > q67 ~ "Gain",
TRUE ~ "Neutral"
)
)
return_dist <- data_return %>%
filter(!is.na(Return_Category)) %>%
count(Return_Category) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(desc(n))
knitr::kable(return_dist,
col.names = c("Kategori Return", "Jumlah", "Persentase (%)"),
caption = "Distribusi Kategori Return Saham")
| Kategori Return | Jumlah | Persentase (%) |
|---|---|---|
| Neutral | 169 | 33.87 |
| Gain | 165 | 33.07 |
| Loss | 165 | 33.07 |
Insight Return Category: Distribusi tiga kelas yang seimbang (~33% masing-masing) membuktikan pendekatan persentil efektif menghindari bias kelas.
data_vol <- data_return %>%
mutate(
Volatility = rollapply(Return_Pct, width = 20,
FUN = sd, fill = NA,
align = "right", na.rm = TRUE)
)
v33 <- quantile(data_vol$Volatility, 0.33, na.rm = TRUE)
v67 <- quantile(data_vol$Volatility, 0.67, na.rm = TRUE)
cat("Threshold Volatilitas:\n")
## Threshold Volatilitas:
cat(" Persentil 33 (batas Low–Medium) :", round(v33, 2), "\n")
## Persentil 33 (batas Low–Medium) : 124.17
cat(" Persentil 67 (batas Medium–High) :", round(v67, 2), "\n")
## Persentil 67 (batas Medium–High) : 190.16
data_vol <- data_vol %>%
mutate(
Volatility_Bin = case_when(
is.na(Volatility) ~ NA_character_,
Volatility <= v33 ~ "Low Risk",
Volatility <= v67 ~ "Medium Risk",
TRUE ~ "High Risk"
),
Volatility_Bin = factor(Volatility_Bin,
levels = c("Low Risk", "Medium Risk", "High Risk"))
)
vol_dist <- data_vol %>%
filter(!is.na(Volatility_Bin)) %>%
count(Volatility_Bin) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(Volatility_Bin)
knitr::kable(vol_dist,
col.names = c("Kategori Risiko", "Jumlah", "Persentase (%)"),
caption = "Distribusi Bin Volatilitas (Risiko Saham)")
| Kategori Risiko | Jumlah | Persentase (%) |
|---|---|---|
| Low Risk | 159 | 33.06 |
| Medium Risk | 163 | 33.89 |
| High Risk | 159 | 33.06 |
Insight Volatility Binning: Rentang
High Risk yang sangat lebar menunjukkan saham berisiko
tinggi dapat jauh lebih ekstrem dari kategori lainnya. Investor
konservatif disarankan fokus pada Low Risk.
Flag untuk mendeteksi posisi harga terhadap MA20 dan MA50 — dua level teknikal paling umum digunakan trader.
data_flag <- data_vol %>%
mutate(
MA_20 = rollmean(Stock_Price, k = 20, fill = NA, align = "right"),
MA_50 = rollmean(Stock_Price, k = 50, fill = NA, align = "right"),
Flag_Above_MA20 = Stock_Price > MA_20,
Flag_Above_MA50 = Stock_Price > MA_50,
MA_Signal = case_when(
is.na(MA_20) | is.na(MA_50) ~ NA_character_,
Flag_Above_MA20 & Flag_Above_MA50 ~ "Strong Uptrend",
!Flag_Above_MA20 & !Flag_Above_MA50 ~ "Strong Downtrend",
Flag_Above_MA20 & !Flag_Above_MA50 ~ "Short-Term Bullish",
!Flag_Above_MA20 & Flag_Above_MA50 ~ "Short-Term Bearish",
TRUE ~ NA_character_
)
)
ma_dist <- data_flag %>%
filter(!is.na(MA_Signal)) %>%
count(MA_Signal) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(desc(n))
knitr::kable(ma_dist,
col.names = c("Sinyal MA", "Jumlah", "Persentase (%)"),
caption = "Distribusi Flag Moving Average (MA20 & MA50)")
| Sinyal MA | Jumlah | Persentase (%) |
|---|---|---|
| Strong Downtrend | 223 | 49.45 |
| Strong Uptrend | 206 | 45.68 |
| Short-Term Bullish | 17 | 3.77 |
| Short-Term Bearish | 5 | 1.11 |
ma_price <- data_flag %>%
filter(!is.na(MA_Signal)) %>%
group_by(MA_Signal) %>%
summarise(
Avg_Price = round(mean(Stock_Price), 2),
Avg_RSI = round(mean(RSI_14, na.rm = TRUE), 2),
Avg_MACD = round(mean(MACD_Line, na.rm = TRUE), 2),
N = n()
) %>%
arrange(desc(Avg_Price))
knitr::kable(ma_price,
col.names = c("Sinyal MA", "Avg Price",
"Avg RSI", "Avg MACD", "N"),
caption = "Rata-rata Indikator per Kategori MA Signal")
| Sinyal MA | Avg Price | Avg RSI | Avg MACD | N |
|---|---|---|---|---|
| Strong Uptrend | 1151.65 | 52.94 | 26.94 | 206 |
| Short-Term Bearish | 821.17 | 49.88 | 25.33 | 5 |
| Short-Term Bullish | 748.25 | 50.00 | -15.93 | 17 |
| Strong Downtrend | 425.30 | 47.21 | -27.46 | 223 |
Insight MA Crossover: RSI dan MACD yang lebih tinggi
pada saham Strong Uptrend mengkonfirmasi
confluence ketiga indikator. Kondisi
Short-Term Bullish (di atas MA20 tapi di bawah MA50) adalah
fase transisi kritis.
data_final_all <- data_flag %>%
mutate(across(where(is.numeric), ~ round(., 4))) %>%
select(
Stock_ID, Date, Sector, Performance, Stock_Price,
RSI_14, RSI_Zone,
MACD_Line, MACD_Signal, MACD_Histogram, MACD_Trend, MACD_Crossover,
BB_MA20, BB_Upper, BB_Lower, BB_Width, BB_Position, BB_Squeeze,
Return_Pct, Return_Category,
Volatility, Volatility_Bin,
MA_20, MA_50, Flag_Above_MA20, Flag_Above_MA50, MA_Signal
)
cat("Dimensi dataset final:", nrow(data_final_all), "baris x",
ncol(data_final_all), "kolom\n")
## Dimensi dataset final: 500 baris x 27 kolom
datatable(
data_final_all,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Dataset Final Bab 3 – Technical Indicators, Kategorisasi & Binning',
rownames = FALSE
)
insight_df <- tibble(
Fitur = c(
"RSI-14", "MACD", "Bollinger Bands",
"Return Category", "Volatility Bin", "MA Signal"
),
Temuan_Utama = c(
"RSI rata-rata ≈ 50; tidak ada zona ekstrem overbought/oversold",
"MACD rata-rata negatif (≈ −8.5); lebih banyak Sell Signal",
"Bandwidth lebar; %B ≈ 0.5; harga di tengah bands",
"Distribusi Gain/Neutral/Loss seimbang; asimetri return positif",
"Tiga kelas risiko merata; High Risk sangat lebar rentangnya",
"Strong Uptrend & Downtrend seimbang; RSI/MACD konfirmasi confluence"
)
)
knitr::kable(insight_df,
col.names = c("Fitur / Proses", "Temuan Utama"),
caption = "Ringkasan Insight Bab 3 – Technical Indicators")
| Fitur / Proses | Temuan Utama |
|---|---|
| RSI-14 | RSI rata-rata ≈ 50; tidak ada zona ekstrem overbought/oversold |
| MACD | MACD rata-rata negatif (≈ −8.5); lebih banyak Sell Signal |
| Bollinger Bands | Bandwidth lebar; %B ≈ 0.5; harga di tengah bands |
| Return Category | Distribusi Gain/Neutral/Loss seimbang; asimetri return positif |
| Volatility Bin | Tiga kelas risiko merata; High Risk sangat lebar rentangnya |
| MA Signal | Strong Uptrend & Downtrend seimbang; RSI/MACD konfirmasi confluence |
\[Return_t = \frac{Price_t - Price_{t-1}}{Price_{t-1}} \times 100\]
Threshold menggunakan persentil 33 dan 67 agar distribusi kelas seimbang.
data_return <- data_bb %>%
mutate(
Return_Pct = (Stock_Price - lag(Stock_Price)) / lag(Stock_Price) * 100
)
q33 <- quantile(data_return$Return_Pct, 0.33, na.rm = TRUE)
q67 <- quantile(data_return$Return_Pct, 0.67, na.rm = TRUE)
cat("Threshold Return:\n")
## Threshold Return:
cat(" Persentil 33 (batas Loss–Neutral) :", round(q33, 2), "%\n")
## Persentil 33 (batas Loss–Neutral) : -32.16 %
cat(" Persentil 67 (batas Neutral–Gain) :", round(q67, 2), "%\n")
## Persentil 67 (batas Neutral–Gain) : 54.75 %
data_return <- data_return %>%
mutate(
Return_Category = case_when(
is.na(Return_Pct) ~ NA_character_,
Return_Pct < q33 ~ "Loss",
Return_Pct > q67 ~ "Gain",
TRUE ~ "Neutral"
)
)
return_dist <- data_return %>%
filter(!is.na(Return_Category)) %>%
count(Return_Category) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(desc(n))
knitr::kable(return_dist,
col.names = c("Kategori Return", "Jumlah", "Persentase (%)"),
caption = "Distribusi Kategori Return Saham")
| Kategori Return | Jumlah | Persentase (%) |
|---|---|---|
| Neutral | 169 | 33.87 |
| Gain | 165 | 33.07 |
| Loss | 165 | 33.07 |
Insight Return Category: Distribusi tiga kelas yang
seimbang (~33% masing-masing) membuktikan pendekatan persentil efektif
menghindari bias kelas. Rata-rata return kategori Gain
lebih besar dari Loss → asimetri keuntungan yang mendukung
strategi long-only investing.
data_vol <- data_return %>%
mutate(
Volatility = rollapply(Return_Pct, width = 20,
FUN = sd, fill = NA,
align = "right", na.rm = TRUE)
)
v33 <- quantile(data_vol$Volatility, 0.33, na.rm = TRUE)
v67 <- quantile(data_vol$Volatility, 0.67, na.rm = TRUE)
cat("Threshold Volatilitas:\n")
## Threshold Volatilitas:
cat(" Persentil 33 (batas Low–Medium) :", round(v33, 2), "\n")
## Persentil 33 (batas Low–Medium) : 124.17
cat(" Persentil 67 (batas Medium–High) :", round(v67, 2), "\n")
## Persentil 67 (batas Medium–High) : 190.16
data_vol <- data_vol %>%
mutate(
Volatility_Bin = case_when(
is.na(Volatility) ~ NA_character_,
Volatility <= v33 ~ "Low Risk",
Volatility <= v67 ~ "Medium Risk",
TRUE ~ "High Risk"
),
Volatility_Bin = factor(Volatility_Bin,
levels = c("Low Risk", "Medium Risk", "High Risk"))
)
vol_dist <- data_vol %>%
filter(!is.na(Volatility_Bin)) %>%
count(Volatility_Bin) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(Volatility_Bin)
knitr::kable(vol_dist,
col.names = c("Kategori Risiko", "Jumlah", "Persentase (%)"),
caption = "Distribusi Bin Volatilitas (Risiko Saham)")
| Kategori Risiko | Jumlah | Persentase (%) |
|---|---|---|
| Low Risk | 159 | 33.06 |
| Medium Risk | 163 | 33.89 |
| High Risk | 159 | 33.06 |
Insight Volatility Binning: Rentang
High Risk yang sangat lebar menunjukkan saham berisiko
tinggi dapat jauh lebih ekstrem dari kategori lainnya. Investor
konservatif disarankan fokus pada Low Risk, sementara
dynamic risk management wajib diterapkan untuk portofolio
campuran.
Flag untuk mendeteksi posisi harga terhadap MA20 dan MA50 — dua level teknikal paling umum digunakan trader.
data_flag <- data_vol %>%
mutate(
MA_20 = rollmean(Stock_Price, k = 20, fill = NA, align = "right"),
MA_50 = rollmean(Stock_Price, k = 50, fill = NA, align = "right"),
Flag_Above_MA20 = Stock_Price > MA_20,
Flag_Above_MA50 = Stock_Price > MA_50,
MA_Signal = case_when(
is.na(MA_20) | is.na(MA_50) ~ NA_character_,
Flag_Above_MA20 & Flag_Above_MA50 ~ "Strong Uptrend",
!Flag_Above_MA20 & !Flag_Above_MA50 ~ "Strong Downtrend",
Flag_Above_MA20 & !Flag_Above_MA50 ~ "Short-Term Bullish",
!Flag_Above_MA20 & Flag_Above_MA50 ~ "Short-Term Bearish",
TRUE ~ NA_character_
)
)
ma_dist <- data_flag %>%
filter(!is.na(MA_Signal)) %>%
count(MA_Signal) %>%
mutate(Persen = round(n / sum(n) * 100, 2)) %>%
arrange(desc(n))
knitr::kable(ma_dist,
col.names = c("Sinyal MA", "Jumlah", "Persentase (%)"),
caption = "Distribusi Flag Moving Average (MA20 & MA50)")
| Sinyal MA | Jumlah | Persentase (%) |
|---|---|---|
| Strong Downtrend | 223 | 49.45 |
| Strong Uptrend | 206 | 45.68 |
| Short-Term Bullish | 17 | 3.77 |
| Short-Term Bearish | 5 | 1.11 |
ma_price <- data_flag %>%
filter(!is.na(MA_Signal)) %>%
group_by(MA_Signal) %>%
summarise(
Avg_Price = round(mean(Stock_Price), 2),
Avg_RSI = round(mean(RSI_14, na.rm = TRUE), 2),
Avg_MACD = round(mean(MACD_Line, na.rm = TRUE), 2),
N = n()
) %>%
arrange(desc(Avg_Price))
knitr::kable(ma_price,
col.names = c("Sinyal MA", "Avg Price",
"Avg RSI", "Avg MACD", "N"),
caption = "Rata-rata Indikator per Kategori MA Signal")
| Sinyal MA | Avg Price | Avg RSI | Avg MACD | N |
|---|---|---|---|---|
| Strong Uptrend | 1151.65 | 52.94 | 26.94 | 206 |
| Short-Term Bearish | 821.17 | 49.88 | 25.33 | 5 |
| Short-Term Bullish | 748.25 | 50.00 | -15.93 | 17 |
| Strong Downtrend | 425.30 | 47.21 | -27.46 | 223 |
Insight MA Crossover: RSI dan MACD yang lebih tinggi
pada saham Strong Uptrend mengkonfirmasi
confluence ketiga indikator — validitas sinyal meningkat ketika
RSI, MACD, dan posisi MA menunjukkan arah yang sama. Kondisi
Short-Term Bullish (di atas MA20 tapi di bawah MA50) adalah
fase transisi kritis yang menentukan apakah saham akan berlanjut naik
atau kembali turun.
data_final_all <- data_flag %>%
mutate(across(where(is.numeric), ~ round(., 4))) %>%
select(
Stock_ID, Date, Sector, Performance, Stock_Price,
# Technical Indicators
RSI_14, RSI_Zone,
MACD_Line, MACD_Signal, MACD_Histogram, MACD_Trend, MACD_Crossover,
BB_MA20, BB_Upper, BB_Lower, BB_Width, BB_Position, BB_Squeeze,
# Kategorisasi & Binning
Return_Pct, Return_Category,
Volatility, Volatility_Bin,
MA_20, MA_50, Flag_Above_MA20, Flag_Above_MA50, MA_Signal
)
cat("Dimensi dataset final:", nrow(data_final_all), "baris x",
ncol(data_final_all), "kolom\n")
## Dimensi dataset final: 500 baris x 27 kolom
datatable(
data_final_all,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Dataset Final Lengkap – Semua Proses Transformasi',
rownames = FALSE
)
insight_df <- tibble(
Bab = c(
"Bab 1", "Bab 1", "Bab 1",
"Bab 2", "Bab 2", "Bab 2",
"Bab 3", "Bab 3", "Bab 3",
"Bab 3", "Bab 3", "Bab 3"
),
Fitur = c(
"Hapus Non-Trading", "Forward-Fill", "Filter Tanggal",
"Outlier Flag", "Encoding", "Normalization",
"RSI-14", "MACD", "Bollinger Bands",
"Return Category", "Volatility Bin", "MA Signal"
),
Temuan_Utama = c(
"138 baris hari Sabtu/Minggu dihapus",
"Tidak ada missing value setelah forward-fill",
"Data valid rentang 2020–2024",
"18 outlier IQR dipertahankan dengan flag",
"Label encoding ordinal + 5 kolom one-hot sektor",
"Z-score (mean≈0, SD≈1) & min-max ([0,1]) diterapkan",
"RSI rata-rata ≈ 50; tidak ada zona ekstrem overbought/oversold",
"MACD rata-rata negatif (≈ −8.5); lebih banyak Sell Signal",
"Bandwidth lebar; %B ≈ 0.5; harga di tengah bands",
"Distribusi Gain/Neutral/Loss seimbang; asimetri return positif",
"Tiga kelas risiko merata; High Risk sangat lebar rentangnya",
"Strong Uptrend & Downtrend seimbang; RSI/MACD konfirmasi confluence"
)
)
knitr::kable(insight_df,
col.names = c("Bab", "Fitur / Proses", "Temuan Utama"),
caption = "Ringkasan Seluruh Proses Transformasi Data")
| Bab | Fitur / Proses | Temuan Utama |
|---|---|---|
| Bab 1 | Hapus Non-Trading | 138 baris hari Sabtu/Minggu dihapus |
| Bab 1 | Forward-Fill | Tidak ada missing value setelah forward-fill |
| Bab 1 | Filter Tanggal | Data valid rentang 2020–2024 |
| Bab 2 | Outlier Flag | 18 outlier IQR dipertahankan dengan flag |
| Bab 2 | Encoding | Label encoding ordinal + 5 kolom one-hot sektor |
| Bab 2 | Normalization | Z-score (mean≈0, SD≈1) & min-max ([0,1]) diterapkan |
| Bab 3 | RSI-14 | RSI rata-rata ≈ 50; tidak ada zona ekstrem overbought/oversold |
| Bab 3 | MACD | MACD rata-rata negatif (≈ −8.5); lebih banyak Sell Signal |
| Bab 3 | Bollinger Bands | Bandwidth lebar; %B ≈ 0.5; harga di tengah bands |
| Bab 3 | Return Category | Distribusi Gain/Neutral/Loss seimbang; asimetri return positif |
| Bab 3 | Volatility Bin | Tiga kelas risiko merata; High Risk sangat lebar rentangnya |
| Bab 3 | MA Signal | Strong Uptrend & Downtrend seimbang; RSI/MACD konfirmasi confluence |
# Load dataset langsung dari CSV
data_saham <- read.csv("6 Data-Transformation – Data Science Programming.csv")
data_clean <- data_saham %>%
select(-1) %>%
mutate(
Date = as.Date(Date),
Stock_Price = as.numeric(Stock_Price),
Market_Cap = as.numeric(Market_Cap),
Volume_Traded = as.numeric(Volume_Traded),
PE_Ratio = as.numeric(PE_Ratio),
Dividend_Yield = as.numeric(Dividend_Yield),
Return_on_Equity = as.numeric(Return_on_Equity)
)
# Kolom numerik yang akan diproses
num_cols <- c("Stock_Price", "Market_Cap", "Volume_Traded",
"PE_Ratio", "Dividend_Yield", "Return_on_Equity")
cat("Dimensi data:", nrow(data_clean), "baris x", ncol(data_clean), "kolom\n")
## Dimensi data: 500 baris x 10 kolom
\[Z = \frac{x - \mu}{\sigma}\]
Nilai dianggap outlier jika \(|Z| > 3\).
zscore_summary <- data_clean %>%
summarise(across(
all_of(num_cols),
~ sum(abs((. - mean(., na.rm = TRUE)) / sd(., na.rm = TRUE)) > 3,
na.rm = TRUE),
.names = "{.col}"
)) %>%
pivot_longer(everything(),
names_to = "Kolom",
values_to = "Jumlah_Outlier_ZScore")
knitr::kable(zscore_summary,
caption = "Jumlah Outlier per Kolom – Metode Z-Score (|Z| > 3)",
col.names = c("Kolom", "Jumlah Outlier"))
| Kolom | Jumlah Outlier |
|---|---|
| Stock_Price | 0 |
| Market_Cap | 1 |
| Volume_Traded | 0 |
| PE_Ratio | 0 |
| Dividend_Yield | 0 |
| Return_on_Equity | 1 |
\[IQR = Q_3 - Q_1 \quad ; \quad \text{Batas} = [Q_1 - 1.5 \times IQR,\ Q_3 + 1.5 \times IQR]\]
iqr_table <- map_dfr(num_cols, function(col) {
Q1 <- quantile(data_clean[[col]], 0.25, na.rm = TRUE)
Q3 <- quantile(data_clean[[col]], 0.75, na.rm = TRUE)
IQR_v <- IQR(data_clean[[col]], na.rm = TRUE)
tibble(
Kolom = col,
Q1 = round(Q1, 2),
Q3 = round(Q3, 2),
IQR = round(IQR_v, 2),
Batas_Bawah = round(Q1 - 1.5 * IQR_v, 2),
Batas_Atas = round(Q3 + 1.5 * IQR_v, 2)
)
})
knitr::kable(iqr_table, caption = "Batas IQR per Kolom Numerik")
| Kolom | Q1 | Q3 | IQR | Batas_Bawah | Batas_Atas |
|---|---|---|---|---|---|
| Stock_Price | 416.36 | 1135.04 | 718.67 | -661.65 | 2.213050e+03 |
| Market_Cap | 113384781.82 | 568574109.14 | 455189327.31 | -569399209.14 | 1.251358e+09 |
| Volume_Traded | 236653.50 | 737370.50 | 500717.00 | -514422.00 | 1.488446e+06 |
| PE_Ratio | 11.87 | 18.12 | 6.25 | 2.49 | 2.750000e+01 |
| Dividend_Yield | 2.46 | 7.91 | 5.45 | -5.72 | 1.609000e+01 |
| Return_on_Equity | 10.03 | 14.12 | 4.09 | 3.89 | 2.026000e+01 |
iqr_outlier_count <- map_dfr(num_cols, function(col) {
Q1 <- quantile(data_clean[[col]], 0.25, na.rm = TRUE)
Q3 <- quantile(data_clean[[col]], 0.75, na.rm = TRUE)
IQR_v <- IQR(data_clean[[col]], na.rm = TRUE)
lower <- Q1 - 1.5 * IQR_v
upper <- Q3 + 1.5 * IQR_v
n_out <- sum(data_clean[[col]] < lower | data_clean[[col]] > upper, na.rm = TRUE)
tibble(Kolom = col, Jumlah_Outlier_IQR = n_out,
Batas_Bawah = round(lower, 2), Batas_Atas = round(upper, 2))
})
knitr::kable(iqr_outlier_count,
caption = "Jumlah Outlier per Kolom – Metode IQR",
col.names = c("Kolom", "Jumlah Outlier", "Batas Bawah", "Batas Atas"))
| Kolom | Jumlah Outlier | Batas Bawah | Batas Atas |
|---|---|---|---|
| Stock_Price | 0 | -661.65 | 2.213040e+03 |
| Market_Cap | 8 | -569399209.14 | 1.251358e+09 |
| Volume_Traded | 0 | -514422.00 | 1.488446e+06 |
| PE_Ratio | 8 | 2.48 | 2.750000e+01 |
| Dividend_Yield | 0 | -5.72 | 1.609000e+01 |
| Return_on_Equity | 4 | 3.89 | 2.026000e+01 |
data_flagged <- data_clean %>%
mutate(
Flag_ZScore = if_any(
all_of(num_cols),
~ abs((. - mean(., na.rm = TRUE)) / sd(., na.rm = TRUE)) > 3
)
)
for (col in num_cols) {
Q1 <- quantile(data_flagged[[col]], 0.25, na.rm = TRUE)
Q3 <- quantile(data_flagged[[col]], 0.75, na.rm = TRUE)
IQR_v <- IQR(data_flagged[[col]], na.rm = TRUE)
lower <- Q1 - 1.5 * IQR_v
upper <- Q3 + 1.5 * IQR_v
data_flagged[[paste0("IQR_out_", col)]] <-
data_flagged[[col]] < lower | data_flagged[[col]] > upper
}
data_flagged <- data_flagged %>%
mutate(
Flag_IQR = if_any(starts_with("IQR_out_"), ~ .),
Outlier_Flag = case_when(
Flag_ZScore & Flag_IQR ~ "Z-Score & IQR",
Flag_ZScore ~ "Z-Score Only",
Flag_IQR ~ "IQR Only",
TRUE ~ "Normal"
)
) %>%
select(-starts_with("IQR_out_"), -Flag_ZScore, -Flag_IQR)
flag_summary <- data_flagged %>%
count(Outlier_Flag) %>%
mutate(Persen = round(n / sum(n) * 100, 2))
knitr::kable(flag_summary,
col.names = c("Status Outlier", "Jumlah", "Persentase (%)"),
caption = "Ringkasan Penandaan Outlier")
| Status Outlier | Jumlah | Persentase (%) |
|---|---|---|
| IQR Only | 18 | 3.6 |
| Normal | 480 | 96.0 |
| Z-Score & IQR | 2 | 0.4 |
data_outlier_view <- data_flagged %>%
filter(Outlier_Flag != "Normal") %>%
select(Stock_ID, Date, Stock_Price, Market_Cap,
PE_Ratio, Return_on_Equity, Outlier_Flag)
datatable(
data_outlier_view,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Baris yang Terdeteksi sebagai Outlier (Dipertahankan dengan Flag)',
rownames = FALSE
)
Metode Z-Score: Z-Score mengukur seberapa jauh suatu nilai menyimpang dari rata-rata dalam satuan standar deviasi. Nilai \(|Z| > 3\) berarti data tersebut berada di luar 99.7% distribusi normal — hal ini sangat jarang terjadi secara alami, sehingga layak dicurigai sebagai outlier. Pada data pasar keuangan, outlier Z-Score biasanya mencerminkan kejadian ekstrem seperti crash pasar, short-squeeze, atau lonjakan volume akibat aksi korporasi.
Metode IQR: IQR lebih robust terhadap distribusi yang tidak simetris (skewed) — kondisi yang sangat umum pada data keuangan karena adanya ekor distribusi yang panjang (fat tails). Metode ini tidak terpengaruh oleh nilai ekstrem seperti halnya Z-Score yang berbasis mean dan standar deviasi.
Strategi Pertahankan dengan Flag (Flagging):
Keputusan untuk mempertahankan outlier dengan
menambahkan kolom Outlier_Flag adalah pendekatan yang tepat
dalam konteks pasar keuangan, karena:
Perbandingan Z-Score vs IQR: Jika sebuah data terdeteksi oleh kedua metode sekaligus (“Z-Score & IQR”), maka tingkat keyakinan bahwa data tersebut adalah outlier sangat tinggi. Data yang hanya terdeteksi salah satu metode perlu dievaluasi lebih lanjut secara kontekstual.
Performancedata_encoded <- data_flagged %>%
mutate(
Performance_Label = case_when(
Performance == "Negative" ~ 0L,
Performance == "Stable" ~ 1L,
Performance == "Positive" ~ 2L,
TRUE ~ NA_integer_
)
)
data_encoded %>%
distinct(Performance, Performance_Label) %>%
arrange(Performance_Label) %>%
knitr::kable(col.names = c("Performance (Asli)", "Label Encoded"),
caption = "Mapping Label Encoding – Kolom Performance")
| Performance (Asli) | Label Encoded |
|---|---|
| Negative | 0 |
| Stable | 1 |
| Positive | 2 |
Sectordata_encoded <- data_encoded %>%
mutate(
Sector_Consumer_Goods = as.integer(Sector == "Consumer Goods"),
Sector_Energy = as.integer(Sector == "Energy"),
Sector_Finance = as.integer(Sector == "Finance"),
Sector_Healthcare = as.integer(Sector == "Healthcare"),
Sector_Technology = as.integer(Sector == "Technology")
)
data_encoded %>%
distinct(Sector, Sector_Consumer_Goods, Sector_Energy,
Sector_Finance, Sector_Healthcare, Sector_Technology) %>%
arrange(Sector) %>%
knitr::kable(caption = "Mapping One-Hot Encoding – Kolom Sector")
| Sector | Sector_Consumer_Goods | Sector_Energy | Sector_Finance | Sector_Healthcare | Sector_Technology |
|---|---|---|---|---|---|
| Consumer Goods | 1 | 0 | 0 | 0 | 0 |
| Energy | 0 | 1 | 0 | 0 | 0 |
| Finance | 0 | 0 | 1 | 0 | 0 |
| Healthcare | 0 | 0 | 0 | 1 | 0 |
| Technology | 0 | 0 | 0 | 0 | 1 |
datatable(
data_encoded %>%
select(Stock_ID, Date, Sector, Performance,
Performance_Label, starts_with("Sector_"), Outlier_Flag),
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Tabel Hasil Encoding Variabel Kategorikal',
rownames = FALSE
)
Label Encoding – Performance: Kolom
Performance memiliki tiga kategori yang bersifat
ordinal (memiliki urutan yang bermakna):
Negative < Stable <
Positive. Oleh karena itu, label encoding dengan nilai 0,
1, 2 adalah pilihan yang tepat karena mempertahankan hubungan urutan
tersebut. Jika menggunakan one-hot encoding pada variabel ordinal,
informasi urutan ini akan hilang.
One-Hot Encoding – Sector: Kolom
Sector bersifat nominal (tidak memiliki
urutan alami antar sektor). Penggunaan one-hot encoding menghasilkan
lima kolom biner baru (Sector_Consumer_Goods,
Sector_Energy, Sector_Finance,
Sector_Healthcare, Sector_Technology), yang
memastikan model tidak salah menginterpretasikan perbedaan antar sektor
sebagai perbedaan yang berjenjang. Setiap baris hanya memiliki satu
nilai 1 di antara kelima kolom tersebut.
Implikasi untuk Pemodelan: Setelah encoding, semua variabel kategorikal sudah dalam format numerik yang siap digunakan sebagai input untuk algoritma machine learning (regresi, random forest, neural network, dll.) yang membutuhkan input numerik.
\[Z = \frac{x - \mu}{\sigma}\]
zscore_cols <- c("Stock_Price", "Volume_Traded",
"Return_on_Equity", "Dividend_Yield")
data_normalized <- data_encoded %>%
mutate(across(
all_of(zscore_cols),
~ (. - mean(., na.rm = TRUE)) / sd(., na.rm = TRUE),
.names = "zscore_{.col}"
))
zscore_check <- data_normalized %>%
summarise(across(
starts_with("zscore_"),
list(Mean = ~ round(mean(., na.rm = TRUE), 4),
SD = ~ round(sd(., na.rm = TRUE), 4))
)) %>%
pivot_longer(everything(),
names_to = c("Kolom", "Statistik"),
names_sep = "_(?=[^_]+$)") %>%
pivot_wider(names_from = Statistik, values_from = value) %>%
mutate(Kolom = str_remove(Kolom, "zscore_"))
knitr::kable(zscore_check,
caption = "Verifikasi Z-Score Normalization (Mean ≈ 0, SD ≈ 1)",
col.names = c("Kolom", "Mean", "SD"))
| Kolom | Mean | SD |
|---|---|---|
| Stock_Price | 0 | 1 |
| Volume_Traded | 0 | 1 |
| Return_on_Equity | 0 | 1 |
| Dividend_Yield | 0 | 1 |
\[x_{scaled} = \frac{x - x_{min}}{x_{max} - x_{min}}\]
minmax_cols <- c("Market_Cap", "PE_Ratio", "Stock_Price")
data_normalized <- data_normalized %>%
mutate(across(
all_of(minmax_cols),
~ (. - min(., na.rm = TRUE)) / (max(., na.rm = TRUE) - min(., na.rm = TRUE)),
.names = "minmax_{.col}"
))
minmax_check <- data_normalized %>%
summarise(across(
starts_with("minmax_"),
list(Min = ~ round(min(., na.rm = TRUE), 4),
Max = ~ round(max(., na.rm = TRUE), 4))
)) %>%
pivot_longer(everything(),
names_to = c("Kolom", "Statistik"),
names_sep = "_(?=[^_]+$)") %>%
pivot_wider(names_from = Statistik, values_from = value) %>%
mutate(Kolom = str_remove(Kolom, "minmax_"))
knitr::kable(minmax_check,
caption = "Verifikasi Min-Max Scaling (Min = 0, Max = 1)",
col.names = c("Kolom", "Min", "Max"))
| Kolom | Min | Max |
|---|---|---|
| Market_Cap | 0 | 1 |
| PE_Ratio | 0 | 1 |
| Stock_Price | 0 | 1 |
compare_df <- tibble(
Kondisi = c("Sebelum Scaling", "Z-Score Normalized", "Min-Max Scaled"),
Min = c(
round(min(data_clean$Stock_Price, na.rm = TRUE), 2),
round(min(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(min(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
),
Mean = c(
round(mean(data_clean$Stock_Price, na.rm = TRUE), 2),
round(mean(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(mean(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
),
Max = c(
round(max(data_clean$Stock_Price, na.rm = TRUE), 2),
round(max(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(max(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
),
SD = c(
round(sd(data_clean$Stock_Price, na.rm = TRUE), 2),
round(sd(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(sd(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
)
)
knitr::kable(compare_df,
caption = "Perbandingan Statistik Stock_Price Sebelum & Sesudah Scaling",
col.names = c("Kondisi", "Minimum", "Mean", "Maximum", "Std Dev"))
| Kondisi | Minimum | Mean | Maximum | Std Dev |
|---|---|---|---|---|
| Sebelum Scaling | 100.5700 | 781.2300 | 1499.1300 | 409.3300 |
| Z-Score Normalized | -1.6629 | 0.0000 | 1.7538 | 1.0000 |
| Min-Max Scaled | 0.0000 | 0.4867 | 1.0000 | 0.2927 |
Z-Score Normalization: Hasil verifikasi menunjukkan
bahwa setelah normalisasi, mean kolom mendekati 0 dan standar deviasi
mendekati 1 — ini mengkonfirmasi bahwa normalisasi berjalan dengan
benar. Z-Score cocok digunakan untuk kolom Stock_Price,
Volume_Traded, Return_on_Equity, dan
Dividend_Yield karena kolom-kolom ini memiliki satuan dan
skala yang sangat berbeda. Normalisasi Z-Score juga lebih robust
terhadap outlier dibandingkan Min-Max karena tidak bergantung pada nilai
minimum dan maksimum absolut.
Min-Max Scaling: Setelah scaling, seluruh nilai
Market_Cap, PE_Ratio, dan
Stock_Price berada dalam rentang [0, 1]. Min-Max scaling
menjaga proporsi relatif antar nilai dan cocok ketika algoritma yang
digunakan sensitif terhadap rentang absolut, seperti K-Nearest Neighbors
(KNN) atau Neural Network. Kelemahannya adalah sensitif terhadap outlier
— satu nilai ekstrem dapat “mendorong” semua nilai lain menuju 0.
Pemilihan Metode per Kolom: Pilihan menggunakan Z-Score untuk variabel return/yield dan Min-Max untuk harga/market cap mencerminkan pertimbangan yang matang: variabel return memiliki distribusi yang lebih mendekati normal (cocok untuk Z-Score), sementara harga dan market cap memiliki rentang yang lebar dan perlu dikompres ke skala yang sama.
Perbandingan Sebelum & Sesudah: Tabel perbandingan mengkonfirmasi bahwa transformasi tidak mengubah bentuk distribusi — hanya mengubah skala. Rata-rata Z-Score mendekati 0, dan Min-Max menghasilkan rentang 0 hingga 1 seperti yang diharapkan.
data_p2_final <- data_normalized %>%
select(
Stock_ID, Date, Sector, Performance,
Stock_Price, Market_Cap, Volume_Traded,
PE_Ratio, Dividend_Yield, Return_on_Equity,
Outlier_Flag, Performance_Label,
starts_with("Sector_"),
starts_with("zscore_"),
starts_with("minmax_")
)
cat("Dimensi dataset Bab 2:", nrow(data_p2_final), "baris x",
ncol(data_p2_final), "kolom\n")
## Dimensi dataset Bab 2: 500 baris x 24 kolom
datatable(
data_p2_final,
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Dataset Final Bab 2 – Outlier Detection, Encoding & Normalization',
rownames = FALSE
)
data_encoded <- data_flagged %>%
mutate(
Performance_Label = case_when(
Performance == "Negative" ~ 0L,
Performance == "Stable" ~ 1L,
Performance == "Positive" ~ 2L,
TRUE ~ NA_integer_
)
)
data_encoded %>%
distinct(Performance, Performance_Label) %>%
arrange(Performance_Label) %>%
knitr::kable(col.names = c("Performance (Asli)", "Label Encoded"),
caption = "Mapping Label Encoding – Kolom Performance")
| Performance (Asli) | Label Encoded |
|---|---|
| Negative | 0 |
| Stable | 1 |
| Positive | 2 |
Interpretasi Label Encoding: Variabel
Performance bersifat ordinal — terdapat urutan yang
bermakna antara Negative (0), Stable (1), dan
Positive (2). Label encoding dengan angka berurutan
mempertahankan hubungan ordinal ini, sehingga model dapat memahami bahwa
Positive lebih baik dari Stable, dan
Stable lebih baik dari Negative. Penggunaan
one-hot encoding pada variabel ordinal justru akan kehilangan informasi
hierarki ini.
data_encoded <- data_encoded %>%
mutate(
Sector_Consumer_Goods = as.integer(Sector == "Consumer Goods"),
Sector_Energy = as.integer(Sector == "Energy"),
Sector_Finance = as.integer(Sector == "Finance"),
Sector_Healthcare = as.integer(Sector == "Healthcare"),
Sector_Technology = as.integer(Sector == "Technology")
)
data_encoded %>%
distinct(Sector, Sector_Consumer_Goods, Sector_Energy,
Sector_Finance, Sector_Healthcare, Sector_Technology) %>%
arrange(Sector) %>%
knitr::kable(caption = "Mapping One-Hot Encoding – Kolom Sector")
| Sector | Sector_Consumer_Goods | Sector_Energy | Sector_Finance | Sector_Healthcare | Sector_Technology |
|---|---|---|---|---|---|
| Consumer Goods | 1 | 0 | 0 | 0 | 0 |
| Energy | 0 | 1 | 0 | 0 | 0 |
| Finance | 0 | 0 | 1 | 0 | 0 |
| Healthcare | 0 | 0 | 0 | 1 | 0 |
| Technology | 0 | 0 | 0 | 0 | 1 |
datatable(
data_encoded %>%
select(Stock_ID, Date, Sector, Performance,
Performance_Label, starts_with("Sector_"), Outlier_Flag),
extensions = 'Buttons',
options = list(
dom = 'Blfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
pageLength = 10,
autoWidth = TRUE,
scrollX = TRUE
),
caption = 'Tabel Hasil Encoding Variabel Kategorikal',
rownames = FALSE
)
Interpretasi One-Hot Encoding: Kolom
Sector bersifat nominal — tidak ada urutan alami antara
Finance, Technology, Healthcare,
dan sektor lainnya. One-Hot Encoding menghasilkan lima kolom biner yang
saling eksklusif, di mana setiap baris hanya memiliki nilai
1 pada kolom yang sesuai dengan sektornya. Pendekatan ini
mencegah model menginterpretasikan perbedaan antar sektor sebagai
perbedaan ordinal yang tidak bermakna. Dalam konteks analisis pasar
keuangan, perbedaan perilaku antar sektor (seperti volatilitas sektor
Technology yang cenderung lebih tinggi dibandingkan
Consumer Goods) dapat ditangkap secara lebih akurat oleh
model.
\[Z = \frac{x - \mu}{\sigma}\]
zscore_cols <- c("Stock_Price", "Volume_Traded",
"Return_on_Equity", "Dividend_Yield")
data_normalized <- data_encoded %>%
mutate(across(
all_of(zscore_cols),
~ (. - mean(., na.rm = TRUE)) / sd(., na.rm = TRUE),
.names = "zscore_{.col}"
))
zscore_check <- data_normalized %>%
summarise(across(
starts_with("zscore_"),
list(Mean = ~ round(mean(., na.rm = TRUE), 4),
SD = ~ round(sd(., na.rm = TRUE), 4))
)) %>%
pivot_longer(everything(),
names_to = c("Kolom", "Statistik"),
names_sep = "_(?=[^_]+$)") %>%
pivot_wider(names_from = Statistik, values_from = value) %>%
mutate(Kolom = str_remove(Kolom, "zscore_"))
knitr::kable(zscore_check,
caption = "Verifikasi Z-Score Normalization (Mean ≈ 0, SD ≈ 1)",
col.names = c("Kolom", "Mean", "SD"))
| Kolom | Mean | SD |
|---|---|---|
| Stock_Price | 0 | 1 |
| Volume_Traded | 0 | 1 |
| Return_on_Equity | 0 | 1 |
| Dividend_Yield | 0 | 1 |
Interpretasi Z-Score Normalization: Hasil verifikasi
memperlihatkan bahwa setelah normalisasi, mean seluruh kolom mendekati 0
dan standar deviasi mendekati 1 — ini mengkonfirmasi proses normalisasi
berjalan dengan benar. Z-Score normalization dipilih untuk
Stock_Price, Volume_Traded,
Return_on_Equity, dan Dividend_Yield karena
variabel-variabel ini memiliki satuan yang berbeda-beda namun
distribusinya relatif mendekati simetris. Setelah Z-Score, perbedaan
skala antar variabel hilang sehingga model tidak akan bias terhadap
variabel dengan nilai absolut terbesar.
\[x_{scaled} = \frac{x - x_{min}}{x_{max} - x_{min}}\]
minmax_cols <- c("Market_Cap", "PE_Ratio", "Stock_Price")
data_normalized <- data_normalized %>%
mutate(across(
all_of(minmax_cols),
~ (. - min(., na.rm = TRUE)) / (max(., na.rm = TRUE) - min(., na.rm = TRUE)),
.names = "minmax_{.col}"
))
minmax_check <- data_normalized %>%
summarise(across(
starts_with("minmax_"),
list(Min = ~ round(min(., na.rm = TRUE), 4),
Max = ~ round(max(., na.rm = TRUE), 4))
)) %>%
pivot_longer(everything(),
names_to = c("Kolom", "Statistik"),
names_sep = "_(?=[^_]+$)") %>%
pivot_wider(names_from = Statistik, values_from = value) %>%
mutate(Kolom = str_remove(Kolom, "minmax_"))
knitr::kable(minmax_check,
caption = "Verifikasi Min-Max Scaling (Min = 0, Max = 1)",
col.names = c("Kolom", "Min", "Max"))
| Kolom | Min | Max |
|---|---|---|
| Market_Cap | 0 | 1 |
| PE_Ratio | 0 | 1 |
| Stock_Price | 0 | 1 |
Interpretasi Min-Max Scaling: Seluruh nilai
Market_Cap, PE_Ratio, dan
Stock_Price berhasil dikompres ke dalam rentang [0, 1].
Min-Max scaling digunakan untuk variabel-variabel ini karena perlu
mempertahankan interpretasi proporsional — misalnya, saham dengan
minmax_Stock_Price = 0.8 berarti harganya berada di 80%
dari rentang harga seluruh dataset. Min-Max scaling ideal untuk
visualisasi komparatif dan algoritma berbasis jarak seperti KNN. Namun
perlu dicatat: jika di masa mendatang muncul data baru dengan harga di
luar rentang historis, nilai scaled-nya bisa keluar dari [0, 1] —
kondisi ini perlu diantisipasi saat deployment model.
compare_df <- tibble(
Kondisi = c("Sebelum Scaling", "Z-Score Normalized", "Min-Max Scaled"),
Min = c(
round(min(data_clean$Stock_Price, na.rm = TRUE), 2),
round(min(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(min(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
),
Mean = c(
round(mean(data_clean$Stock_Price, na.rm = TRUE), 2),
round(mean(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(mean(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
),
Max = c(
round(max(data_clean$Stock_Price, na.rm = TRUE), 2),
round(max(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(max(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
),
SD = c(
round(sd(data_clean$Stock_Price, na.rm = TRUE), 2),
round(sd(data_normalized$zscore_Stock_Price, na.rm = TRUE), 4),
round(sd(data_normalized$minmax_Stock_Price, na.rm = TRUE), 4)
)
)
# ← TAMBAHKAN INI
knitr::kable(compare_df,
caption = "Perbandingan Statistik Stock_Price Sebelum & Sesudah Scaling",
col.names = c("Kondisi", "Minimum", "Mean", "Maximum", "Std Dev"))
| Kondisi | Minimum | Mean | Maximum | Std Dev |
|---|---|---|---|---|
| Sebelum Scaling | 100.5700 | 781.2300 | 1499.1300 | 409.3300 |
| Z-Score Normalized | -1.6629 | 0.0000 | 1.7538 | 1.0000 |
| Min-Max Scaled | 0.0000 | 0.4867 | 1.0000 | 0.2927 |
Tabel perbandingan mengkonfirmasi bahwa kedua metode scaling berhasil mengubah skala tanpa mengubah bentuk distribusi data. Nilai mean Z-Score mendekati 0 dan Min-Max berada di antara 0 dan 1, sesuai dengan definisi matematis masing-masing metode.
Penting untuk dipahami bahwa scaling tidak menghilangkan outlier — nilai ekstrem tetap ada namun sudah berada dalam skala yang sebanding dengan variabel lain. Pada konteks pasar keuangan, hal ini berarti kejadian pasar yang ekstrem (seperti crash atau rally besar) tetap terwakili secara proporsional dalam dataset yang sudah dinormalisasi.
Seluruh proses normalisasi dan scaling telah berhasil mempersiapkan dataset untuk tahap pemodelan lanjutan. Berikut ringkasan pilihan metode yang diterapkan:
| Kolom | Metode | Alasan |
|---|---|---|
Stock_Price |
Z-Score & Min-Max | Diterapkan keduanya untuk perbandingan |
Volume_Traded |
Z-Score | Distribusi mendekati normal, satuan berbeda |
Return_on_Equity |
Z-Score | Nilai bisa negatif, Z-Score lebih tepat |
Dividend_Yield |
Z-Score | Persentase kecil, perlu standarisasi |
Market_Cap |
Min-Max | Nilai sangat besar, perlu dikompres ke [0,1] |
PE_Ratio |
Min-Max | Rasio positif, interpretasi proporsional lebih mudah |
Dataset final kini memiliki semua fitur yang diperlukan untuk analisis prediktif pasar keuangan — mulai dari indikator teknikal, kategorisasi risiko, hingga variabel yang sudah terstandarisasi dan siap digunakan sebagai input model machine learning.