rezvan98's picture
Remove pandas-ta dependency from Space
c68e576 verified
Raw
History Blame Contribute Delete
26 kB
"""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)