| | """ |
| | 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 |
| |
|
| |
|
| | |
| |
|
| | def show_freshness_status(freshness: dict): |
| | if freshness.get("fresh"): |
| | st.success(freshness["message"]) |
| | else: |
| | st.warning(freshness["message"]) |
| |
|
| |
|
| | |
| |
|
| | 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""" |
| | <div style="background:{bg}; padding:25px; border-radius:15px; |
| | text-align:center; box-shadow:0 8px 16px rgba(0,0,0,0.3); margin:16px 0;"> |
| | <div style="color:rgba(255,255,255,0.7); font-size:12px; |
| | letter-spacing:3px; margin-bottom:6px;"> |
| | {approach_name.upper()} Β· NEXT TRADING DAY SIGNAL |
| | </div> |
| | <h1 style="color:white; font-size:40px; margin:0; font-weight:800; |
| | text-shadow:2px 2px 4px rgba(0,0,0,0.3);"> |
| | {label} |
| | </h1> |
| | </div> |
| | """, unsafe_allow_html=True) |
| |
|
| |
|
| | |
| |
|
| | 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""" |
| | <div style="border:2px solid {color}; border-radius:12px; padding:18px 16px; |
| | background:#111118; text-align:center; margin-bottom:8px;"> |
| | <div style="color:{color}; font-size:10px; font-weight:700; |
| | letter-spacing:2px; margin-bottom:6px;">{name.upper()}{badge}</div> |
| | <div style="color:{sig_col}; font-size:30px; font-weight:800; margin:8px 0;">{signal}</div> |
| | <div style="color:#aaa; font-size:12px;"> |
| | Confidence: <span style="color:{color}; font-weight:700;">{top_prob:.1f}%</span> |
| | </div> |
| | </div> |
| | """, 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'<span style="background:{bg}; border:1px solid {border}; ' |
| | f'border-radius:6px; padding:4px 10px; font-size:13px; ' |
| | f'color:{txt_col}; font-weight:{weight};">' |
| | f'{star}{name} {score:.3f}</span>' |
| | ) |
| | return "".join(badges) |
| |
|
| |
|
| | |
| |
|
| | 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) |
| |
|
| | |
| | st.markdown(f""" |
| | <div style="background:#ffffff; border:1px solid #ddd; |
| | border-left:5px solid {color}; border-radius:12px; |
| | padding:18px 24px 16px 24px; margin:12px 0; |
| | box-shadow:0 2px 8px rgba(0,0,0,0.07);"> |
| | <div style="display:flex; align-items:center; gap:12px; margin-bottom:14px; flex-wrap:wrap;"> |
| | <span style="font-size:20px;">{icon}</span> |
| | <span style="font-size:18px; font-weight:700; color:#1a1a1a;">Signal Conviction</span> |
| | <span style="background:#f0f0f0; border:1px solid {color}; color:{color}; |
| | font-weight:700; font-size:14px; padding:3px 12px; border-radius:8px;"> |
| | Z = {z_score:.2f} σ |
| | </span> |
| | <span style="margin-left:auto; background:{color}; color:#fff; |
| | font-weight:700; padding:4px 16px; border-radius:20px; font-size:13px;"> |
| | {label} |
| | </span> |
| | </div> |
| | <div style="display:flex; justify-content:space-between; |
| | font-size:11px; color:#999; margin-bottom:4px;"> |
| | <span>Weak −3σ</span><span>Neutral 0σ</span><span>Strong +3σ</span> |
| | </div> |
| | <div style="background:#f0f0f0; border-radius:8px; height:10px; overflow:hidden; |
| | position:relative; border:1px solid #e0e0e0; margin-bottom:16px;"> |
| | <div style="position:absolute; left:50%; top:0; width:2px; height:100%; background:#ccc;"></div> |
| | <div style="width:{bar_pct}%; height:100%; |
| | background:linear-gradient(90deg,#fab1a0,{color}); border-radius:8px;"></div> |
| | </div> |
| | <div style="font-size:11px; color:#999; margin-bottom:8px; font-weight:600; letter-spacing:1px;"> |
| | MODEL PROBABILITY BY ETF |
| | </div> |
| | <div style="display:flex; flex-wrap:wrap; gap:8px;"> |
| | {_build_etf_badges(sorted_pairs, best_name, color)} |
| | </div> |
| | </div> |
| | """, 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." |
| | ) |
| |
|
| |
|
| | |
| |
|
| | def show_metrics_row(result: dict, tbill_rate: float, spy_ann_return: float = None): |
| | c1, c2, c3, c4, c5 = st.columns(5) |
| |
|
| | |
| | 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") |
| |
|
| | |
| | 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) |
| |
|
| |
|
| | |
| |
|
| | 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) |
| |
|
| |
|
| | |
| |
|
| | 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) |
| |
|