This commit is contained in:
Azmikun1 2025-12-17 15:27:30 +07:00
parent 61e81938e6
commit 15eda61833
1 changed files with 177 additions and 377 deletions

506
app.py
View File

@ -1,4 +1,3 @@
import requests
import streamlit as st import streamlit as st
import joblib import joblib
import pandas as pd import pandas as pd
@ -12,461 +11,262 @@ import matplotlib.pyplot as plt
import matplotlib.dates as mdates import matplotlib.dates as mdates
from matplotlib import rcParams from matplotlib import rcParams
# Set font untuk mendukung karakter Indonesia # Set font
rcParams['font.family'] = 'DejaVu Sans' rcParams['font.family'] = 'DejaVu Sans'
# --- Konfigurasi Halaman --- # --- Konfigurasi Halaman ---
st.set_page_config( st.set_page_config(page_title="Prediksi Harga Ethereum (GRU)", page_icon="🪙")
page_title="Prediksi Harga Ethereum (GRU)",
page_icon="🪙"
)
# --- GLOBAL STYLES (UI only) --- # --- GLOBAL STYLES ---
st.markdown(""" st.markdown("""
<style> <style>
/* font & warna dasar */
html, body, [class*="css"] { font-family: "Inter", "DejaVu Sans", sans-serif; }
:root {
--card-bg: #ffffff;
--muted: #6b7280;
--ring: #e5e7eb;
}
/* gradient title */
.app-title { .app-title {
text-align:center; text-align:center; font-weight:800; font-size: 32px;
font-weight:800;
font-size: 32px;
background: linear-gradient(90deg, #6EE7F9 0%, #7C3AED 50%, #F59E0B 100%); background: linear-gradient(90deg, #6EE7F9 0%, #7C3AED 50%, #F59E0B 100%);
-webkit-background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
-webkit-text-fill-color: transparent;
margin: 0.25rem 0 0.5rem 0; margin: 0.25rem 0 0.5rem 0;
} }
.app-subtitle { text-align:center; color: #6b7280; margin-bottom: 1.25rem; }
/* subtitle */
.app-subtitle {
text-align:center;
color: var(--muted);
margin-bottom: 1.25rem;
}
/* card container */
.card { .card {
background: var(--card-bg); background: #ffffff; border: 1px solid #e5e7eb; border-radius: 16px;
border: 1px solid var(--ring); padding: 1rem 1.25rem; box-shadow: 0 6px 20px rgba(0,0,0,0.05); margin-bottom: 1rem;
border-radius: 16px;
padding: 1rem 1.25rem;
box-shadow: 0 6px 20px rgba(0,0,0,0.05);
margin-bottom: 1rem;
}
/* section heading */
.h-section {
font-weight:700;
font-size: 20px;
margin: 0 0 .5rem 0;
}
/* table tweaks */
.dataframe tbody tr:hover { background-color: #fafafa; }
/* footer */
.footer {
text-align:center;
color: var(--muted);
font-size: 13px;
margin-top: 2rem;
} }
.h-section { font-weight:700; font-size: 20px; margin: 0 0 .5rem 0; }
.footer { text-align:center; color: #6b7280; font-size: 13px; margin-top: 2rem; }
</style> </style>
""", unsafe_allow_html=True) """, unsafe_allow_html=True)
# --- TOMBOL RESET CACHE (PENTING!) ---
with st.sidebar:
st.header("⚙️ Kontrol Data")
if st.button("🔄 Paksa Update Data (Clear Cache)"):
st.cache_data.clear()
st.success("Cache dihapus! Silakan tekan 'R' untuk reload.")
st.stop() # Hentikan app sebentar biar user reload
# --- Fungsi-fungsi Bantuan --- # --- FUNGSI LOAD DATA (VERSI YFINANCE ONLY) ---
@st.cache_data(ttl="1h") @st.cache_data(ttl="1h")
def load_eth_data(): def load_eth_data():
""" """
Metode Hybrid (Tumpuk Data) dengan Debug Info. Fokus: Download via Library yfinance.
Fitur:
1. Mengatasi data bolong (Resample Daily).
2. Auto Adjust harga (OHLC bersih).
""" """
ticker = "ETH-USD" ticker = "ETH-USD"
df_final = None
# --- BAGIAN 1: BACA DATA LAMA (BASE) --- # Kita set start date agak jauh biar grafiknya bagus
start_date = "2024-01-01"
end_date = date.today() + timedelta(days=1)
st.toast("Sedang menghubungi server Yahoo Finance...", icon="")
try: try:
df_base = pd.read_csv("eth_backup.csv") # DOWNLOAD ONLINE
df = yf.download(
# Bersihkan kolom sampah
if "Unnamed: 0" in df_base.columns:
df_base = df_base.drop(columns=["Unnamed: 0"])
# Standarisasi kolom Date
if "Date" not in df_base.columns:
df_base = df_base.rename(columns={df_base.columns[0]: "Date"})
df_base["Date"] = pd.to_datetime(df_base["Date"]).dt.tz_localize(None)
except FileNotFoundError:
df_base = pd.DataFrame(columns=["Date", "Open", "High", "Low", "Close", "Volume"])
# --- BAGIAN 2: HITUNG TANGGAL DOWNLOAD ---
today = date.today()
if not df_base.empty:
last_date_csv = df_base["Date"].max()
start_download = last_date_csv + timedelta(days=1)
else:
start_download = pd.to_datetime("2020-01-01")
last_date_csv = "Kosong"
# DEBUG INFO DI SIDEBAR (Supaya kelihatan prosesnya)
with st.sidebar:
st.write(f"📂 **Status Data:**")
st.write(f"- Terakhir di CSV: `{str(last_date_csv).split()[0]}`")
st.write(f"- Target Download: `{str(start_download.date())}` s.d `{today}`")
# --- BAGIAN 3: DOWNLOAD & GABUNG ---
# Hanya download jika tanggal download <= hari ini
if start_download.date() <= today:
try:
df_new = yf.download(
ticker, ticker,
start=start_download, start=start_date,
end=today + timedelta(days=1), end=end_date,
progress=False, progress=False,
auto_adjust=True, auto_adjust=True, # Biar harga bersih
multi_level_index=False multi_level_index=False
) )
if df_new is not None and not df_new.empty: if df is not None and not df.empty:
df_new = df_new.reset_index() # 1. Bersihkan Index
df = df.reset_index()
# Rapikan kolom # 2. Rapikan Kolom (Cegah MultiIndex/Tuple)
new_cols = [] new_cols = []
for col in df_new.columns: for col in df.columns:
col_name = col[0] if isinstance(col, tuple) else str(col) col_name = col[0] if isinstance(col, tuple) else str(col)
new_cols.append(col_name) new_cols.append(col_name)
df_new.columns = new_cols df.columns = new_cols
if 'Date' not in df_new.columns: # 3. Pastikan kolom Date ada
df_new = df_new.rename(columns={df_new.columns[0]: 'Date'}) if 'Date' not in df.columns:
df = df.rename(columns={df.columns[0]: 'Date'})
df_new['Date'] = pd.to_datetime(df_new['Date']).dt.tz_localize(None) # 4. Hapus Timezone
df['Date'] = pd.to_datetime(df['Date']).dt.tz_localize(None)
# INFO DEBUG # --- BAGIAN PENTING: TAMBAL DATA BOLONG (RESAMPLING) ---
st.sidebar.success(f"✅ Berhasil download {len(df_new)} hari baru!") # Ini mengatasi masalah "Loncat" dari tgl 15 ke 17.
# Kita paksa buat tanggal harian (Daily) lengkap.
df = df.sort_values('Date').set_index('Date')
# GABUNGKAN # Buat range tanggal penuh dari awal sampai akhir data
df_final = pd.concat([df_base, df_new], ignore_index=True) full_idx = pd.date_range(start=df.index.min(), end=df.index.max(), freq='D')
else: # Reindex & Forward Fill (Isi kekosongan dengan data hari sebelumnya)
st.sidebar.warning("⚠️ Download dijalankan tapi data kosong (Mungkin libur/belum close).") df = df.reindex(full_idx).ffill().reset_index()
df_final = df_base df = df.rename(columns={'index': 'Date'})
# -------------------------------------------------------
# 5. Simpan Backup Otomatis
try:
df.to_csv("eth_backup.csv", index=False)
except:
pass
return df, "online"
except Exception as e: except Exception as e:
st.sidebar.error(f"❌ Gagal update online: {e}") print(f"Error yfinance: {e}")
df_final = df_base
else:
st.sidebar.info("✅ Data CSV sudah paling update.")
df_final = df_base
# --- FINALISASI --- # FALLBACK: JIKA DOWNLOAD GAGAL, BACA BACKUP LAMA
if df_final is not None and not df_final.empty: try:
df_final = df_final.drop_duplicates(subset="Date", keep="last") df_backup = pd.read_csv("eth_backup.csv")
df_final = df_final.sort_values("Date").reset_index(drop=True) if "Unnamed: 0" in df_backup.columns: df_backup = df_backup.drop(columns=["Unnamed: 0"])
if "Date" not in df_backup.columns: df_backup = df_backup.rename(columns={df_backup.columns[0]: "Date"})
target_cols = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume'] df_backup["Date"] = pd.to_datetime(df_backup["Date"]).dt.tz_localize(None)
available = [c for c in target_cols if c in df_final.columns]
df_final = df_final[available]
return df_final, "mixed"
return df_backup, "backup"
except:
return None, "error" return None, "error"
# --- Fungsi Helper Lainnya (Model & Scaler) ---
def validate_scaler(scaler): def validate_scaler(scaler):
"""Validasi scaler agar konsisten dengan training.""" issues, warnings = [], []
issues = [] if not isinstance(scaler, MinMaxScaler): warnings.append(f"Scaler bukan MinMaxScaler.")
warnings = [] return (len(issues)==0), issues, warnings
if not isinstance(scaler, MinMaxScaler):
warnings.append(f"Scaler bukan MinMaxScaler (terdeteksi: {type(scaler).__name__}). Pastikan ini scaler training.")
required_attrs = ["n_features_in_", "data_min_", "data_max_", "scale_", "min_"]
for a in required_attrs:
if not hasattr(scaler, a):
issues.append(f"Scaler belum ter-fit atau tidak valid (atribut '{a}' tidak ada).")
if hasattr(scaler, "n_features_in_"):
if int(scaler.n_features_in_) != 1:
issues.append(f"Scaler mengharapkan {scaler.n_features_in_} fitur, tapi aplikasi hanya pakai 1 fitur ('Close').")
ok = (len(issues) == 0)
return ok, issues, warnings
def validate_model_input(model): def validate_model_input(model):
"""Pastikan input model bentuknya (None, time_step, 1)."""
issues = [] issues = []
try: try:
shape = model.input_shape # biasanya (None, time_step, 1) shape = model.input_shape
if not (isinstance(shape, (list, tuple)) and len(shape) == 3): if shape[2] != 1: issues.append(f"Fitur model = {shape[2]}, input = 1.")
issues.append(f"input_shape tidak sesuai harapan: {shape} (harus 3 dimensi).") except: pass
else: return (len(issues)==0), issues
if shape[2] != 1:
issues.append(f"Jumlah fitur input model = {shape[2]}, tapi aplikasi membentuk fitur=1.")
if shape[1] is None or int(shape[1]) <= 1:
issues.append(f"time_step tidak valid: {shape[1]}.")
except Exception as e:
issues.append(f"Gagal membaca input_shape model: {e}")
ok = (len(issues) == 0)
return ok, issues
@st.cache_resource @st.cache_resource
def load_gru_assets(): def load_gru_assets():
"""Load model & scaler hasil training (wajib pakai scaler yang sama)."""
model_path = Path("gru_model.h5") model_path = Path("gru_model.h5")
scaler_path = Path("scaler_gru.pkl") scaler_path = Path("scaler_gru.pkl")
if not model_path.exists() or not scaler_path.exists(): return None, None
if not model_path.exists() or not scaler_path.exists():
st.warning(
f"File tidak ditemukan.\n"
f"Model: {model_path.resolve().name} ada? {model_path.exists()}\n"
f"Scaler: {scaler_path.resolve().name} ada? {scaler_path.exists()}"
)
return None, None
try: try:
model = load_model(model_path, compile=False) model = load_model(model_path, compile=False)
scaler = joblib.load(scaler_path) scaler = joblib.load(scaler_path)
ok_scaler, scaler_issues, scaler_warnings = validate_scaler(scaler)
for w in scaler_warnings:
st.warning("⚠️ " + w)
if not ok_scaler:
st.error("❌ Scaler tidak kompatibel:\n- " + "\n- ".join(scaler_issues))
return None, None
ok_model, model_issues = validate_model_input(model)
if not ok_model:
st.error("❌ Model input tidak kompatibel:\n- " + "\n- ".join(model_issues))
return None, None
return model, scaler return model, scaler
except: return None, None
except Exception as e:
st.error(f"Gagal load aset: {e}")
return None, None
def predict_from_sequence_pure(model, initial_sequence_scaled, n_days): def predict_from_sequence_pure(model, initial_sequence_scaled, n_days):
seq = np.array(initial_sequence_scaled, dtype="float32").reshape(-1)
seq = np.array(initial_sequence_scaled, dtype="float32").reshape(-1) # (time_step,)
preds = [] preds = []
for _ in range(n_days): for _ in range(n_days):
x = seq.reshape(1, -1, 1) # (1, time_step, 1) x = seq.reshape(1, -1, 1)
y = float(model.predict(x, verbose=0)[0, 0]) # output model (scaled) y = float(model.predict(x, verbose=0)[0, 0])
preds.append(y) preds.append(y)
seq = np.append(seq[1:], y) # autoregressive (murni) seq = np.append(seq[1:], y)
return np.array(preds, dtype="float32").reshape(-1, 1) return np.array(preds, dtype="float32").reshape(-1, 1)
def create_combined_chart(df, start_date, future_dates, future_predictions): def create_combined_chart(df, start_date, future_dates, future_predictions):
"""Membuat grafik gabungan dengan gaya yang sama seperti referensi.""" context_start = start_date - timedelta(days=60)
context_start_date = start_date - timedelta(days=60) recent_df = df[(df["Date"] >= context_start) & (df["Date"] <= start_date)].copy()
recent_df = df[(df["Date"] >= context_start_date) & (df["Date"] <= start_date)].copy() recent_df["Trend"] = recent_df["Close"].rolling(window=7).mean()
window_size = 7
recent_df["Trend_Aktual"] = recent_df["Close"].rolling(window=window_size).mean()
predictions_flat = future_predictions.flatten()
fig, ax = plt.subplots(figsize=(14, 8)) fig, ax = plt.subplots(figsize=(14, 8))
ax.plot(recent_df["Date"], recent_df["Close"], color="gray", alpha=0.5, label="Harga Aktual")
ax.plot(recent_df["Date"], recent_df["Trend"], color="#1f77b4", linewidth=2, label="Tren Aktual")
ax.plot(future_dates, future_predictions.flatten(), color="#d62728", linewidth=2, marker="o", markersize=4, label="Prediksi GRU")
ax.plot( if not recent_df.empty:
recent_df["Date"], recent_df["Close"], ax.plot([recent_df["Date"].iloc[-1], future_dates[0]],
color="gray", linewidth=1.0, label="Harga Aktual (Harian)", alpha=0.5 [recent_df["Trend"].dropna().iloc[-1], future_predictions.flatten()[0]],
) color="#d62728", linestyle="--", alpha=0.7)
ax.plot(
recent_df["Date"], recent_df["Trend_Aktual"],
color="#1f77b4", linewidth=2.5, label="Tren Harga Aktual", alpha=0.9
)
ax.plot(
future_dates, predictions_flat,
color="#d62728", linewidth=2.5, label="Harga Prediksi (GRU)",
alpha=0.9, marker="o", markersize=4
)
ax.scatter(
recent_df["Date"], recent_df["Close"],
color="black", s=15, alpha=0.6, zorder=5, label="Data Harian Aktual"
)
# garis penghubung trend terakhir ke prediksi pertama
try:
last_trend_date = recent_df["Date"].iloc[-1]
last_trend_price = recent_df["Trend_Aktual"].dropna().iloc[-1]
first_prediction_date = future_dates[0]
first_prediction_price = predictions_flat[0]
ax.plot(
[last_trend_date, first_prediction_date],
[last_trend_price, first_prediction_price],
color="#d62728", linestyle="--", linewidth=2.0, alpha=0.7
)
except Exception:
pass
ax.set_xlabel("Waktu", fontsize=12, fontweight="bold")
ax.set_ylabel("Harga (USD)", fontsize=12, fontweight="bold")
ax.set_title("Analisis Tren Historis dan Prediksi Harga Ethereum", fontsize=16, fontweight="bold", pad=20)
ax.set_title("Analisis Tren & Prediksi Ethereum")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d, %Y")) ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d, %Y"))
plt.xticks(rotation=45) plt.xticks(rotation=45)
ax.grid(True, alpha=0.3, linestyle="--") ax.legend()
ax.grid(True, alpha=0.3)
handles, labels = ax.get_legend_handles_labels()
order = [2, 0, 1, 3]
if len(handles) == 4:
ax.legend([handles[idx] for idx in order], [labels[idx] for idx in order], loc="upper left", fontsize=11)
else:
ax.legend(loc="upper left", fontsize=11)
plt.tight_layout()
ax.set_facecolor("#f8f9fa")
fig.patch.set_facecolor("white")
return fig return fig
# --- Tampilan Antarmuka Aplikasi --- # --- MAIN UI ---
st.markdown("<div class='app-title'>Prediksi Harga Ethereum (GRU)</div>", unsafe_allow_html=True) st.markdown("<div class='app-title'>Prediksi Harga Ethereum (GRU)</div>", unsafe_allow_html=True)
st.markdown("<div class='app-subtitle'>Data historis & prediksi ETH-USD berbasis GRU</div>", unsafe_allow_html=True) st.markdown("<div class='app-subtitle'>Data historis & prediksi ETH-USD berbasis GRU</div>", unsafe_allow_html=True)
df, data_source = load_eth_data() # LOAD DATA
df, status = load_eth_data()
# Notifikasi Status
if status == "backup":
st.toast("Koneksi Yahoo Gagal. Pakai Backup.", icon="⚠️")
elif status == "error":
st.error("❌ Gagal memuat data (Library gagal & Backup tidak ada).")
st.stop()
# LOAD MODEL
model, scaler = load_gru_assets() model, scaler = load_gru_assets()
# LOGIC UI (TOAST/WARNING) DITARUH DI SINI (DILUAR CACHE)
if data_source == "backup":
st.toast("Koneksi Yahoo lambat. Menggunakan data backup lokal.", icon="⚠️")
elif data_source == "error":
st.error("❌ Gagal memuat data (Online gagal & Backup tidak ada).")
if df is not None and model is not None and scaler is not None: if df is not None and model is not None and scaler is not None:
# Menampilkan tabel data historis
# INFO UPDATE DATA
last_date = df["Date"].max()
st.info(f"📅 Data Terupdate sampai: **{last_date.strftime('%d %B %Y')}**")
# 1. Tampilkan Data
st.markdown("<div class='card'>", unsafe_allow_html=True) st.markdown("<div class='card'>", unsafe_allow_html=True)
st.markdown("<div class='h-section'>📚 Data Historis Harga Ethereum</div>", unsafe_allow_html=True) st.markdown("<div class='h-section'>📚 Data Historis Harga Ethereum</div>", unsafe_allow_html=True)
st.dataframe(df, height=300, use_container_width=True) st.dataframe(df.tail(10).sort_values("Date", ascending=False), height=300, use_container_width=True) # Tampilkan 10 data terakhir biar kelihatan update
st.markdown("</div>", unsafe_allow_html=True) st.markdown("</div>", unsafe_allow_html=True)
# Menampilkan visualisasi line chart # 2. Chart Historis
st.markdown("<div class='card'>", unsafe_allow_html=True) st.markdown("<div class='card'>", unsafe_allow_html=True)
st.markdown("<div class='h-section'>📈 Visualisasi Harga Historis</div>", unsafe_allow_html=True) st.markdown("<div class='h-section'>📈 Visualisasi Harga Historis</div>", unsafe_allow_html=True)
st.line_chart(df.rename(columns={"Date": "index"}).set_index("index")["Close"]) st.line_chart(df.set_index("Date")["Close"])
st.markdown("</div>", unsafe_allow_html=True) st.markdown("</div>", unsafe_allow_html=True)
# 3. Prediksi
st.markdown("<div class='card'>", unsafe_allow_html=True) st.markdown("<div class='card'>", unsafe_allow_html=True)
st.markdown("<div class='h-section'>🎯 Mulai Prediksi Berdasarkan Tanggal</div>", unsafe_allow_html=True) st.markdown("<div class='h-section'>🎯 Mulai Prediksi</div>", unsafe_allow_html=True)
time_step = int(model.input_shape[1]) time_step = int(model.input_shape[1])
min_selectable_date = (df["Date"].min() + timedelta(days=time_step)).date() min_date = (df["Date"].min() + timedelta(days=time_step)).date()
max_selectable_date = df["Date"].max().date() max_date = df["Date"].max().date()
col1, col2 = st.columns(2) c1, c2 = st.columns(2)
with col1: with c1:
selected_date = st.date_input( # Default value ke hari ini (max_date)
"Pilih tanggal mulai prediksi:", start_date = st.date_input("Mulai tanggal:", value=max_date, min_value=min_date, max_value=max_date)
value=max_selectable_date, with c2:
min_value=min_selectable_date, days = st.slider("Jumlah hari:", 1, 30, 15)
max_value=max_selectable_date,
help=f"Pilih tanggal antara {min_selectable_date} dan {max_selectable_date}"
)
with col2:
prediction_days = st.slider(
"Pilih jumlah hari untuk prediksi (1-30 hari):",
min_value=1, max_value=30, value=15, step=1
)
if st.button("Buat Prediksi", key="predict_button"): if st.button("Buat Prediksi"):
if prediction_days < 1: with st.spinner("Memproses..."):
st.warning("Jumlah hari prediksi minimal **1**. Silakan geser slidernya dulu. 🙂") vals = df[["Close"]].values.astype(float)
st.stop() scaled = scaler.transform(vals)
with st.spinner("Memproses dan menjalankan prediksi..."): check_date = pd.to_datetime(start_date)
# scaling (mengikuti scaler training) # Cari index tanggal
close_values = df[["Close"]].values.astype(float) idx = df[df["Date"].dt.date == check_date.date()].index
scaled_data = scaler.transform(close_values)
# Cocokkan berdasarkan tanggal (robust) if len(idx) > 0:
selected_date_dt = pd.to_datetime(selected_date) idx = idx[0]
mask = df["Date"].dt.date == selected_date_dt.date() if idx < time_step:
idxs = np.where(mask.to_numpy())[0] st.error(f"Data tidak cukup (butuh {time_step} hari sebelumnya).")
else:
seq = scaled[idx-time_step : idx]
pred_scaled = predict_from_sequence_pure(model, seq, days)
pred_real = scaler.inverse_transform(pred_scaled)
if len(idxs) == 0: f_dates = [check_date + timedelta(days=i) for i in range(1, days+1)]
st.error(f"Tanggal {selected_date_dt.strftime('%Y-%m-%d')} tidak ditemukan dalam dataset.")
st.stop()
start_index = int(idxs[0])
if start_index - time_step < 0:
st.error(
f"Tanggal terlalu awal untuk diprediksi. "
f"Butuh {time_step} hari data sebelumnya. Pilih tanggal setelah "
f"{(df['Date'].min() + timedelta(days=time_step)).strftime('%Y-%m-%d')}."
)
st.stop()
initial_sequence = scaled_data[start_index - time_step: start_index] # (time_step,1)
# ✅ Prediksi MURNI dari model
future_predictions_scaled = predict_from_sequence_pure(model, initial_sequence, prediction_days)
# ✅ Inverse transform MURNI dari scaler
future_predictions = scaler.inverse_transform(future_predictions_scaled)
# sanity-check
if not np.isfinite(future_predictions).all():
st.error("❌ Hasil prediksi mengandung NaN/Inf. Periksa kompatibilitas model/scaler.")
st.stop()
future_dates = [selected_date_dt + timedelta(days=i) for i in range(1, prediction_days + 1)]
st.markdown("<div class='card'>", unsafe_allow_html=True) st.markdown("<div class='card'>", unsafe_allow_html=True)
st.subheader("📊 Grafik Prediksi (Historis + Prediksi)") fig = create_combined_chart(df, check_date, f_dates, pred_real)
fig = create_combined_chart(df, selected_date_dt, future_dates, future_predictions)
st.pyplot(fig) st.pyplot(fig)
res_df = pd.DataFrame({"Tanggal": [d.strftime("%Y-%m-%d") for d in f_dates], "Harga (USD)": pred_real.flatten()})
st.dataframe(res_df, use_container_width=True)
st.markdown("</div>", unsafe_allow_html=True) st.markdown("</div>", unsafe_allow_html=True)
else:
st.markdown("<div class='card'>", unsafe_allow_html=True) st.error("Tanggal tidak ditemukan dalam dataset.")
st.subheader("🧾 Tabel Detail Prediksi")
prediction_table_df = pd.DataFrame({
"Tanggal": [d.strftime("%Y-%m-%d") for d in future_dates],
"Harga Prediksi (USD)": [f"USD {price[0]:,.0f}" for price in future_predictions]
})
prediction_table_df.index = prediction_table_df.index + 1
st.dataframe(prediction_table_df, use_container_width=True)
st.markdown("</div>", unsafe_allow_html=True)
last_price = float(df.loc[start_index, "Close"])
max_prediction = float(np.max(future_predictions))
min_prediction = float(np.min(future_predictions))
st.info(f"""
📊 **Informasi Prediksi:**
- Harga terakhir pada tanggal {selected_date.strftime('%Y-%m-%d')}: USD {last_price:,.0f}
- Harga prediksi tertinggi: USD {max_prediction:,.0f}
- Harga prediksi terendah: USD {min_prediction:,.0f}
""")
st.markdown("</div>", unsafe_allow_html=True) st.markdown("</div>", unsafe_allow_html=True)
st.markdown("<div class='footer'>Dibuat oleh Muhammad Gilman Nadhif Azmi</div>", unsafe_allow_html=True) st.markdown("<div class='footer'>Dibuat oleh Muhammad Gilman Nadhif Azmi</div>", unsafe_allow_html=True)
elif model is None: elif model is None:
st.error("Gagal memuat model. Pastikan file 'gru_model.h5' ada di folder yang sama dengan aplikasi.") st.error("Gagal memuat Model/Scaler. Pastikan file .h5 dan .pkl ada.")
elif scaler is None:
st.error("Gagal memuat scaler. Pastikan file 'scaler_gru.pkl' ada di folder yang sama dengan aplikasi.")
else:
st.error("Gagal memuat data. Coba cek koneksi internet atau sumber data yfinance.")