"""Streamlit dashboard for Kaggle-trained trading-agent artifacts.""" from __future__ import annotations import json import os from pathlib import Path os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") import numpy as np import pandas as pd import plotly.graph_objects as go import streamlit as st from huggingface_hub import hf_hub_download, list_repo_files st.set_page_config( page_title="Trading Agent Dashboard", page_icon=":chart_with_upwards_trend:", layout="wide", initial_sidebar_state="expanded", ) HF_MODEL_REPO = os.getenv("HF_MODEL_REPO", "rezvan98/trading-agent-rl") HF_TOKEN = os.getenv("HF_TOKEN") or None SYMBOLS = [ symbol.strip().upper() for symbol in os.getenv("SYMBOLS", "AAPL").split(",") if symbol.strip() ] CAPITAL = float(os.getenv("CAPITAL", "100000")) TEST_START = os.getenv("TEST_START", "2024-01-01") EXPECTED_ARTIFACTS = { "report/metrics.json": "Backtest metrics", "data/backtest_results.csv": "Portfolio equity curve", "data/trade_log.csv": "Backtest trade log", "data/feature_schema.json": "Feature columns and training scalers", "models/ppo_final.zip": "PPO model", "models/sac_final.zip": "SAC model", "models/a2c_final.zip": "A2C model", "models/ensemble_weights.json": "Ensemble weights", } PERCENT_KEY_PARTS = ("return", "drawdown", "win_rate", "var", "cvar", "alpha") def _download(filename: str) -> tuple[Path | None, str | None]: try: path = hf_hub_download( repo_id=HF_MODEL_REPO, repo_type="model", filename=filename, token=HF_TOKEN, ) return Path(path), None except Exception as exc: return None, str(exc) @st.cache_data(ttl=3600, show_spinner=False) def load_metrics() -> tuple[dict[str, float] | None, str | None]: path, error = _download("report/metrics.json") if error or path is None: return None, error try: return json.loads(path.read_text()), None except Exception as exc: return None, str(exc) @st.cache_data(ttl=3600, show_spinner=False) def load_backtest_results() -> tuple[pd.DataFrame | None, str | None]: path, error = _download("data/backtest_results.csv") if error or path is None: return None, error try: df = pd.read_csv(path) if "portfolio_value" not in df.columns: return None, "data/backtest_results.csv is missing portfolio_value" return df, None except Exception as exc: return None, str(exc) @st.cache_data(ttl=3600, show_spinner=False) def load_feature_schema() -> tuple[dict | None, str | None]: path, error = _download("data/feature_schema.json") if error or path is None: return None, error try: return json.loads(path.read_text()), None except Exception as exc: return None, str(exc) @st.cache_data(ttl=3600, show_spinner=False) def load_ensemble_weights() -> dict[str, float]: path, error = _download("models/ensemble_weights.json") if error or path is None: return {} try: raw = json.loads(path.read_text()) weights = {str(k): float(v) for k, v in raw.items()} total = sum(max(v, 0.0) for v in weights.values()) if total > 0: return {k: max(v, 0.0) / total for k, v in weights.items()} except Exception: pass return {} @st.cache_data(ttl=3600, show_spinner=False) def load_artifact_files() -> tuple[list[str], str | None]: try: files = list_repo_files(repo_id=HF_MODEL_REPO, repo_type="model", token=HF_TOKEN) return sorted(files), None except Exception as exc: return [], str(exc) @st.cache_data(ttl=3600, show_spinner=False) def load_spy_data(start: str = TEST_START) -> pd.DataFrame: try: import yfinance as yf df = yf.download("SPY", start=start, auto_adjust=True, progress=False) if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) return df[["Close"]].dropna() except Exception: return pd.DataFrame() def _safe(df: pd.DataFrame, series: pd.Series | None, name: str) -> None: df[name] = np.nan if series is None else series.replace([np.inf, -np.inf], np.nan) def turbulence_index_numpy(df: pd.DataFrame, window: int = 252) -> pd.Series: returns = df["Close"].pct_change().fillna(0).to_numpy() turbulence = np.zeros(len(df)) for i in range(window, len(df)): hist = returns[i - window : i] variance = np.var(hist) + 1e-8 turbulence[i] = float((returns[i] - hist.mean()) ** 2 / variance) return pd.Series(turbulence, index=df.index) def add_features(df: pd.DataFrame) -> tuple[pd.DataFrame, list[str]]: out = df[["Open", "High", "Low", "Close", "Volume"]].copy() close, high, low, volume = df["Close"], df["High"], df["Low"], df["Volume"] typical = (high + low + close) / 3 def ema(series: pd.Series, span: int) -> pd.Series: return series.ewm(span=span, adjust=False).mean() def rolling_mid(window: int) -> pd.Series: return (high.rolling(window).max() + low.rolling(window).min()) / 2 true_range = pd.concat( [ high - low, (high - close.shift()).abs(), (low - close.shift()).abs(), ], axis=1, ).max(axis=1) atr_14 = true_range.rolling(14).mean() for period in (5, 10, 20, 50, 200): _safe(out, close.rolling(period).mean(), f"SMA_{period}") for period in (9, 21): _safe(out, ema(close, period), f"EMA_{period}") macd = ema(close, 12) - ema(close, 26) macd_signal = ema(macd, 9) _safe(out, macd, "MACD_12_26_9") _safe(out, macd - macd_signal, "MACDh_12_26_9") _safe(out, macd_signal, "MACDs_12_26_9") up_move = high.diff() down_move = -low.diff() plus_dm = pd.Series( np.where((up_move > down_move) & (up_move > 0), up_move, 0.0), index=df.index, ) minus_dm = pd.Series( np.where((down_move > up_move) & (down_move > 0), down_move, 0.0), index=df.index, ) tr_14 = true_range.rolling(14).sum() dmp_14 = 100 * plus_dm.rolling(14).sum() / (tr_14 + 1e-9) dmn_14 = 100 * minus_dm.rolling(14).sum() / (tr_14 + 1e-9) dx_14 = 100 * (dmp_14 - dmn_14).abs() / (dmp_14 + dmn_14 + 1e-9) _safe(out, dx_14.rolling(14).mean(), "ADX_14") _safe(out, dmp_14, "DMP_14") _safe(out, dmn_14, "DMN_14") tenkan = rolling_mid(9) kijun = rolling_mid(26) _safe(out, (tenkan + kijun) / 2, "ISA_9") _safe(out, rolling_mid(52), "ISB_26") _safe(out, tenkan, "ITS_9") _safe(out, kijun, "IKS_26") _safe(out, close.shift(26), "ICS_26") delta = close.diff() gain = delta.where(delta > 0, 0.0).rolling(14).mean() loss = (-delta.where(delta < 0, 0.0)).rolling(14).mean() rs = gain / (loss + 1e-9) _safe(out, 100 - (100 / (1 + rs)), "RSI_14") lowest_14 = low.rolling(14).min() highest_14 = high.rolling(14).max() stoch_k = 100 * (close - lowest_14) / (highest_14 - lowest_14 + 1e-9) _safe(out, stoch_k.rolling(3).mean(), "STOCHk_14_3_3") _safe(out, stoch_k.rolling(3).mean().rolling(3).mean(), "STOCHd_14_3_3") _safe(out, -100 * (highest_14 - close) / (highest_14 - lowest_14 + 1e-9), "WILLR_14") sma_tp = typical.rolling(20).mean() mad_tp = (typical - sma_tp).abs().rolling(20).mean() _safe(out, (typical - sma_tp) / (0.015 * mad_tp + 1e-9), "CCI_20") _safe(out, close.pct_change(10) * 100, "ROC_10") _safe(out, close.diff(10), "MOM_10") bb_mid = close.rolling(20).mean() bb_std = close.rolling(20).std() bb_low = bb_mid - 2 * bb_std bb_high = bb_mid + 2 * bb_std _safe(out, bb_low, "BBL_20_2") _safe(out, bb_mid, "BBM_20_2") _safe(out, bb_high, "BBU_20_2") _safe(out, 100 * (bb_high - bb_low) / (bb_mid.abs() + 1e-9), "BBB_20_2") _safe(out, (close - bb_low) / (bb_high - bb_low + 1e-9), "BBP_20_2") _safe(out, atr_14, "ATR_14") kc_mid = ema(typical, 20) _safe(out, kc_mid - 2 * atr_14, "KCL_20_2") _safe(out, kc_mid, "KCB_20_2") _safe(out, kc_mid + 2 * atr_14, "KCU_20_2") direction = np.sign(close.diff()).fillna(0.0) _safe(out, (direction * volume).cumsum(), "OBV") _safe(out, (typical * volume).cumsum() / (volume.cumsum() + 1e-9), "VWAP") raw_money_flow = typical * volume positive_flow = raw_money_flow.where(typical.diff() > 0, 0.0) negative_flow = raw_money_flow.where(typical.diff() < 0, 0.0) money_ratio = positive_flow.rolling(14).sum() / (negative_flow.rolling(14).sum() + 1e-9) _safe(out, 100 - (100 / (1 + money_ratio)), "MFI_14") mf_multiplier = ((close - low) - (high - close)) / (high - low + 1e-9) _safe(out, (mf_multiplier * volume).rolling(20).sum() / (volume.rolling(20).sum() + 1e-9), "CMF_20") _safe(out, turbulence_index_numpy(df), "Turbulence") _safe(out, close.pct_change().rolling(30).std() * np.sqrt(252), "VIX_Proxy") feature_cols = [col for col in out.columns if col not in ("Open", "High", "Low", "Close", "Volume")] out[feature_cols] = out[feature_cols].shift(1) out.dropna(inplace=True) return out, feature_cols @st.cache_data(ttl=300, show_spinner=False) def download_market_data(symbol: str) -> pd.DataFrame: import yfinance as yf df = yf.download(symbol, period="3y", auto_adjust=True, progress=False) if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) return df[["Open", "High", "Low", "Close", "Volume"]].dropna() def build_live_observation( raw_df: pd.DataFrame, schema: dict, symbol: str, ) -> tuple[np.ndarray, float, int]: feature_cols = list(schema["feature_cols"]) lookback = int(schema.get("lookback", 30)) featured, computed_cols = add_features(raw_df) if featured.empty: raise RuntimeError("Not enough market history to compute live features.") missing = [col for col in feature_cols if col not in computed_cols] if missing: raise RuntimeError(f"Live feature pipeline is missing columns: {', '.join(missing[:8])}") scaler_by_symbol = schema.get("scalers", {}) scaler = scaler_by_symbol.get(symbol) or scaler_by_symbol.get("AAPL") if not scaler: raise RuntimeError(f"No scaler found for {symbol} and no AAPL fallback scaler is available.") center = np.asarray(scaler["center"], dtype=np.float32) scale = np.asarray(scaler["scale"], dtype=np.float32) scale = np.where(np.abs(scale) < 1e-9, 1.0, scale) if center.shape[0] != len(feature_cols) or scale.shape[0] != len(feature_cols): raise RuntimeError("Scaler dimensions do not match feature schema.") featured = featured.copy() values = featured[feature_cols].fillna(0).to_numpy(dtype=np.float32) featured.loc[:, feature_cols] = (values - center) / scale window = featured[feature_cols].tail(lookback).to_numpy(dtype=np.float32) if len(window) < lookback: pad = np.zeros((lookback - len(window), len(feature_cols)), dtype=np.float32) window = np.vstack([pad, window]) position = np.zeros((lookback, 1), dtype=np.float32) obs = np.concatenate([window, position], axis=1) price = float(featured["Close"].iloc[-1]) return obs, price, lookback @st.cache_resource(ttl=3600, show_spinner=False) def load_agent_models(n_features: int, lookback: int): import gymnasium as gym import torch import torch.nn as nn from gymnasium import spaces from stable_baselines3 import A2C, PPO, SAC from stable_baselines3.common.torch_layers import BaseFeaturesExtractor torch.set_num_threads(min(4, os.cpu_count() or 1)) class _SelfAttn(nn.Module): def __init__(self, dim: int): super().__init__() self.scale = dim**-0.5 self.q = nn.Linear(dim, dim, bias=False) self.k = nn.Linear(dim, dim, bias=False) self.v = nn.Linear(dim, dim, bias=False) def forward(self, x): q, k, v = self.q(x), self.k(x), self.v(x) attn = torch.softmax(torch.bmm(q, k.transpose(1, 2)) * self.scale, dim=-1) return torch.bmm(attn, v) class LSTMExtractor(BaseFeaturesExtractor): def __init__(self, observation_space, features_dim: int = 32): super().__init__(observation_space, features_dim) shape = observation_space.shape self.lb = shape[0] if len(shape) == 2 else 1 self.nf = shape[1] if len(shape) == 2 else shape[0] self.l1 = nn.LSTM(self.nf, 256, batch_first=True) self.l2 = nn.LSTM(256, 128, batch_first=True) self.attn = _SelfAttn(128) self.mlp = nn.Sequential( nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, features_dim), nn.ReLU(), ) def forward(self, obs): x = obs.float() if x.dim() == 2: x = x.view(x.shape[0], self.lb, self.nf) out1, _ = self.l1(x) out2, _ = self.l2(out1) return self.mlp(self.attn(out2)[:, -1, :]) class SignalEnv(gym.Env): def __init__(self): self.observation_space = spaces.Box( -np.inf, np.inf, shape=(lookback, n_features + 1), dtype=np.float32, ) self.action_space = spaces.Box(-1.0, 1.0, shape=(1,), dtype=np.float32) def reset(self, *, seed=None, options=None): super().reset(seed=seed) return np.zeros(self.observation_space.shape, dtype=np.float32), {} def step(self, action): return self.reset()[0], 0.0, True, False, {} paths = {} for name in ("ppo", "sac", "a2c"): path, error = _download(f"models/{name}_final.zip") if error or path is None: raise RuntimeError(f"Could not download {name}_final.zip: {error}") paths[name] = path env = SignalEnv() policy_kwargs = { "features_extractor_class": LSTMExtractor, "features_extractor_kwargs": {"features_dim": 32}, "net_arch": dict(pi=[64, 32], vf=[64, 32]), } custom_objects = {"policy_kwargs": policy_kwargs} return { "ppo": PPO.load(str(paths["ppo"]), env=env, device="cpu", custom_objects=custom_objects), "sac": SAC.load(str(paths["sac"]), env=env, device="cpu"), "a2c": A2C.load(str(paths["a2c"]), env=env, device="cpu", custom_objects=custom_objects), } @st.cache_data(ttl=300, show_spinner=False) def generate_live_signal(symbol: str) -> dict[str, object]: schema, schema_error = load_feature_schema() if not schema: return {"ok": False, "note": f"Missing feature schema: {schema_error}"} try: raw = download_market_data(symbol) obs, price, lookback = build_live_observation(raw, schema, symbol) feature_count = len(schema["feature_cols"]) models = load_agent_models(feature_count, lookback) obs_batch = obs.astype(np.float32) actions = {} for name, model in models.items(): action, _ = model.predict(obs_batch, deterministic=True) actions[name] = float(np.clip(np.squeeze(action), -1.0, 1.0)) weights = load_ensemble_weights() if not weights: weights = {name: 1.0 / len(actions) for name in actions} total = sum(weights.get(name, 0.0) for name in actions) if total <= 0: weights = {name: 1.0 / len(actions) for name in actions} total = 1.0 signal = float(sum(actions[name] * weights.get(name, 0.0) for name in actions) / total) signal = float(np.clip(signal, -1.0, 1.0)) direction = "LONG" if signal > 0.15 else "SHORT" if signal < -0.15 else "FLAT" return { "ok": True, "signal": round(signal, 3), "direction": direction, "price": price, "actions": {name: round(value, 3) for name, value in actions.items()}, "weights": {name: round(float(weights.get(name, 0.0)), 3) for name in actions}, } except Exception as exc: return {"ok": False, "note": str(exc)} def metric_value(metrics: dict[str, float], key: str) -> float | None: value = metrics.get(key) if value is None: return None try: return float(value) except (TypeError, ValueError): return None def format_metric(key: str, value: float | None) -> str: if value is None or not np.isfinite(value): return "n/a" if any(part in key for part in PERCENT_KEY_PARTS): return f"{value * 100:+.2f}%" return f"{value:.4f}" def show_missing_artifacts(error: str | None = None) -> None: st.warning("No Kaggle training artifacts are available yet.") if error: with st.expander("Hub download details"): st.code(error) st.write("Expected files in the model repo:") st.dataframe( pd.DataFrame( [{"Path": path, "Purpose": purpose} for path, purpose in EXPECTED_ARTIFACTS.items()] ), use_container_width=True, hide_index=True, ) st.sidebar.title("Trading Agent") page = st.sidebar.radio("View", ["Overview", "Backtest Charts", "Live Signal", "Artifacts"]) st.sidebar.markdown("---") st.sidebar.markdown( f"Model repo: [{HF_MODEL_REPO}](https://huggingface.co/{HF_MODEL_REPO})\n\n" "Research and paper trading only. Not financial advice." ) if page == "Overview": st.title("Portfolio Overview") st.caption("Kaggle-trained PPO, SAC, and A2C ensemble artifacts served from Hugging Face Hub.") metrics, error = load_metrics() if not metrics: show_missing_artifacts(error) else: col1, col2, col3, col4 = st.columns(4) with col1: total_return = metric_value(metrics, "total_return") spy_return = metric_value(metrics, "benchmark_return_spy") st.metric("Total Return", format_metric("total_return", total_return), f"SPY {format_metric('return', spy_return)}") with col2: st.metric("Sharpe Ratio", format_metric("sharpe_ratio", metric_value(metrics, "sharpe_ratio"))) with col3: st.metric("Max Drawdown", format_metric("max_drawdown", metric_value(metrics, "max_drawdown"))) with col4: st.metric("Win Rate", format_metric("win_rate", metric_value(metrics, "win_rate"))) rows = [ {"Metric": key.replace("_", " ").title(), "Value": format_metric(key, metric_value(metrics, key))} for key in sorted(metrics) ] st.subheader("Backtest Metrics") st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True) st.divider() st.subheader("Deployment Links") st.markdown( f""" | Resource | Link | |---|---| | Kaggle Notebook | https://kaggle.com/code/rezvan998/trading-agent-rl | | HF Model Hub | https://huggingface.co/{HF_MODEL_REPO} | | HF Dashboard | https://huggingface.co/spaces/rezvan98/trading-dashboard | """ ) elif page == "Backtest Charts": st.title("Backtest Charts") bt, error = load_backtest_results() if bt is None or bt.empty: show_missing_artifacts(error) else: spy_df = load_spy_data() portfolio = bt["portfolio_value"].astype(float).to_numpy() idx = np.arange(len(portfolio)) fig = go.Figure() fig.add_trace( go.Scatter( x=idx, y=portfolio, name="Ensemble Agent", line=dict(color="#0ea5e9", width=2), ) ) if not spy_df.empty: spy = spy_df["Close"].to_numpy() spy_norm = spy[: len(portfolio)] / spy[0] * CAPITAL fig.add_trace( go.Scatter( x=np.arange(len(spy_norm)), y=spy_norm, name="SPY buy and hold", line=dict(color="#f97316", width=1.5, dash="dash"), ) ) fig.update_layout( template="plotly_white", height=430, xaxis_title="Trading day", yaxis_title="Portfolio value ($)", hovermode="x unified", margin=dict(l=0, r=0, t=20, b=0), ) st.plotly_chart(fig, use_container_width=True) col_dd, col_month = st.columns(2) with col_dd: peak = np.maximum.accumulate(portfolio) drawdown = (portfolio - peak) / (peak + 1e-9) * 100 fig_dd = go.Figure( go.Scatter( x=idx, y=drawdown, fill="tozeroy", line=dict(color="#dc2626"), fillcolor="rgba(220,38,38,0.18)", ) ) fig_dd.update_layout( template="plotly_white", height=320, title="Drawdown", xaxis_title="Day", yaxis_title="Drawdown (%)", margin=dict(l=0, r=0, t=40, b=0), ) st.plotly_chart(fig_dd, use_container_width=True) with col_month: pv_series = pd.Series( portfolio, index=pd.date_range(TEST_START, periods=len(portfolio), freq="B"), ) monthly = pv_series.resample("ME").last().pct_change().dropna() * 100 if len(monthly) < 2: st.info("Not enough monthly data yet.") else: month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] mdf = pd.DataFrame({"year": monthly.index.year, "month": monthly.index.month, "ret": monthly.values}) pivot = mdf.pivot(index="year", columns="month", values="ret") fig_month = go.Figure( go.Heatmap( z=pivot.values, x=[month_names[m - 1] for m in pivot.columns], y=pivot.index.astype(str), colorscale=[[0, "#dc2626"], [0.5, "#f8fafc"], [1, "#16a34a"]], zmid=0, text=np.round(pivot.values, 1), texttemplate="%{text:.1f}%", ) ) fig_month.update_layout( template="plotly_white", height=320, title="Monthly Returns (%)", margin=dict(l=0, r=0, t=40, b=0), ) st.plotly_chart(fig_month, use_container_width=True) elif page == "Live Signal": st.title("Live Signal") st.caption("Loads Kaggle-trained PPO, SAC, and A2C models from the Hub and runs CPU inference on fresh market data.") symbol = st.selectbox("Symbol", SYMBOLS, index=0) if st.button("Generate Signal", type="primary"): with st.spinner("Loading models and fetching latest market data..."): result = generate_live_signal(symbol) if not result.get("ok"): st.error("Model signal is not available yet.") st.write(str(result.get("note", "Unknown error"))) show_missing_artifacts() else: col1, col2, col3 = st.columns(3) col1.metric("Model Signal", f"{float(result['signal']):+.3f}") col2.metric("Direction", str(result.get("direction", "FLAT"))) col3.metric("Current Price", f"${float(result.get('price', 0.0)):,.2f}") fig_gauge = go.Figure( go.Indicator( mode="gauge+number", value=float(result["signal"]), gauge={ "axis": {"range": [-1, 1]}, "bar": {"color": "#0ea5e9"}, "steps": [ {"range": [-1, -0.15], "color": "#fee2e2"}, {"range": [-0.15, 0.15], "color": "#f1f5f9"}, {"range": [0.15, 1], "color": "#dcfce7"}, ], }, title={"text": "Model signal (-1 short, +1 long)"}, ) ) fig_gauge.update_layout(template="plotly_white", height=300) st.plotly_chart(fig_gauge, use_container_width=True) col_actions, col_weights = st.columns(2) with col_actions: st.subheader("Agent Actions") st.dataframe( pd.DataFrame( [{"Agent": name.upper(), "Action": value} for name, value in result["actions"].items()] ), use_container_width=True, hide_index=True, ) with col_weights: st.subheader("Ensemble Weights") st.dataframe( pd.DataFrame( [{"Agent": name.upper(), "Weight": value} for name, value in result["weights"].items()] ), use_container_width=True, hide_index=True, ) else: st.title("Artifacts") files, error = load_artifact_files() if error: st.warning("Could not list model repo files.") with st.expander("Hub list details"): st.code(error) rows = [] file_set = set(files) for path, purpose in EXPECTED_ARTIFACTS.items(): rows.append({"Path": path, "Purpose": purpose, "Status": "present" if path in file_set else "missing"}) st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True) if files: st.subheader("All Hub Files") st.dataframe(pd.DataFrame({"Path": files}), use_container_width=True, hide_index=True)