import streamlit as st import pandas as pd import numpy as np import torch import plotly.graph_objects as go import joblib from transformers import ( TimesFmModelForPrediction, PatchTSTConfig, PatchTSTForPrediction ) from nixtla import NixtlaClient from sklearn.metrics import mean_absolute_error, root_mean_squared_error from datetime import datetime from statsmodels.tsa.statespace.sarimax import SARIMAX from vnstock import Vnstock import time from datetime import timedelta import warnings warnings.filterwarnings("ignore", message="To copy construct from a tensor") st.markdown(""" """, unsafe_allow_html=True) # 1. Page Config st.set_page_config( page_title="VN30 Forecast - Model Comparison", layout="wide" ) # Sitebar navigation st.sidebar.title("📂 VN30 Forecast") st.sidebar.markdown("---") menu = st.sidebar.radio( "Menu", [ "📊 So sánh model", "🧪 Demo sản phẩm", "📘 Tài liệu" ] ) if menu == "📊 So sánh model": # 2. Load data @st.cache_data def load_data(): df = pd.read_csv("VN30_Train.csv") df["time"] = pd.to_datetime(df["time"]) df = df.sort_values(["symbol", "time"]) return df df_train = load_data() symbols = sorted(df_train["symbol"].unique()) def load_test_data(): df = pd.read_csv("VN30_Test.csv") df["time"] = pd.to_datetime(df["time"]) df = df.sort_values(["symbol", "time"]) return df df_test = load_test_data() @st.cache_data def load_news(): df = pd.read_csv("VN30_news_with_sentiment.csv") df["date"] = pd.to_datetime(df["date"]) return df df_news = load_news() # 3. UI st.markdown('

📈 VN30 Stock Forecast - Model Comparison

', unsafe_allow_html=True) a1, a2, a3 = st.columns([1, 2, 1]) with a2: st.image( "VN30_Model_Thumb.png", width=1000 ) st.markdown("---") with st.expander("🎯 **Mục tiêu dự án** (bấm để xem chi tiết)"): st.markdown( """

🎯 Mục tiêu dự án

Dự án này nhằm xây dựng một ứng dụng dự báo giá cổ phiếu VN30 theo ngày bằng nhiều phương pháp forecasting khác nhau, bao gồm:

""", unsafe_allow_html=True ) # Xuất ra ngày hiện tại today_str = datetime.now().strftime("%d/%m/%Y") st.markdown( f"""

📅 Ngày hiện tại: {today_str}

""", unsafe_allow_html=True ) # 4. Crawl data def crawl_vn30_data(start_date, end_date, filename): symbols = [ "ACB","DGC","BCM","BID","FPT","HDB","HPG","LPB","MSN","MBB", "MWG","PLX","GAS","SAB","STB","SHB","SSB","SSI","TCB","TPB", "VCB","CTG","VJC","VIB","GVR","VNM","VRE","VIC","VHM","VPB" ] all_dfs = [] progress = st.progress(0) status = st.empty() for i, symbol in enumerate(symbols): try: stock = Vnstock().stock(symbol=symbol, source="VCI") df = stock.quote.history( start=start_date, end=end_date, interval="1D" ) if df.empty: status.warning(f"{symbol} không có dữ liệu") continue # Feature engineering (GIỮ NGUYÊN) df["estimated_value"] = ( (df["open"] + df["high"] + df["low"] + df["close"]) / 4 ) * df["volume"] df["+/- price percent"] = df["close"].pct_change().mul(100).round(2) df["symbol"] = symbol all_dfs.append(df) status.info(f"Đã crawl xong {symbol}") except Exception as e: status.error(f"Lỗi {symbol}: {e}") progress.progress((i + 1) / len(symbols)) time.sleep(10) if len(all_dfs) == 0: st.error("Không crawl được dữ liệu nào") return final_df = pd.concat(all_dfs, ignore_index=True) final_df.to_csv(filename, index=False) st.success(f"Đã lưu {filename}") st.markdown("## 📥 Crawl dữ liệu VN30 thành 2 tập train và test") c1, c2 = st.columns(2) with c1: if st.button("⬇️ Crawl Train Data (Từ 2022 đến 2 tuần trước hiện tại)"): with st.spinner("Đang crawl Train data..."): end_date = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d") crawl_vn30_data( start_date="2022-01-01", end_date=end_date, filename="VN30_Train.csv" ) st.cache_data.clear() st.rerun() with c2: if st.button("⬇️ Crawl Test Data (Từ 2 tuần trước đến hiện tại)"): with st.spinner("Đang crawl Test data..."): end_date = datetime.now().strftime("%Y-%m-%d") crawl_vn30_data( start_date="2022-01-01", end_date=end_date, filename="VN30_Test.csv" ) st.cache_data.clear() st.rerun() # 5. Làm button để cho người dùng chọn cổ phiếu và model st.markdown("## 📌 Chọn cổ phiếu và ngày sắp tới để dự đoán") left, right = st.columns([1, 1]) with left: symbol = st.selectbox( "Bạn hãy chọn cổ phiếu", symbols ) with right: horizon = st.number_input( "Bạn muốn forecast trong bao nhiêu ngày sắp tới", min_value=1, max_value=30, value=14, step=1 ) st.markdown("## 🧠 Chọn model để dự đoán") MODEL_OPTIONS = [ "Technical Analysis", "SARIMA", "PatchTST", "TimeFM", "TimeGPT (Recommended)", "TimeGPT with news (Beta)" ] selected_models = [] cols = st.columns(3) for i, m in enumerate(MODEL_OPTIONS): with cols[i % 3]: if st.checkbox(m, value=(m == "TimeGPT (Recommended)")): selected_models.append(m) if len(selected_models) == 0: st.warning("⚠️ Bạn cần chọn ít nhất 1 model") # 6. Load Models @st.cache_resource def load_timefm(): model = TimesFmModelForPrediction.from_pretrained( "google/timesfm-2.0-500m-pytorch", dtype=torch.bfloat16, attn_implementation="sdpa", device_map="auto" ) model.eval() return model @st.cache_resource def load_timegpt(): return NixtlaClient( api_key="nixak-zWQjbVl9QCc6eIFL3DDbaBXi09bnPKsa5jdUU7Q8izPpn3eYl0rZPWLLs8NI597PT0VzIODhPUmKzkMc" ) @st.cache_resource def load_patchtst(): config = PatchTSTConfig( context_length=31, prediction_length=3, input_size=1, patch_len=6, stride=3, d_model=96, num_hidden_layers=4, num_attention_heads=4, dropout=0.15 ) model = PatchTSTForPrediction(config) model.load_state_dict( torch.load("best_patchtst_vn30.pt", map_location="cpu") ) model.eval() scalers = joblib.load("patchtst_scalers_vn30.pkl") return model, scalers # 7. Forecast Function def compute_ci(df_sym, preds): returns = df_sym["close"].diff().dropna() vol = returns.rolling(20, min_periods=5).std().iloc[-1] z = 1.96 return preds - z * vol, preds + z * vol def forecast_timefm(symbol, horizon): model = load_timefm() df_sym = df_train[df_train["symbol"] == symbol] series = df_sym["close"].astype(float).values past_tensor = ( torch.from_numpy(series) .to(dtype=torch.bfloat16, device=model.device) .unsqueeze(0) ) freq_tensor = torch.tensor([0], dtype=torch.long, device=model.device) with torch.no_grad(): outputs = model( past_values=past_tensor, freq=freq_tensor, return_dict=True ) preds = outputs.mean_predictions[0].float().cpu().numpy()[:horizon] future_time = pd.bdate_range( start=df_sym["time"].iloc[-1] + pd.offsets.BDay(), periods=horizon ) lo, hi = compute_ci(df_sym, preds) return pd.DataFrame({ "time": future_time, "forecast_close": preds, "lower_95": lo, "upper_95": hi }) def forecast_timegpt(symbol, horizon): client = load_timegpt() df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.rename(columns={"time": "ds", "close": "y"})[["ds", "y"]] df_sym = df_sym.sort_values("ds").drop_duplicates(subset="ds") # Create full business-day range full_range = pd.date_range( start=df_sym["ds"].min(), end=df_sym["ds"].max(), freq="B" ) df_sym = ( df_sym .set_index("ds") .reindex(full_range) .rename_axis("ds") .reset_index() ) # Fill missing prices df_sym["y"] = df_sym["y"].ffill().bfill() df_ts = df_sym.rename( columns={"time": "ds", "close": "y"} )[["ds", "y"]] df_ts["unique_id"] = symbol df_fc = client.forecast( df=df_ts, h=horizon, freq="B", level=[95] ) return df_fc.rename(columns={ "ds": "time", "TimeGPT": "forecast_close", "TimeGPT-lo-95": "lower_95", "TimeGPT-hi-95": "upper_95" })[["time", "forecast_close", "lower_95", "upper_95"]] def forecast_timegpt_news(symbol, horizon): client = load_timegpt() df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.rename(columns={"time": "ds", "close": "y"})[["ds", "y"]] df_sym = df_sym.sort_values("ds").drop_duplicates(subset="ds") # Create full business-day range full_range = pd.date_range( start=df_sym["ds"].min(), end=df_sym["ds"].max(), freq="B" ) df_sym = ( df_sym .set_index("ds") .reindex(full_range) .rename_axis("ds") .reset_index() ) # Fill missing prices df_sym["y"] = df_sym["y"].ffill().bfill() # News df_news_sym = df_news[df_news["symbol"] == symbol].copy() df_news_sym["date"] = pd.to_datetime(df_news_sym["date"]) df_sent = ( df_news_sym .groupby("date")["sentiment"] .mean() .reset_index() ) df_sym["date"] = df_sym["ds"].dt.normalize() df_ts = df_sym.merge(df_sent, on="date", how="left") # Fill missing sentiment df_ts["sentiment"] = df_ts["sentiment"].fillna(0.0) df_ts["unique_id"] = symbol df_fc = client.forecast( df=df_ts[["unique_id", "ds", "y", "sentiment"]], h=horizon, freq="B", level=[95] ) return df_fc.rename(columns={ "ds": "time", "TimeGPT": "forecast_close", "TimeGPT-lo-95": "lower_95", "TimeGPT-hi-95": "upper_95" })[["time", "forecast_close", "lower_95", "upper_95"]] def forecast_patchtst(symbol, horizon): model, scalers = load_patchtst() df_sym = df_train[df_train["symbol"] == symbol] scaler = scalers[symbol] values = df_sym["close"].values.reshape(-1, 1) scaled = scaler.transform(values).flatten() context = scaled[-31:].copy() preds_scaled = [] with torch.no_grad(): for _ in range(horizon): xb = torch.tensor(context, dtype=torch.float32).unsqueeze(0).unsqueeze(-1) out = model(past_values=xb) next_pred = out.prediction_outputs[0, 0, 0].item() preds_scaled.append(next_pred) context = np.roll(context, -1) context[-1] = next_pred preds = scaler.inverse_transform( np.array(preds_scaled).reshape(-1, 1) ).flatten() future_time = pd.bdate_range( start=df_sym["time"].iloc[-1] + pd.offsets.BDay(), periods=horizon ) lo, hi = compute_ci(df_sym, preds) return pd.DataFrame({ "time": future_time, "forecast_close": preds, "lower_95": lo, "upper_95": hi }) def forecast_sarima(symbol, horizon=7): df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.sort_values("time").drop_duplicates(subset="time") # Endogenous (price) ts = ( df_sym .set_index("time")["close"] .astype(float) .asfreq("B") .ffill() ) # Exogenous variables df_exog = df_sym.set_index("time").asfreq("B").ffill() df_exog["return"] = df_exog["close"].pct_change().shift(1) df_exog["volume"] = np.log1p(df_exog["volume"]) exog = df_exog[["return", "volume"]].fillna(0) # Đồng bộ index exog = exog.loc[ts.index] # Model model = SARIMAX( ts, exog=exog, order=(2, 0, 2), seasonal_order=(1, 0, 1, 5), enforce_stationarity=False, enforce_invertibility=False ) result = model.fit(disp=False, maxiter=50) # Future exog future_time = pd.bdate_range( start=ts.index[-1] + pd.offsets.BDay(), periods=horizon ) mean_ret = df_exog["return"].tail(20).mean() mean_vol = df_exog["volume"].tail(20).mean() future_exog = pd.DataFrame( { "return": np.full(horizon, mean_ret), "volume": np.full(horizon, mean_vol) }, index=future_time ) fc = result.get_forecast( steps=horizon, exog=future_exog ) conf_int = fc.conf_int(alpha=0.05) return pd.DataFrame({ "time": future_time, "forecast_close": fc.predicted_mean.values, "lower_95": conf_int.iloc[:, 0].values, "upper_95": conf_int.iloc[:, 1].values }) def forecast_technical(symbol, horizon=7): df = df_train[df_train["symbol"] == symbol].copy() df = df.sort_values("time").reset_index(drop=True) # Indicators df["MA20"] = df["close"].rolling(20).mean() df["MA50"] = df["close"].rolling(50).mean() delta = df["close"].diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.rolling(14).mean() avg_loss = loss.rolling(14).mean() rs = avg_gain / avg_loss df["RSI"] = 100 - (100 / (1 + rs)) def signal(row): if row["close"] > row["MA20"] > row["MA50"] and row["RSI"] > 50: return 1 elif row["close"] < row["MA20"] < row["MA50"] and row["RSI"] < 50: return -1 else: return 0 df["signal"] = df.apply(signal, axis=1) # Forecast last_price = df["close"].iloc[-1] last_signal = df["signal"].iloc[-1] returns = df["close"].pct_change().rolling(10).std() vol = returns.iloc[-1] vol = 0 if np.isnan(vol) else min(vol, 0.01) future_time = pd.bdate_range( start=df["time"].iloc[-1] + pd.offsets.BDay(), periods=horizon ) prices = [] price = last_price for _ in range(horizon): if last_signal == 1: price *= (1 + vol) elif last_signal == -1: price *= (1 - vol) else: price *= (1 + 0.1 * vol) prices.append(price) preds = np.array(prices) lo, hi = compute_ci(df, preds) return pd.DataFrame({ "time": future_time, "forecast_close": preds, "lower_95": lo, "upper_95": hi }) # 8. Rolling backtest def rolling_backtest(df_sym, forecast_fn, window=500, step=5): errors = [] for start in range(0, len(df_sym) - window - horizon, step): train_slice = df_sym.iloc[start:start + window] test_slice = df_sym.iloc[start + window:start + window + horizon] df_pred = forecast_fn( train_slice["symbol"].iloc[0], horizon ) y_true = test_slice["close"].values[:len(df_pred)] y_pred = df_pred["forecast_close"].values # MAE mae = mean_absolute_error(y_true, y_pred) # RMSE rmse = root_mean_squared_error(y_true, y_pred) # MASE (naive = yesterday) naive = train_slice["close"].diff().dropna() mae_naive = naive.abs().mean() mase = mae / mae_naive if mae_naive != 0 else np.nan # Directional Accuracy actual_dir = np.sign(np.diff(y_true)) pred_dir = np.sign(np.diff(y_pred)) da = (actual_dir == pred_dir).mean() if len(actual_dir) > 0 else np.nan errors.append({ "MAE": mae, "RMSE": rmse, "MASE": mase, "DA": da }) return pd.DataFrame(errors) def backtest_sarima(symbol, n_test=7): df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.sort_values("time").drop_duplicates(subset="time") # Endogenous ts = ( df_sym .set_index("time")["close"] .astype(float) .asfreq("B") .ffill() ) # Exogenous df_exog = df_sym.set_index("time").asfreq("B").ffill() df_exog["return"] = df_exog["close"].pct_change().shift(1) df_exog["volume"] = np.log1p(df_exog["volume"]) exog = df_exog[["return", "volume"]].fillna(0) # Đồng bộ index exog = exog.loc[ts.index] # Train / Test split ts_train = ts.iloc[:-n_test] ts_test = ts.iloc[-n_test:] exog_train = exog.loc[ts_train.index] exog_test = exog.loc[ts_test.index] # Model model = SARIMAX( ts_train, exog=exog_train, order=(2, 0, 2), seasonal_order=(1, 0, 1, 5), enforce_stationarity=False, enforce_invertibility=False ) result = model.fit(disp=False, maxiter=50) fc = result.get_forecast( steps=n_test, exog=exog_test ) y_pred = fc.predicted_mean y_true = ts_test # Metrics mae = mean_absolute_error(y_true, y_pred) rmse = root_mean_squared_error(y_true, y_pred) naive = ts_train.shift(1).dropna() mae_naive = mean_absolute_error( ts_train.loc[naive.index], naive ) mase = mae / mae_naive if mae_naive != 0 else np.nan actual_dir = np.sign(y_true.diff().iloc[1:]) pred_dir = np.sign(y_pred.diff().iloc[1:]) da = (actual_dir == pred_dir).mean() return { "MAE": mae, "RMSE": rmse, "MASE": mase, "DA": da } def backtest_technical(symbol, n_test=7): df = df_train[df_train["symbol"] == symbol].copy() df = df.sort_values("time").reset_index(drop=True) train = df.iloc[:-n_test] test = df.iloc[-n_test:] df_fc = forecast_technical(symbol, horizon=n_test) y_true = test["close"].values y_pred = df_fc["forecast_close"].values # MAE mae = mean_absolute_error(y_true, y_pred) # RMSE rmse = root_mean_squared_error(y_true, y_pred) # MASE naive = train["close"].shift(1).dropna() mae_naive = np.abs(naive.values - train["close"].iloc[1:].values).mean() mase = mae / mae_naive if mae_naive != 0 else np.nan # Directional Accuracy actual_dir = np.sign(np.diff(y_true)) pred_dir = np.sign(np.diff(y_pred)) da = (actual_dir == pred_dir).mean() return { "MAE": mae, "RMSE": rmse, "MASE": mase, "DA": da } # 9. Run Forecast if st.button("🚀 Forecast") and len(selected_models) > 0: progress = st.progress(0) status = st.empty() all_forecasts = {} all_metrics = {} total = len(selected_models) for i, model_name in enumerate(selected_models): status.info(f"⏳ Đang chạy model: **{model_name}**") # Forecast if model_name == "TimeFM": df_fc = forecast_timefm(symbol, horizon) bt_fn = forecast_timefm df_bt = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) elif model_name == "TimeGPT (Recommended)": df_fc = forecast_timegpt(symbol, horizon) bt_fn = forecast_timegpt df_bt = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) elif model_name == "TimeGPT with news (Beta)": df_fc = forecast_timegpt_news(symbol, horizon) bt_fn = forecast_timegpt_news df_bt = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) elif model_name == "SARIMA": df_fc = forecast_sarima(symbol, horizon) df_bt = pd.DataFrame([backtest_sarima(symbol, n_test=horizon)]) elif model_name == "Technical Analysis": df_fc = forecast_technical(symbol, horizon) df_bt = pd.DataFrame([backtest_technical(symbol, n_test=horizon)]) else: # PatchTST df_fc = forecast_patchtst(symbol, horizon) bt_fn = forecast_patchtst df_bt = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) all_forecasts[model_name] = df_fc all_metrics[model_name] = df_bt.mean(numeric_only=True) progress.progress((i + 1) / total) status.success("Forecast hoàn tất!") st.session_state.all_forecasts = all_forecasts st.session_state.all_metrics = pd.DataFrame(all_metrics).T.reset_index().rename( columns={"index": "Model"} ) # 11. Plot if "all_forecasts" in st.session_state: st.subheader(f"📊 Forecast plot {symbol} – {horizon} ngày") model_to_view = st.selectbox( "🎛️ Chọn model để hiển thị", list(st.session_state.all_forecasts.keys()) ) df_fc = st.session_state.all_forecasts[model_to_view] df_hist = df_train[df_train["symbol"] == symbol] df_test_sym = df_test[df_test["symbol"] == symbol] last_train_time = df_hist["time"].iloc[-1] df_actual_future = df_test_sym[ df_test_sym["time"] >= last_train_time ].iloc[:horizon] fig = go.Figure() # Historical fig.add_trace(go.Scatter( x=df_hist["time"], y=df_hist["close"], mode="lines", name="Historical", line=dict(width=2.5) )) # Actual if len(df_actual_future) > 0: fig.add_trace(go.Scatter( x=df_actual_future["time"], y=df_actual_future["close"], mode="lines+markers", name="Actual" )) # CI fig.add_trace(go.Scatter( x=df_fc["time"], y=df_fc["upper_95"], mode="lines", line=dict(width=0), name="95% CI", showlegend=True ) ) fig.add_trace(go.Scatter( x=df_fc["time"], y=df_fc["lower_95"], mode="lines", line=dict(width=0), fill="tonexty", fillcolor="rgba(168, 85, 247, 0.35)", # tím xịn name=None, showlegend=False ) ) # Forecast fig.add_trace(go.Scatter( x=df_fc["time"], y=df_fc["forecast_close"], mode="lines+markers", name=f"Forecast ({model_to_view})" )) fig.update_layout( template="plotly_dark", hovermode="x unified", dragmode="pan", ) st.plotly_chart( fig, use_container_width=True, config={"scrollZoom": True} ) # 12. Metrics if "all_metrics" in st.session_state: st.subheader("📐 So sánh Metrics") metric_options = { "MAE (giá trị càng nhỏ model càng tốt)": "MAE", "RMSE (giá trị càng nhỏ model càng tốt)": "RMSE", "MASE (giá trị càng nhỏ model càng tốt)": "MASE", "DA (giá trị càng lớn model càng tốt, trên 0.5 là model rất tốt)": "DA", } metric_label = st.selectbox( "🎛️ Chọn metric để so sánh", list(metric_options.keys()) ) metric_to_view = metric_options[metric_label] df_m = st.session_state.all_metrics.sort_values( metric_to_view, ascending=(metric_to_view != "DA") # DA càng cao càng tốt ) fig = go.Figure() fig.add_trace(go.Bar( x=df_m["Model"], y=df_m[metric_to_view], text=df_m[metric_to_view].round(3), textposition="auto" )) fig.update_layout( template="plotly_dark", yaxis_title=metric_to_view, xaxis_title="Model", dragmode="pan", ) st.plotly_chart( fig, use_container_width=True, config={"scrollZoom": True} ) if menu == "🧪 Demo sản phẩm": # 1. Page Config st.set_page_config( page_title="VN30 Forecast - Demo Product", layout="wide" ) # 2. Load data @st.cache_data def load_data(): df = pd.read_csv("VN30_Test.csv") df["time"] = pd.to_datetime(df["time"]) df = df.sort_values(["symbol", "time"]) return df df_train = load_data() symbols = sorted(df_train["symbol"].unique()) @st.cache_data def load_news(): df = pd.read_csv("VN30_news_with_sentiment.csv") df["date"] = pd.to_datetime(df["date"]) return df df_news = load_news() # 3. UI st.markdown('

📈 VN30 Stock Forecast - Demo Product

', unsafe_allow_html=True) a1, a2, a3 = st.columns([1, 2, 1]) with a2: st.image( "VN30_Product_Thumb.png", width=1000 ) st.markdown("---") with st.expander("🎯 **Mục tiêu dự án** (bấm để xem chi tiết)"): st.markdown( """

🎯 Mục tiêu dự án

Dự án này nhằm xây dựng một ứng dụng dự báo giá cổ phiếu VN30 theo ngày bằng nhiều phương pháp forecasting khác nhau, bao gồm:

""", unsafe_allow_html=True ) # Xuất ra ngày hiện tại today_str = datetime.now().strftime("%d/%m/%Y") st.markdown( f"""

📅 Ngày hiện tại: {today_str}

""", unsafe_allow_html=True ) # 4. Crawl data def crawl_vn30_data(start_date, end_date, filename): symbols = [ "ACB","DGC","BCM","BID","FPT","HDB","HPG","LPB","MSN","MBB", "MWG","PLX","GAS","SAB","STB","SHB","SSB","SSI","TCB","TPB", "VCB","CTG","VJC","VIB","GVR","VNM","VRE","VIC","VHM","VPB" ] all_dfs = [] progress = st.progress(0) status = st.empty() for i, symbol in enumerate(symbols): try: stock = Vnstock().stock(symbol=symbol, source="VCI") df = stock.quote.history( start=start_date, end=end_date, interval="1D" ) if df.empty: status.warning(f"{symbol} không có dữ liệu") continue # Feature engineering (GIỮ NGUYÊN) df["estimated_value"] = ( (df["open"] + df["high"] + df["low"] + df["close"]) / 4 ) * df["volume"] df["+/- price percent"] = df["close"].pct_change().mul(100).round(2) df["symbol"] = symbol all_dfs.append(df) status.info(f"Đã crawl xong {symbol}") except Exception as e: status.error(f"Lỗi {symbol}: {e}") progress.progress((i + 1) / len(symbols)) time.sleep(10) if len(all_dfs) == 0: st.error("Không crawl được dữ liệu nào") return final_df = pd.concat(all_dfs, ignore_index=True) final_df.to_csv(filename, index=False) st.success(f"Đã lưu {filename}") st.markdown("## 📥 Crawl dữ liệu VN30") if st.button("⬇️ Crawl Data (Từ 2022 đến hiện tại)"): with st.spinner("Đang crawl data..."): end_date = datetime.now().strftime("%Y-%m-%d") crawl_vn30_data( start_date="2022-01-01", end_date=end_date, filename="VN30_Test.csv" ) st.cache_data.clear() st.rerun() # 5. Làm button để cho người dùng chọn cổ phiếu và model st.markdown("## 📌 Chọn cổ phiếu và ngày sắp tới để dự đoán") left, right = st.columns([1, 1]) with left: symbol = st.selectbox( "Bạn hãy chọn cổ phiếu", symbols ) with right: horizon = st.number_input( "Bạn muốn forecast trong bao nhiêu ngày sắp tới", min_value=1, max_value=30, value=14, step=1 ) st.markdown("## 🧠 Chọn model để dự đoán") model_name = st.radio( "", [ "Technical Analysis", "SARIMA", "PatchTST", "TimeFM", "TimeGPT (Recommended)", "TimeGPT with news (Beta)" ], horizontal=True ) # 6. Load Models @st.cache_resource def load_timefm(): model = TimesFmModelForPrediction.from_pretrained( "google/timesfm-2.0-500m-pytorch", dtype=torch.bfloat16, attn_implementation="sdpa", device_map="auto" ) model.eval() return model @st.cache_resource def load_timegpt(): return NixtlaClient( api_key="nixak-zWQjbVl9QCc6eIFL3DDbaBXi09bnPKsa5jdUU7Q8izPpn3eYl0rZPWLLs8NI597PT0VzIODhPUmKzkMc" ) @st.cache_resource def load_patchtst(): config = PatchTSTConfig( context_length=31, prediction_length=3, input_size=1, patch_len=6, stride=3, d_model=96, num_hidden_layers=4, num_attention_heads=4, dropout=0.15 ) model = PatchTSTForPrediction(config) model.load_state_dict( torch.load("best_patchtst_vn30.pt", map_location="cpu") ) model.eval() scalers = joblib.load("patchtst_scalers_vn30.pkl") return model, scalers # 7. Forecast Function def compute_ci(df_sym, preds): returns = df_sym["close"].diff().dropna() vol = returns.rolling(20, min_periods=5).std().iloc[-1] z = 1.96 return preds - z * vol, preds + z * vol def forecast_timefm(symbol, horizon): model = load_timefm() df_sym = df_train[df_train["symbol"] == symbol] series = df_sym["close"].astype(float).values past_tensor = ( torch.from_numpy(series) .to(dtype=torch.bfloat16, device=model.device) .unsqueeze(0) ) freq_tensor = torch.tensor([0], dtype=torch.long, device=model.device) with torch.no_grad(): outputs = model( past_values=past_tensor, freq=freq_tensor, return_dict=True ) preds = outputs.mean_predictions[0].float().cpu().numpy()[:horizon] future_time = pd.bdate_range( start=df_sym["time"].iloc[-1] + pd.offsets.BDay(), periods=horizon ) lo, hi = compute_ci(df_sym, preds) return pd.DataFrame({ "time": future_time, "forecast_close": preds, "lower_95": lo, "upper_95": hi }) def forecast_timegpt(symbol, horizon): client = load_timegpt() df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.rename(columns={"time": "ds", "close": "y"})[["ds", "y"]] df_sym = df_sym.sort_values("ds").drop_duplicates(subset="ds") # Create full business-day range full_range = pd.date_range( start=df_sym["ds"].min(), end=df_sym["ds"].max(), freq="B" ) df_sym = ( df_sym .set_index("ds") .reindex(full_range) .rename_axis("ds") .reset_index() ) # Fill missing prices df_sym["y"] = df_sym["y"].ffill().bfill() df_ts = df_sym.rename( columns={"time": "ds", "close": "y"} )[["ds", "y"]] df_ts["unique_id"] = symbol df_fc = client.forecast( df=df_ts, h=horizon, freq="B", level=[95] ) return df_fc.rename(columns={ "ds": "time", "TimeGPT": "forecast_close", "TimeGPT-lo-95": "lower_95", "TimeGPT-hi-95": "upper_95" })[["time", "forecast_close", "lower_95", "upper_95"]] def forecast_timegpt_news(symbol, horizon): client = load_timegpt() df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.rename(columns={"time": "ds", "close": "y"})[["ds", "y"]] df_sym = df_sym.sort_values("ds").drop_duplicates(subset="ds") # Create full business-day range full_range = pd.date_range( start=df_sym["ds"].min(), end=df_sym["ds"].max(), freq="B" ) df_sym = ( df_sym .set_index("ds") .reindex(full_range) .rename_axis("ds") .reset_index() ) # Fill missing prices df_sym["y"] = df_sym["y"].ffill().bfill() # News df_news_sym = df_news[df_news["symbol"] == symbol].copy() df_news_sym["date"] = pd.to_datetime(df_news_sym["date"]) df_sent = ( df_news_sym .groupby("date")["sentiment"] .mean() .reset_index() ) df_sym["date"] = df_sym["ds"].dt.normalize() df_ts = df_sym.merge(df_sent, on="date", how="left") # Fill missing sentiment df_ts["sentiment"] = df_ts["sentiment"].fillna(0.0) df_ts["unique_id"] = symbol df_fc = client.forecast( df=df_ts[["unique_id", "ds", "y", "sentiment"]], h=horizon, freq="B", level=[95] ) return df_fc.rename(columns={ "ds": "time", "TimeGPT": "forecast_close", "TimeGPT-lo-95": "lower_95", "TimeGPT-hi-95": "upper_95" })[["time", "forecast_close", "lower_95", "upper_95"]] def forecast_patchtst(symbol, horizon): model, scalers = load_patchtst() df_sym = df_train[df_train["symbol"] == symbol] scaler = scalers[symbol] values = df_sym["close"].values.reshape(-1, 1) scaled = scaler.transform(values).flatten() context = scaled[-31:].copy() preds_scaled = [] with torch.no_grad(): for _ in range(horizon): xb = torch.tensor(context, dtype=torch.float32).unsqueeze(0).unsqueeze(-1) out = model(past_values=xb) next_pred = out.prediction_outputs[0, 0, 0].item() preds_scaled.append(next_pred) context = np.roll(context, -1) context[-1] = next_pred preds = scaler.inverse_transform( np.array(preds_scaled).reshape(-1, 1) ).flatten() future_time = pd.bdate_range( start=df_sym["time"].iloc[-1] + pd.offsets.BDay(), periods=horizon ) lo, hi = compute_ci(df_sym, preds) return pd.DataFrame({ "time": future_time, "forecast_close": preds, "lower_95": lo, "upper_95": hi }) def forecast_sarima(symbol, horizon=7): df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.sort_values("time").drop_duplicates(subset="time") # Endogenous (price) ts = ( df_sym .set_index("time")["close"] .astype(float) .asfreq("B") .ffill() ) # Exogenous variables df_exog = df_sym.set_index("time").asfreq("B").ffill() df_exog["return"] = df_exog["close"].pct_change().shift(1) df_exog["volume"] = np.log1p(df_exog["volume"]) exog = df_exog[["return", "volume"]].fillna(0) # Đồng bộ index exog = exog.loc[ts.index] # Model model = SARIMAX( ts, exog=exog, order=(2, 0, 2), seasonal_order=(1, 0, 1, 5), enforce_stationarity=False, enforce_invertibility=False ) result = model.fit(disp=False, maxiter=50) # Future exog future_time = pd.bdate_range( start=ts.index[-1] + pd.offsets.BDay(), periods=horizon ) mean_ret = df_exog["return"].tail(20).mean() mean_vol = df_exog["volume"].tail(20).mean() future_exog = pd.DataFrame( { "return": np.full(horizon, mean_ret), "volume": np.full(horizon, mean_vol) }, index=future_time ) fc = result.get_forecast( steps=horizon, exog=future_exog ) conf_int = fc.conf_int(alpha=0.05) return pd.DataFrame({ "time": future_time, "forecast_close": fc.predicted_mean.values, "lower_95": conf_int.iloc[:, 0].values, "upper_95": conf_int.iloc[:, 1].values }) def forecast_technical(symbol, horizon=7): df = df_train[df_train["symbol"] == symbol].copy() df = df.sort_values("time").reset_index(drop=True) # Indicators df["MA20"] = df["close"].rolling(20).mean() df["MA50"] = df["close"].rolling(50).mean() delta = df["close"].diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.rolling(14).mean() avg_loss = loss.rolling(14).mean() rs = avg_gain / avg_loss df["RSI"] = 100 - (100 / (1 + rs)) def signal(row): if row["close"] > row["MA20"] > row["MA50"] and row["RSI"] > 50: return 1 elif row["close"] < row["MA20"] < row["MA50"] and row["RSI"] < 50: return -1 else: return 0 df["signal"] = df.apply(signal, axis=1) # Forecast last_price = df["close"].iloc[-1] last_signal = df["signal"].iloc[-1] returns = df["close"].pct_change().rolling(10).std() vol = returns.iloc[-1] vol = 0 if np.isnan(vol) else min(vol, 0.01) future_time = pd.bdate_range( start=df["time"].iloc[-1] + pd.offsets.BDay(), periods=horizon ) prices = [] price = last_price for _ in range(horizon): if last_signal == 1: price *= (1 + vol) elif last_signal == -1: price *= (1 - vol) else: price *= (1 + 0.1 * vol) prices.append(price) preds = np.array(prices) lo, hi = compute_ci(df, preds) return pd.DataFrame({ "time": future_time, "forecast_close": preds, "lower_95": lo, "upper_95": hi }) # 8. Rolling backtest def rolling_backtest(df_sym, forecast_fn, window=500, step=5): errors = [] for start in range(0, len(df_sym) - window - horizon, step): train_slice = df_sym.iloc[start:start + window] test_slice = df_sym.iloc[start + window:start + window + horizon] df_pred = forecast_fn( train_slice["symbol"].iloc[0], horizon ) y_true = test_slice["close"].values[:len(df_pred)] y_pred = df_pred["forecast_close"].values # MAE mae = mean_absolute_error(y_true, y_pred) # RMSE rmse = root_mean_squared_error(y_true, y_pred) # MASE (naive = yesterday) naive = train_slice["close"].diff().dropna() mae_naive = naive.abs().mean() mase = mae / mae_naive if mae_naive != 0 else np.nan # Directional Accuracy actual_dir = np.sign(np.diff(y_true)) pred_dir = np.sign(np.diff(y_pred)) da = (actual_dir == pred_dir).mean() if len(actual_dir) > 0 else np.nan errors.append({ "MAE": mae, "RMSE": rmse, "MASE": mase, "DA": da }) return pd.DataFrame(errors) def backtest_sarima(symbol, n_test=7): df_sym = df_train[df_train["symbol"] == symbol].copy() df_sym = df_sym.sort_values("time").drop_duplicates(subset="time") # Endogenous ts = ( df_sym .set_index("time")["close"] .astype(float) .asfreq("B") .ffill() ) # Exogenous df_exog = df_sym.set_index("time").asfreq("B").ffill() df_exog["return"] = df_exog["close"].pct_change().shift(1) df_exog["volume"] = np.log1p(df_exog["volume"]) exog = df_exog[["return", "volume"]].fillna(0) # Đồng bộ index exog = exog.loc[ts.index] # Train / Test split ts_train = ts.iloc[:-n_test] ts_test = ts.iloc[-n_test:] exog_train = exog.loc[ts_train.index] exog_test = exog.loc[ts_test.index] # Model model = SARIMAX( ts_train, exog=exog_train, order=(2, 0, 2), seasonal_order=(1, 0, 1, 5), enforce_stationarity=False, enforce_invertibility=False ) result = model.fit(disp=False, maxiter=50) fc = result.get_forecast( steps=n_test, exog=exog_test ) y_pred = fc.predicted_mean y_true = ts_test # Metrics mae = mean_absolute_error(y_true, y_pred) rmse = root_mean_squared_error(y_true, y_pred) naive = ts_train.shift(1).dropna() mae_naive = mean_absolute_error( ts_train.loc[naive.index], naive ) mase = mae / mae_naive if mae_naive != 0 else np.nan actual_dir = np.sign(y_true.diff().iloc[1:]) pred_dir = np.sign(y_pred.diff().iloc[1:]) da = (actual_dir == pred_dir).mean() return { "MAE": mae, "RMSE": rmse, "MASE": mase, "DA": da } def backtest_technical(symbol, n_test=7): df = df_train[df_train["symbol"] == symbol].copy() df = df.sort_values("time").reset_index(drop=True) train = df.iloc[:-n_test] test = df.iloc[-n_test:] df_fc = forecast_technical(symbol, horizon=n_test) y_true = test["close"].values y_pred = df_fc["forecast_close"].values # MAE mae = mean_absolute_error(y_true, y_pred) # RMSE rmse = root_mean_squared_error(y_true, y_pred) # MASE naive = train["close"].shift(1).dropna() mae_naive = np.abs(naive.values - train["close"].iloc[1:].values).mean() mase = mae / mae_naive if mae_naive != 0 else np.nan # Directional Accuracy actual_dir = np.sign(np.diff(y_true)) pred_dir = np.sign(np.diff(y_pred)) da = (actual_dir == pred_dir).mean() return { "MAE": mae, "RMSE": rmse, "MASE": mase, "DA": da } # 7. Run Forecast if st.button("🚀 Forecast"): status = st.empty() status.info(f"⏳ Đang chạy model: **{model_name}**") # Forecast theo model được chọn if model_name == "TimeFM": df_fc = forecast_timefm(symbol, horizon) bt_fn = forecast_timefm bt_err = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) elif model_name == "TimeGPT (Recommended)": df_fc = forecast_timegpt(symbol, horizon) bt_fn = forecast_timegpt bt_err = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) elif model_name == "TimeGPT with news (Beta)": df_fc = forecast_timegpt_news(symbol, horizon) bt_fn = forecast_timegpt_news bt_err = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) elif model_name == "SARIMA": df_fc = forecast_sarima(symbol, horizon) bt_err = pd.DataFrame([backtest_sarima(symbol, n_test=horizon)]) elif model_name == "Technical Analysis": df_fc = forecast_technical(symbol, horizon) bt_err = pd.DataFrame([backtest_technical(symbol, n_test=horizon)]) else: # PatchTST df_fc = forecast_patchtst(symbol, horizon) bt_fn = forecast_patchtst bt_err = rolling_backtest( df_train[df_train["symbol"] == symbol].reset_index(drop=True), bt_fn ) # Lưu vào session_state st.session_state.df_fc = df_fc st.session_state.bt_err = bt_err status.success("Forecast hoàn tất!") # 9. Table if "df_fc" in st.session_state: st.subheader(f"📋 Forecast Table {symbol} – {horizon} ngày ({model_name})") df_display = ( st.session_state.df_fc .rename(columns={ "time": "Time", "forecast_close": "Forecast Close Price", "lower_95": "Lower 95% CI", "upper_95": "Upper 95% CI" }) .assign( **{ "Forecast Close Price": lambda x: x["Forecast Close Price"].round(2), "Lower 95% CI": lambda x: x["Lower 95% CI"].round(2), "Upper 95% CI": lambda x: x["Upper 95% CI"].round(2), } ) ) st.dataframe(df_display, use_container_width=True, height=min(len(df_display), 30) * 35 + 40) # 10. Plot if "df_fc" in st.session_state: df_fc = st.session_state.df_fc df_hist = df_train[df_train["symbol"] == symbol] st.subheader(f"📊 Forecast plot {symbol} – {horizon} ngày ({model_name})") fig = go.Figure() # Historical fig.add_trace(go.Scatter( x=df_hist["time"], y=df_hist["close"], mode="lines", name="Historical", line=dict(width=2.5) )) # CI fig.add_trace(go.Scatter( x=df_fc["time"], y=df_fc["upper_95"], mode="lines", line=dict(width=0), name="95% CI", showlegend=True ) ) fig.add_trace(go.Scatter( x=df_fc["time"], y=df_fc["lower_95"], mode="lines", line=dict(width=0), fill="tonexty", fillcolor="rgba(168, 85, 247, 0.35)", # tím xịn name=None, showlegend=False ) ) # Forecast fig.add_trace(go.Scatter( x=df_fc["time"], y=df_fc["forecast_close"], mode="lines+markers", name=f"Forecast ({model_name})" )) fig.update_layout( template="plotly_dark", hovermode="x unified", dragmode="pan", ) st.plotly_chart( fig, use_container_width=True, config={"scrollZoom": True} ) # 11. Metrics if "bt_err" in st.session_state: st.subheader("📐 Metrics") c1, c2, c3, c4 = st.columns(4) with c1: st.metric( "MAE (avg)", f"{st.session_state.bt_err['MAE'].mean():.3f}" ) with c2: st.metric( "RMSE (avg)", f"{st.session_state.bt_err['RMSE'].mean():.3f}" ) with c3: st.metric( "MASE (avg)", f"{st.session_state.bt_err['MASE'].mean():.3f}" ) with c4: st.metric( "DA (avg)", f"{st.session_state.bt_err['DA'].mean() * 100:.1f}%" ) if menu == "📘 Tài liệu": # 1. Page Config st.set_page_config( page_title="VN30 Forecast - Documentation", layout="wide" ) # 2. UI st.markdown('

📈 VN30 Stock Forecast - Documentation

', unsafe_allow_html=True) a1, a2, a3 = st.columns([1, 2, 1]) with a2: st.image( "VN30_Doc_Thumb.png", width=1000 ) st.markdown("---") # 3. Tổng quan hệ thống st.markdown("## 🔍 Tổng quan hệ thống") st.markdown( """ Ứng dụng **VN30 Stock Forecast** được xây dựng nhằm mục tiêu dự báo **giá đóng cửa (Close price)** của các cổ phiếu thuộc rổ **VN30** theo tần suất **ngày giao dịch (Business Day)**. Hệ thống kết hợp nhiều phương pháp dự báo khác nhau, từ: - Phương pháp truyền thống - Mô hình thống kê - Deep Learning - Foundation & Generative Models Pipeline tổng thể gồm **3 bước chính**: 1. Thu thập dữ liệu (Crawl data) 2. Dự báo bằng nhiều mô hình 3. Đánh giá và so sánh mô hình """ ) # 4. Crawl data st.markdown("## 📥 Thu thập dữ liệu (Crawl Data)") st.markdown( """ ### 🔹 Nguồn dữ liệu Dữ liệu giá cổ phiếu được thu thập thông qua thư viện **`vnstock`**, sử dụng API từ các công ty chứng khoán tại Việt Nam (VCI). Các mã cổ phiếu thuộc rổ **VN30** được crawl với các trường: - `open`, `high`, `low`, `close` (giá mở cửa, cao nhất, thấp nhất, đóng cửa) - `volume` (khối lượng giao dịch) - `time` (ngày giao dịch) **Tần suất dữ liệu:** theo ngày **Khoảng thời gian:** từ năm 2022 đến hiện tại """ ) st.markdown( """ ### 🔹 Xử lý dữ liệu - Chuẩn hóa định dạng thời gian - Sắp xếp theo `symbol` và `time` - Đồng bộ theo ngày giao dịch (Business Day) - Forward fill / Backward fill cho các ngày thiếu dữ liệu """ ) st.markdown( """ ### 🔹 Tin tức & Sentiment (tuỳ chọn) - Tin tức được crawl từ **cafef.vn** - Mỗi bài viết được gán **sentiment score** - Sentiment được tổng hợp theo ngày - Sentiment được sử dụng như **biến ngoại sinh** cho mô hình TimeGPT with news """ ) # 3. Forecasting models st.markdown("## 🧠 Các mô hình dự báo") st.markdown("### 1. Technical Analysis") st.markdown( """ Mô hình dựa trên các chỉ báo kỹ thuật phổ biến: - Moving Average (MA20, MA50) - RSI (Relative Strength Index) Ý tưởng chính: - Xác định xu hướng giá - Xác định động lượng thị trường - Sinh tín hiệu mua / bán / giữ 👉 Đây là mô hình **baseline**, đơn giản và dễ diễn giải. """ ) st.markdown("### 2. SARIMA") st.markdown( """ SARIMA (Seasonal ARIMA) là mô hình thống kê cổ điển cho chuỗi thời gian. Đặc điểm: - Mô hình hóa xu hướng - Mô hình hóa mùa vụ - Kết hợp biến ngoại sinh Trong hệ thống: - Endogenous: giá đóng cửa - Exogenous: return, volume (log-transform) 👉 Phù hợp với dữ liệu tài chính có tính mùa vụ. """ ) st.markdown("### 3. PatchTST") st.markdown( """ PatchTST là mô hình Transformer cho chuỗi thời gian. Ý tưởng chính: - Chia chuỗi thời gian thành các patch - Giảm độ dài sequence - Học quan hệ dài hạn hiệu quả hơn 👉 Phù hợp cho dự báo nhiều bước (multi-step forecasting). """ ) st.markdown("### 4. TimeFM") st.markdown( """ TimeFM là **foundation model cho time series**, được huấn luyện trước trên tập dữ liệu lớn. Đặc điểm: - Không cần huấn luyện lại nhiều - Dự báo nhanh - Tổng quát hóa tốt 👉 Trong project, TimeFM được dùng ở mức **tham khảo**. """ ) st.markdown("### 5. TimeGPT (Recommended)") st.markdown( """ TimeGPT là **Generative Forecasting Model** của Nixtla. Đặc điểm: - Tự động học trend và seasonality - Sinh dự báo xác suất - Trả về khoảng tin cậy (Confidence Interval) 👉 Đây là **mô hình được khuyến nghị sử dụng** trong ứng dụng. """ ) st.markdown("### 6. TimeGPT with News (Beta)") st.markdown( """ Mở rộng của TimeGPT bằng cách thêm biến **sentiment từ tin tức** từ model LLM **qwen3**. Ý tưởng: - Giá cổ phiếu chịu ảnh hưởng bởi thông tin thị trường - Sentiment được đưa vào như biến ngoại sinh 👉 Hiện tại ở mức **Beta** vì Qwen3 khá nặng chỉ chạy trên máy local, nên chỉ mang tính thử nghiệm. """ ) # 4. Metrics st.markdown("## 📐 Các chỉ số đánh giá (Metrics)") st.markdown("### 🔹 MAE – Mean Absolute Error") st.markdown( """ Đo sai số tuyệt đối trung bình giữa giá thực tế và giá dự báo. 👉 **Giá trị càng nhỏ → model càng tốt** """ ) st.markdown("### 🔹 RMSE – Root Mean Squared Error") st.markdown( """ Đo sai số bình phương trung bình, nhạy cảm với các lỗi lớn. 👉 **Giá trị càng nhỏ → model càng tốt** """ ) st.markdown("### 🔹 MASE – Mean Absolute Scaled Error") st.markdown( """ So sánh mô hình với baseline naive (giá hôm nay = giá hôm qua). 👉 MASE < 1: model tốt hơn naive 👉 **Giá trị càng nhỏ → model càng tốt** """ ) st.markdown("### 🔹 DA – Directional Accuracy") st.markdown( """ Đo độ chính xác trong việc dự đoán **chiều hướng tăng / giảm** của giá. 👉 Giá trị từ 0 đến 1 👉 **DA > 0.5: model dự đoán xu hướng tốt** """ )