Spaces:
Running
Running
| """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) | |
| 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) | |
| 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) | |
| 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) | |
| 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 {} | |
| 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) | |
| 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 | |
| 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 | |
| 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), | |
| } | |
| 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) | |