Fityanandra
Fityanandra Athar Adyaksa
52250059
Adinda Adelia Futri
Adinda Adelia Futri
52250055
Angel F
Angelica Florentina M
52250063
Syafif Azmi
Syafif Azmi Lontoh
52250060
Syahra
Naila Syahrani Putri
52250070
Morris
Morris Alexander Pangaribuan
52250058
Raihaniaputri
Raihania Syahputri
52250054
Richie
Adam Richie Wijaya
52250064
Carol
Carol Dupino Pereira
52250051
Idor
Iganisius Rabi Blolong
52250073

Pendahuluan

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.

Tujuan Praktikum

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:

  1. Data Cleaning — membersihkan data mentah dari inkonsistensi tipe, hari non-trading, dan nilai yang hilang
  2. Feature Engineering — membangun fitur-fitur baru seperti Daily Return, Log Return, dan Lag Features yang relevan untuk analisis temporal
  3. Temporal & Rolling Features — menghitung Moving Average dan Rolling Volatility sebagai representasi tren dan risiko jangka pendek maupun panjang
  4. Technical Indicators — mengimplementasikan indikator teknikal standar industri (RSI, MACD, Bollinger Bands) yang digunakan oleh analis dan trader profesional
  5. Categorization & Binning — mengkategorikan return dan volatilitas ke dalam kelas-kelas diskrit yang bermakna
  6. Outlier Detection & Handling — mendeteksi dan menangani nilai ekstrem menggunakan metode Z-Score dan IQR
  7. Categorical Encoding — mengonversi variabel kategorikal menjadi representasi numerik yang siap digunakan model
  8. Normalization & Scaling — menstandarisasi skala fitur numerik agar model tidak bias terhadap variabel dengan magnitude terbesar

Dataset

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

Metodologi

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
)

BAB 1 – Data Cleaning

Load Library & Import Data

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…

Tabel Data Mentah (Sebelum Cleaning)

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
)

Konversi Tipe Data & Hapus Kolom Index

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…

Identifikasi & Hapus Hari Non-Trading

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"))
Distribusi Hari dalam Dataset
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

Forward-Fill Harga yang Hilang

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

Filter Rentang Tanggal Relevan

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

Ringkasan Statistik Data Bersih

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")
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

Tabel Data Bersih (Final)

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
)

Rangkuman Proses Cleaning

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")
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

Interpretasi & Insight – Bab 1 (Data Cleaning)

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.


BAB 2 - Feature Engineering

Daily Return

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

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

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.


Implementasi Feature Engineering

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

Tabel Interaktif Hasil Feature Engineering

# 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
)

Visualisasi Distribusi Fitur

Distribusi Stock_Price per Sektor

data_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()

Distribusi Log Return PE_Ratio vs Return_on_Equity

data_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()

pengganti boxplot Daily Return per Sektor

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))

Distribution peformance per Sektor

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))


Interpretasi

Daily Return

Daily Return mengukur perubahan harga saham relatif dari satu periode ke periode berikutnya. Dari hasil analisis:

  • Distribusi Daily Return mendekati simetris di sekitar nol, yang mengindikasikan bahwa kenaikan dan penurunan harga terjadi dengan frekuensi yang relatif seimbang di dataset ini.
  • Nilai positif menunjukkan saham mengalami kenaikan harga dibandingkan hari sebelumnya, sedangkan nilai negatif menandakan penurunan harga.
  • Karena data ini merupakan lintas-saham (cross-sectional) dengan tanggal yang berbeda-beda per saham, Daily Return yang dihitung mencerminkan selisih harga antar dua observasi dari saham yang sama, bukan pergerakan pasar secara agregat.

Log Return

Log Return memiliki sifat matematis yang lebih menguntungkan dibandingkan Daily Return biasa:

  • Simetris: Log Return dari kenaikan dan penurunan yang sama besar akan bernilai berlawanan tanda namun sama absolut (contoh: naik 10% dan turun 10% menghasilkan nilai yang simetris).
  • Additive: Log Return bersifat kumulatif secara aditif, sehingga cocok untuk analisis jangka panjang.
  • Dari grafik scatter, terlihat bahwa Daily Return dan Log Return sangat berkorelasi dan hampir identik untuk nilai-nilai kecil, namun mulai berbeda saat return mendekati nilai ekstrem — hal ini sesuai dengan sifat matematisnya (\(\log(1+x) \approx x\) untuk \(x\) kecil).

Lag Features

Lag features digunakan untuk menangkap pola temporal dalam data:

  • Lag_Price_1 & Lag_Price_2: Harga saham 1 dan 2 periode sebelumnya. Fitur ini berguna sebagai input model prediksi harga atau sebagai indikator tren (apakah harga naik atau turun secara konsisten).
  • Lag_Return_1 & Lag_Return_2: Return dari 1 dan 2 periode sebelumnya. Dalam analisis teknikal, lag return dapat mencerminkan momentum atau mean reversion — apakah return positif di masa lalu cenderung diikuti return positif lagi (momentum) atau sebaliknya (reversal).
  • Nilai NA pada baris pertama setiap saham adalah hal yang wajar karena tidak ada data sebelumnya yang bisa dijadikan referensi lag.

Perbandingan Antar Sektor

Dari boxplot Daily Return per sektor, terlihat bahwa:

  • Semua sektor memiliki median return mendekati nol, yang wajar untuk data lintas-saham dengan periode waktu berbeda.
  • Sebaran (spread) return berbeda antar sektor, mencerminkan perbedaan volatilitas antar industri.
  • Outlier yang muncul di setiap sektor menunjukkan adanya saham dengan perubahan harga yang signifikan pada periode tertentu.

Kesimpulan

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.


BAB 3 - Temporal and Rolling Features

Fitur temporal digunakan untuk menangkap pola pergerakan harga saham dari waktu ke waktu. Terdapat dua ukuran utama yang dihitung, yaitu Rolling Mean dan Volatility.


Rolling Mean (Moving Average)

\[\text{RollingMean}_t = \frac{1}{n} \sum_{i=0}^{n-1} P_{t-i}\]

Keterangan:

  • \(P_t\) = harga saham pada waktu \(t\) (hari ini)
  • \(P_{t-1}\) = harga saham satu hari sebelumnya
  • \(n\) = jumlah hari dalam jendela waktu (window)

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.


Volatility (Rolling Standard Deviation)

\[\text{Volatility}_t = \sqrt{\frac{1}{n} \sum_{i=0}^{n-1} (R_{t-i} - \bar{R})^2}\]

Keterangan:

  • \(R_{t-i}\) = Daily Return pada periode ke \(t-i\)
  • \(\bar{R}\) = rata-rata Daily Return dalam jendela waktu \(n\)
  • \(n\) = jumlah hari dalam jendela waktu

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
)

Interpretasi & Insight – Bab 3 (Temporal & Rolling Features)

Moving Average (MA): Keempat window MA yang dihitung (5, 20, 50, dan 200 hari) mencerminkan perspektif analisis yang berbeda:

  • MA-5 merespons perubahan harga dengan sangat cepat dan sering digunakan untuk mendeteksi sinyal jangka pendek. Namun, MA-5 rentan terhadap noise pasar harian.
  • MA-20 merepresentasikan pergerakan harga selama satu bulan perdagangan dan merupakan dasar dari Bollinger Bands — indikator volatilitas yang populer.
  • MA-50 digunakan sebagai indikator tren jangka menengah. Crossover harga terhadap MA-50 sering dijadikan sinyal entry/exit oleh trader institusional.
  • MA-200 adalah tolok ukur tren jangka panjang. Harga di atas MA-200 umumnya diinterpretasikan sebagai pasar dalam kondisi bullish secara struktural.

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:

  • Volatility-5 menangkap lonjakan volatilitas jangka sangat pendek, seperti reaksi terhadap pengumuman earnings atau berita makro ekonomi.
  • Volatility-20 adalah ukuran standar yang paling umum digunakan dalam manajemen risiko dan pricing opsi (mirip dengan konsep realized volatility dalam keuangan kuantitatif).
  • Volatility-50 memberikan gambaran volatilitas jangka menengah yang lebih smooth dan cocok untuk evaluasi risiko portofolio.

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.


BAB 4 – Technical Indicators

Persiapan Data

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 – Relative Strength Index

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}}\]

  • RSI > 70Overbought
  • RSI 30–70Neutral
  • RSI < 30Oversold
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")
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")
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 – Moving Average Convergence Divergence

MACD adalah trend-following indicator dengan tiga komponen:

  • MACD Line = EMA(12) − EMA(26)
  • Signal Line = EMA(9) dari MACD Line
  • Histogram = MACD Line − Signal Line (positif = Buy Signal)
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")
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")
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

Bollinger Bands mengukur deviasi harga dari moving average dan menggambarkan volatilitas relatif pasar.

  • Middle Band = SMA(20)
  • Upper Band = SMA(20) + 2σ
  • Lower Band = SMA(20) − 2σ
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")
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")
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 Indikator Teknikal Lengkap

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
)

Kategorisasi Return – Gain / Neutral / Loss

\[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")
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.


Binning Volatilitas – Low / Medium / High Risk

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)")
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 Price Crossing Key Moving Averages

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)")
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")
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.


Dataset Final Bab 3

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
)

Ringkasan Insight

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")
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

Bab 5 - Categorization and Binning

Kategorisasi Return – Gain / Neutral / Loss

\[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")
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.


Binning Volatilitas – Low / Medium / High Risk

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)")
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 Price Crossing Key Moving Averages

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)")
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")
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.


Dataset Final Gabungan

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
)

Ringkasan Insight Menyeluruh

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")
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

BAB 6 – Detect and Handle Outliers

Persiapan Data

# 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

Deteksi Outlier – Metode Z-Score

\[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"))
Jumlah Outlier per Kolom – Metode Z-Score (|Z| > 3)
Kolom Jumlah Outlier
Stock_Price 0
Market_Cap 1
Volume_Traded 0
PE_Ratio 0
Dividend_Yield 0
Return_on_Equity 1

Deteksi Outlier – Metode IQR

\[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")
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"))
Jumlah Outlier per Kolom – Metode IQR
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

Tandai & Pertahankan Outlier

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")
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
)

Interpretasi & Insight – Bab 6 (Detect and Handle Outliers)

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:

  • Outlier dalam data saham seringkali merupakan kejadian nyata yang valid — bukan kesalahan pengukuran. Menghapusnya akan menghilangkan informasi penting tentang kondisi pasar ekstrem.
  • Dengan adanya flag, model prediktif atau analisis hilir dapat memilih untuk mengecualikan atau memberikan bobot berbeda pada data outlier sesuai kebutuhan.
  • Outlier yang dipertahankan memungkinkan analisis stress testing dan evaluasi ketahanan portofolio terhadap skenario ekstrem.

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.


Label Encoding – Kolom Performance

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")
Mapping Label Encoding – Kolom Performance
Performance (Asli) Label Encoded
Negative 0
Stable 1
Positive 2

One-Hot Encoding – Kolom Sector

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")
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 & Insight – Encoding Variabel Kategorikal (Bab 6)

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-Score Normalization

\[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"))
Verifikasi Z-Score Normalization (Mean ≈ 0, SD ≈ 1)
Kolom Mean SD
Stock_Price 0 1
Volume_Traded 0 1
Return_on_Equity 0 1
Dividend_Yield 0 1

Min-Max Scaling

\[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"))
Verifikasi Min-Max Scaling (Min = 0, Max = 1)
Kolom Min Max
Market_Cap 0 1
PE_Ratio 0 1
Stock_Price 0 1

Perbandingan Sebelum & Sesudah Scaling

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"))
Perbandingan Statistik Stock_Price Sebelum & Sesudah Scaling
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

Interpretasi & Insight – Normalization & Scaling (Bab 6)

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.


Tabel Dataset Final

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
)

Bab 7 - Encode Categorical Variabels

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")
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.


One-Hot Encoding

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")
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.


Bab 8 - Normalize or Scale Features

Z-Score Normalization

\[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"))
Verifikasi Z-Score Normalization (Mean ≈ 0, SD ≈ 1)
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.


Min-Max Scaling

\[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"))
Verifikasi Min-Max Scaling (Min = 0, Max = 1)
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.


Perbandingan Sebelum & Sesudah Scaling

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"))
Perbandingan Statistik Stock_Price Sebelum & Sesudah Scaling
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

Interpretasi Perbandingan Scaling – Bab 8

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.


Ringkasan Akhir – Bab 8

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.