"""
ui/components.py
Reusable Streamlit UI blocks.
Changes:
- Metrics row: Ann Return compared vs SPY (not T-bill)
- Max Daily DD: shows date it happened
- Conviction panel: compact single-line ETF list (no big bars)
- applymap → map (deprecation fix)
"""
import streamlit as st
import pandas as pd
import numpy as np
from signals.conviction import conviction_color, conviction_icon
# ── Freshness status ──────────────────────────────────────────────────────────
def show_freshness_status(freshness: dict):
if freshness.get("fresh"):
st.success(freshness["message"])
else:
st.warning(freshness["message"])
# ── Winner signal banner ──────────────────────────────────────────────────────
def show_signal_banner(next_signal: str, next_date, approach_name: str):
is_cash = next_signal == "CASH"
bg = ("linear-gradient(135deg, #2d3436 0%, #1a1a2e 100%)" if is_cash
else "linear-gradient(135deg, #00d1b2 0%, #00a896 100%)")
label = ("⚠️ DRAWDOWN PROTECTION ACTIVE — CASH"
if is_cash else f"🎯 {next_date.strftime('%Y-%m-%d')} → {next_signal}")
st.markdown(f"""
{approach_name.upper()} · NEXT TRADING DAY SIGNAL
{label}
""", unsafe_allow_html=True)
# ── All models signals panel ──────────────────────────────────────────────────
def show_all_signals_panel(all_signals: dict, target_etfs: list,
include_cash: bool, next_date, optimal_lookback: int):
COLORS = {"Approach 1": "#00ffc8", "Approach 2": "#7c6aff", "Approach 3": "#ff6b6b"}
st.subheader(f"🗓️ All Models — {next_date.strftime('%Y-%m-%d')} Signals")
st.caption(f"📐 Lookback **{optimal_lookback}d** found optimal (auto-selected from 30 / 45 / 60d)")
cols = st.columns(len(all_signals))
for col, (name, info) in zip(cols, all_signals.items()):
color = COLORS.get(name, "#888")
signal = info["signal"]
top_prob = float(np.max(info["proba"])) * 100
badge = " ⭐" if info["is_winner"] else ""
sig_col = "#aaa" if signal == "CASH" else "white"
col.markdown(f"""
{name.upper()}{badge}
{signal}
Confidence: {top_prob:.1f}%
""", unsafe_allow_html=True)
def _build_etf_badges(sorted_pairs: list, best_name: str, color: str) -> str:
"""Build compact ETF probability badges as HTML string."""
badges = []
for name, score in sorted_pairs:
is_best = name == best_name
bg = "#e8fdf7" if is_best else "#f8f8f8"
border = color if is_best else "#ddd"
txt_col = color if is_best else "#555"
weight = "700" if is_best else "400"
star = "★ " if is_best else ""
badges.append(
f''
f'{star}{name} {score:.3f}'
)
return "".join(badges)
# ── Signal conviction panel ───────────────────────────────────────────────────
def show_conviction_panel(conviction: dict):
label = conviction["label"]
z_score = conviction["z_score"]
best_name = conviction["best_name"]
sorted_pairs = conviction["sorted_pairs"]
color = conviction_color(label)
icon = conviction_icon(label)
z_clipped = max(-3.0, min(3.0, z_score))
bar_pct = int((z_clipped + 3) / 6 * 100)
# ── Header with Z-score gauge ─────────────────────────────────────────────
st.markdown(f"""
{icon}
Signal Conviction
Z = {z_score:.2f} σ
{label}
Weak −3σNeutral 0σStrong +3σ
MODEL PROBABILITY BY ETF
{_build_etf_badges(sorted_pairs, best_name, color)}
""", unsafe_allow_html=True)
st.caption(
"Z-score = std deviations the top ETF's probability sits above the mean of all ETF probabilities. "
"Higher → model is more decisive. "
"⚠️ CASH override triggers if 2-day cumulative return ≤ −15%, exits when Z ≥ 1.0."
)
# ── Metrics row ───────────────────────────────────────────────────────────────
def show_metrics_row(result: dict, tbill_rate: float, spy_ann_return: float = None):
c1, c2, c3, c4, c5 = st.columns(5)
# Ann return vs SPY
if spy_ann_return is not None:
diff = (result['ann_return'] - spy_ann_return) * 100
sign = "+" if diff >= 0 else ""
delta_str = f"vs SPY: {sign}{diff:.2f}%"
else:
delta_str = f"vs T-bill: {(result['ann_return'] - tbill_rate)*100:.2f}%"
c1.metric("📈 Ann. Return", f"{result['ann_return']*100:.2f}%", delta=delta_str)
c2.metric("📊 Sharpe", f"{result['sharpe']:.2f}",
delta="Strong" if result['sharpe'] > 1 else "Weak")
c3.metric("🎯 Hit Ratio 15d", f"{result['hit_ratio']*100:.0f}%",
delta="Good" if result['hit_ratio'] > 0.55 else "Weak")
c4.metric("📉 Max Drawdown", f"{result['max_dd']*100:.2f}%",
delta="Peak to Trough")
# Max daily DD with date (only show date if available)
worst_date = result.get("max_daily_date", "N/A")
dd_delta = f"on {worst_date}" if worst_date != "N/A" else "Worst Single Day"
c5.metric("⚠️ Max Daily DD", f"{result['max_daily_dd']*100:.2f}%", delta=dd_delta)
# ── Comparison table ──────────────────────────────────────────────────────────
def show_comparison_table(comparison_df: pd.DataFrame):
def _highlight(row):
if "WINNER" in str(row.get("Winner", "")):
return ["background-color: rgba(0,200,150,0.15); font-weight:bold"] * len(row)
return [""] * len(row)
styled = (
comparison_df.style
.apply(_highlight, axis=1)
.set_properties(**{"text-align": "center", "font-size": "14px"})
.set_table_styles([
{"selector": "th", "props": [("font-size", "14px"),
("font-weight", "bold"),
("text-align", "center")]},
{"selector": "td", "props": [("padding", "10px")]},
])
)
st.dataframe(styled, use_container_width=True)
# ── Audit trail ───────────────────────────────────────────────────────────────
def show_audit_trail(audit_trail: list):
if not audit_trail:
st.info("No audit trail data available.")
return
df = pd.DataFrame(audit_trail).tail(20)
cols = [c for c in ["Date", "Signal", "Net_Return", "Z_Score"] if c in df.columns]
df = df[cols]
def _color_ret(val):
return ("color: #00c896; font-weight:bold" if val > 0
else "color: #ff4b4b; font-weight:bold")
fmt = {"Net_Return": "{:.2%}"}
if "Z_Score" in df.columns:
fmt["Z_Score"] = "{:.2f}"
styled = (
df.style
.map(_color_ret, subset=["Net_Return"])
.format(fmt)
.set_properties(**{"font-size": "14px", "text-align": "center"})
.set_table_styles([
{"selector": "th", "props": [("font-size", "14px"),
("font-weight", "bold"),
("text-align", "center")]},
{"selector": "td", "props": [("padding", "10px")]},
])
)
st.dataframe(styled, use_container_width=True, height=500)