feat: add theme module and fix Streamlit Cloud deployment

- Add tradingagents/ui/theme.py (design system: colors, CSS, Plotly templates)
- Add .streamlit/config.toml for dark theme configuration
- Fix Plotly duplicate keyword args in performance.py and todays_picks.py
- Replace deprecated use_container_width with width="stretch" (Streamlit 1.54+)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Youssef Aitousarrah 2026-02-10 22:40:08 -08:00
parent 8ebb42114d
commit 1ead4d9638
7 changed files with 751 additions and 61 deletions

7
.streamlit/config.toml Normal file
View File

@ -0,0 +1,7 @@
[theme]
base = "dark"
primaryColor = "#22c55e"
backgroundColor = "#0a0e17"
secondaryBackgroundColor = "#111827"
textColor = "#e2e8f0"
font = "monospace"

View File

@ -39,7 +39,7 @@ def render() -> None:
best_strat_name = "N/A"
best_strat_wr = 0.0
for item in (strategy_metrics or []):
for item in strategy_metrics or []:
wr = item.get("Win Rate", 0) or 0
if wr > best_strat_wr:
best_strat_wr = wr
@ -48,11 +48,25 @@ def render() -> None:
# ---- KPI Row ----
cols = st.columns(5)
kpis = [
("Win Rate 7d", f"{win_rate_7d:.0f}%", f"+{win_rate_7d - 50:.0f}pp vs 50%" if win_rate_7d >= 50 else f"{win_rate_7d - 50:.0f}pp vs 50%", "green" if win_rate_7d >= 50 else "red"),
(
"Win Rate 7d",
f"{win_rate_7d:.0f}%",
(
f"+{win_rate_7d - 50:.0f}pp vs 50%"
if win_rate_7d >= 50
else f"{win_rate_7d - 50:.0f}pp vs 50%"
),
"green" if win_rate_7d >= 50 else "red",
),
("Avg Return 7d", f"{avg_return_7d:+.2f}%", "", "green" if avg_return_7d > 0 else "red"),
("Open Positions", str(open_count), "", "blue"),
("Total Signals", str(total_recs), "", "amber"),
("Top Strategy", best_strat_name.upper(), f"{best_strat_wr:.0f}% WR" if best_strat_wr else "", "green" if best_strat_wr >= 60 else "amber"),
(
"Top Strategy",
best_strat_name.upper(),
f"{best_strat_wr:.0f}% WR" if best_strat_wr else "",
"green" if best_strat_wr >= 60 else "amber",
),
]
for col, (label, value, delta, color) in zip(cols, kpis):
with col:
@ -80,21 +94,46 @@ def render() -> None:
size="Count",
color="Strategy",
hover_name="Strategy",
hover_data={"Win Rate": ":.1f", "Avg Return": ":.2f", "Count": True, "Strategy": False},
hover_data={
"Win Rate": ":.1f",
"Avg Return": ":.2f",
"Count": True,
"Strategy": False,
},
labels={"Win Rate": "Win Rate (%)", "Avg Return": "Avg Return (%)"},
size_max=40,
)
fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4)
fig.add_vline(x=50, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4)
fig.add_annotation(x=75, y=5, text="WINNERS", showarrow=False, font=dict(size=10, color=COLORS["green"], family="JetBrains Mono"), opacity=0.3)
fig.add_annotation(x=25, y=-5, text="LOSERS", showarrow=False, font=dict(size=10, color=COLORS["red"], family="JetBrains Mono"), opacity=0.3)
fig.add_annotation(
x=75,
y=5,
text="WINNERS",
showarrow=False,
font=dict(size=10, color=COLORS["green"], family="JetBrains Mono"),
opacity=0.3,
)
fig.add_annotation(
x=25,
y=-5,
text="LOSERS",
showarrow=False,
font=dict(size=10, color=COLORS["red"], family="JetBrains Mono"),
opacity=0.3,
)
fig.update_layout(
**template,
height=380,
showlegend=True,
legend=dict(bgcolor="rgba(0,0,0,0)", font=dict(size=10), orientation="h", yanchor="bottom", y=-0.25),
legend=dict(
bgcolor="rgba(0,0,0,0)",
font=dict(size=10),
orientation="h",
yanchor="bottom",
y=-0.25,
),
)
st.plotly_chart(fig, width="stretch")
else:
@ -118,9 +157,17 @@ def render() -> None:
entry = rec.get("entry_price")
entry_str = f"${entry:.2f}" if entry else "N/A"
score_color = COLORS["green"] if score >= 35 else (COLORS["amber"] if score >= 20 else COLORS["text_muted"])
score_color = (
COLORS["green"]
if score >= 35
else (COLORS["amber"] if score >= 20 else COLORS["text_muted"])
)
conf_bar_w = conf * 10
conf_color = COLORS["green"] if conf >= 8 else (COLORS["amber"] if conf >= 6 else COLORS["red"])
conf_color = (
COLORS["green"]
if conf >= 8
else (COLORS["amber"] if conf >= 6 else COLORS["red"])
)
st.markdown(
f"""
@ -156,7 +203,9 @@ def render() -> None:
)
if len(recs) > 6:
st.caption(f"+{len(recs) - 6} more signals. Switch to Signals page for the full list.")
st.caption(
f"+{len(recs) - 6} more signals. Switch to Signals page for the full list."
)
else:
st.info("No signals generated today.")

View File

@ -11,18 +11,23 @@ import plotly.graph_objects as go
import streamlit as st
from tradingagents.ui.theme import COLORS, get_plotly_template, page_header
from tradingagents.ui.utils import load_performance_database, load_statistics, load_strategy_metrics
from tradingagents.ui.utils import load_statistics, load_strategy_metrics
def render() -> None:
"""Render the performance analytics page."""
st.markdown(page_header("Performance", "Strategy analytics & win/loss breakdown"), unsafe_allow_html=True)
st.markdown(
page_header("Performance", "Strategy analytics & win/loss breakdown"),
unsafe_allow_html=True,
)
strategy_metrics = load_strategy_metrics()
stats = load_statistics()
if not strategy_metrics:
st.warning("No performance data available yet. Run the discovery pipeline and track outcomes.")
st.warning(
"No performance data available yet. Run the discovery pipeline and track outcomes."
)
return
template = get_plotly_template()
@ -105,15 +110,17 @@ def render() -> None:
df_sorted = df.sort_values("Win Rate", ascending=True)
colors = [COLORS["green"] if wr >= 50 else COLORS["red"] for wr in df_sorted["Win Rate"]]
fig_bar = go.Figure(go.Bar(
x=df_sorted["Win Rate"],
y=df_sorted["Strategy"],
orientation="h",
marker_color=colors,
text=[f"{wr:.0f}%" for wr in df_sorted["Win Rate"]],
textposition="auto",
textfont=dict(family="JetBrains Mono", size=11, color=COLORS["text_primary"]),
))
fig_bar = go.Figure(
go.Bar(
x=df_sorted["Win Rate"],
y=df_sorted["Strategy"],
orientation="h",
marker_color=colors,
text=[f"{wr:.0f}%" for wr in df_sorted["Win Rate"]],
textposition="auto",
textfont=dict(family="JetBrains Mono", size=11, color=COLORS["text_primary"]),
)
)
fig_bar.add_vline(x=50, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.5)
@ -158,16 +165,22 @@ def render() -> None:
by_strat = stats["by_strategy"]
rows = []
for strat_name, data in by_strat.items():
rows.append({
"Strategy": strat_name,
"Count": data.get("count", 0),
"Win Rate 1d": f"{data.get('win_rate_1d', 0):.0f}%" if "win_rate_1d" in data else "N/A",
"Win Rate 7d": f"{data.get('win_rate_7d', 0):.0f}%" if "win_rate_7d" in data else "N/A",
"Wins 1d": data.get("wins_1d", 0),
"Losses 1d": data.get("losses_1d", 0),
"Wins 7d": data.get("wins_7d", 0),
"Losses 7d": data.get("losses_7d", 0),
})
rows.append(
{
"Strategy": strat_name,
"Count": data.get("count", 0),
"Win Rate 1d": (
f"{data.get('win_rate_1d', 0):.0f}%" if "win_rate_1d" in data else "N/A"
),
"Win Rate 7d": (
f"{data.get('win_rate_7d', 0):.0f}%" if "win_rate_7d" in data else "N/A"
),
"Wins 1d": data.get("wins_1d", 0),
"Losses 1d": data.get("losses_1d", 0),
"Wins 7d": data.get("wins_7d", 0),
"Losses 7d": data.get("losses_7d", 0),
}
)
if rows:
st.dataframe(pd.DataFrame(rows), width="stretch", hide_index=True)

View File

@ -30,19 +30,23 @@ def render():
st.write("")
if st.button("Add Position"):
if ticker and entry_price > 0 and shares > 0:
from tradingagents.dataflows.discovery.performance.position_tracker import PositionTracker
from tradingagents.dataflows.discovery.performance.position_tracker import (
PositionTracker,
)
tracker = PositionTracker()
pos = tracker.create_position({
"ticker": ticker.upper(),
"entry_price": entry_price,
"shares": shares,
"recommendation_date": datetime.now().isoformat(),
"pipeline": "manual",
"scanner": "manual",
"strategy_match": "manual",
"confidence": 5,
})
pos = tracker.create_position(
{
"ticker": ticker.upper(),
"entry_price": entry_price,
"shares": shares,
"recommendation_date": datetime.now().isoformat(),
"pipeline": "manual",
"scanner": "manual",
"strategy_match": "manual",
"confidence": 5,
}
)
tracker.save_position(pos)
st.success(f"Added {ticker.upper()}")
st.rerun()
@ -74,7 +78,12 @@ def render():
summary_kpis = [
("Invested", f"${total_invested:,.0f}", "", "blue"),
("Current Value", f"${total_current:,.0f}", "", "blue"),
("P/L", f"${total_pnl:+,.0f}", f"{total_pnl_pct:+.1f}%", "green" if total_pnl >= 0 else "red"),
(
"P/L",
f"${total_pnl:+,.0f}",
f"{total_pnl_pct:+.1f}%",
"green" if total_pnl >= 0 else "red",
),
("Positions", str(len(positions)), "", "amber"),
]
for col, (label, value, delta, color) in zip(cols, summary_kpis):
@ -145,15 +154,17 @@ def render():
data = []
for p in positions:
pnl = (p["metrics"]["current_price"] - p["entry_price"]) * p.get("shares", 0)
data.append({
"Ticker": p["ticker"],
"Entry": p["entry_price"],
"Current": p["metrics"]["current_price"],
"Shares": p.get("shares", 0),
"P/L": pnl,
"P/L %": p["metrics"]["current_return"],
"Days": p["metrics"]["days_held"],
})
data.append(
{
"Ticker": p["ticker"],
"Entry": p["entry_price"],
"Current": p["metrics"]["current_price"],
"Shares": p.get("shares", 0),
"P/L": pnl,
"P/L %": p["metrics"]["current_return"],
"Days": p["metrics"]["days_held"],
}
)
st.dataframe(
pd.DataFrame(data),
width="stretch",

View File

@ -12,7 +12,10 @@ from tradingagents.ui.theme import COLORS, page_header
def render() -> None:
"""Render the configuration page."""
st.markdown(page_header("Config", "Pipeline & scanner configuration (read-only)"), unsafe_allow_html=True)
st.markdown(
page_header("Config", "Pipeline & scanner configuration (read-only)"),
unsafe_allow_html=True,
)
config = DEFAULT_CONFIG
discovery_config = config.get("discovery", {})

View File

@ -23,7 +23,11 @@ def _load_price_history(ticker: str, period: str) -> pd.DataFrame:
return pd.DataFrame()
data = download_history(
ticker, period=period, interval="1d", auto_adjust=True, progress=False,
ticker,
period=period,
interval="1d",
auto_adjust=True,
progress=False,
)
if data is None or data.empty:
return pd.DataFrame()
@ -62,9 +66,11 @@ def render():
# ---- Controls row ----
ctrl_cols = st.columns([1, 1, 1, 1])
with ctrl_cols[0]:
pipelines = sorted(set(
(r.get("pipeline") or r.get("strategy_match") or "unknown") for r in recommendations
))
pipelines = sorted(
set(
(r.get("pipeline") or r.get("strategy_match") or "unknown") for r in recommendations
)
)
pipeline_filter = st.multiselect("Strategy", pipelines, default=pipelines)
with ctrl_cols[1]:
min_confidence = st.slider("Min Confidence", 1, 10, 1)
@ -79,7 +85,8 @@ def render():
# Apply filters
filtered = [
r for r in recommendations
r
for r in recommendations
if (r.get("pipeline") or r.get("strategy_match") or "unknown") in pipeline_filter
and r.get("confidence", 0) >= min_confidence
and r.get("final_score", 0) >= min_score
@ -130,7 +137,9 @@ def render():
history = _load_price_history(ticker, chart_window)
if not history.empty:
template = get_plotly_template()
fig = px.line(history, x="date", y="close", labels={"date": "", "close": "Price"})
fig = px.line(
history, x="date", y="close", labels={"date": "", "close": "Price"}
)
# Color line green if trending up, red if down
first_close = history["close"].iloc[0]
@ -145,7 +154,9 @@ def render():
)
fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
fig.update_xaxes(showticklabels=False, showgrid=False)
fig.update_yaxes(showgrid=True, gridcolor="rgba(42,53,72,0.3)", tickprefix="$")
fig.update_yaxes(
showgrid=True, gridcolor="rgba(42,53,72,0.3)", tickprefix="$"
)
st.plotly_chart(fig, width="stretch")
# Action buttons

596
tradingagents/ui/theme.py Normal file
View File

@ -0,0 +1,596 @@
"""
Trading terminal dark theme for the Streamlit dashboard.
Bloomberg/TradingView-inspired aesthetic with green/amber accents.
Uses CSS variables for consistency and injects custom fonts.
"""
# -- Color Tokens --
COLORS = {
"bg_primary": "#0a0e17",
"bg_secondary": "#111827",
"bg_card": "#1a2234",
"bg_card_hover": "#1f2b42",
"bg_input": "#151d2e",
"border": "#2a3548",
"border_active": "#3b82f6",
"text_primary": "#e2e8f0",
"text_secondary": "#94a3b8",
"text_muted": "#64748b",
"green": "#22c55e",
"green_dim": "#16a34a",
"green_glow": "rgba(34, 197, 94, 0.15)",
"red": "#ef4444",
"red_dim": "#dc2626",
"red_glow": "rgba(239, 68, 68, 0.15)",
"amber": "#f59e0b",
"amber_dim": "#d97706",
"blue": "#3b82f6",
"blue_dim": "#2563eb",
"cyan": "#06b6d4",
"purple": "#a855f7",
}
def get_plotly_template():
"""Return a Plotly layout template matching the terminal theme."""
return dict(
paper_bgcolor=COLORS["bg_card"],
plot_bgcolor=COLORS["bg_card"],
font=dict(
family="JetBrains Mono, SF Mono, Menlo, monospace",
color=COLORS["text_secondary"],
size=11,
),
xaxis=dict(
gridcolor="rgba(42, 53, 72, 0.5)",
zerolinecolor=COLORS["border"],
showgrid=True,
gridwidth=1,
),
yaxis=dict(
gridcolor="rgba(42, 53, 72, 0.5)",
zerolinecolor=COLORS["border"],
showgrid=True,
gridwidth=1,
),
margin=dict(l=0, r=0, t=32, b=0),
hoverlabel=dict(
bgcolor=COLORS["bg_secondary"],
font_color=COLORS["text_primary"],
bordercolor=COLORS["border"],
),
colorway=[
COLORS["green"],
COLORS["blue"],
COLORS["amber"],
COLORS["cyan"],
COLORS["purple"],
COLORS["red"],
],
)
GLOBAL_CSS = f"""
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap');
/* ---- Root overrides ---- */
:root {{
--bg-primary: {COLORS["bg_primary"]};
--bg-secondary: {COLORS["bg_secondary"]};
--bg-card: {COLORS["bg_card"]};
--border: {COLORS["border"]};
--text-primary: {COLORS["text_primary"]};
--text-secondary: {COLORS["text_secondary"]};
--green: {COLORS["green"]};
--red: {COLORS["red"]};
--amber: {COLORS["amber"]};
--blue: {COLORS["blue"]};
}}
/* ---- Global ---- */
.stApp {{
background-color: var(--bg-primary) !important;
color: var(--text-primary);
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
}}
/* Hide default Streamlit chrome */
header[data-testid="stHeader"] {{
background-color: var(--bg-primary) !important;
border-bottom: 1px solid var(--border);
}}
/* ---- Sidebar ---- */
section[data-testid="stSidebar"] {{
background-color: var(--bg-secondary) !important;
border-right: 1px solid var(--border);
}}
section[data-testid="stSidebar"] .stRadio label {{
color: var(--text-secondary) !important;
font-family: 'DM Sans', sans-serif;
font-weight: 500;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: all 0.15s ease;
}}
section[data-testid="stSidebar"] .stRadio label:hover {{
background-color: var(--bg-card) !important;
color: var(--text-primary) !important;
}}
section[data-testid="stSidebar"] .stRadio label[data-checked="true"],
section[data-testid="stSidebar"] .stRadio div[role="radiogroup"] label:has(input:checked) {{
background-color: var(--bg-card) !important;
color: var(--green) !important;
border-left: 3px solid var(--green);
}}
/* ---- Metric cards ---- */
div[data-testid="stMetric"] {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
}}
div[data-testid="stMetric"] label {{
color: var(--text-secondary) !important;
font-family: 'DM Sans', sans-serif;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
div[data-testid="stMetric"] div[data-testid="stMetricValue"] {{
color: var(--text-primary) !important;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}}
div[data-testid="stMetric"] div[data-testid="stMetricDelta"] svg {{
display: none;
}}
div[data-testid="stMetric"] div[data-testid="stMetricDelta"] > div {{
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}}
/* ---- Custom KPI card classes ---- */
.kpi-card {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
position: relative;
overflow: hidden;
}}
.kpi-card::before {{
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 10px 10px 0 0;
}}
.kpi-card.green::before {{ background: var(--green); }}
.kpi-card.red::before {{ background: var(--red); }}
.kpi-card.amber::before {{ background: var(--amber); }}
.kpi-card.blue::before {{ background: var(--blue); }}
.kpi-label {{
color: var(--text-secondary);
font-family: 'DM Sans', sans-serif;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.3rem;
}}
.kpi-value {{
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
}}
.kpi-delta {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
font-weight: 500;
margin-top: 0.25rem;
}}
.kpi-delta.positive {{ color: var(--green); }}
.kpi-delta.negative {{ color: var(--red); }}
.kpi-delta.neutral {{ color: var(--text-muted); }}
/* ---- Signal card (recommendation) ---- */
.signal-card {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 0.75rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}}
.signal-card:hover {{
border-color: {COLORS["border_active"]};
box-shadow: 0 0 0 1px {COLORS["border_active"]};
}}
.signal-header {{
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}}
.signal-ticker {{
font-family: 'JetBrains Mono', monospace;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}}
.signal-rank {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-secondary);
padding: 0.2rem 0.6rem;
border-radius: 4px;
}}
.signal-badges {{
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}}
.badge {{
font-family: 'DM Sans', sans-serif;
font-size: 0.65rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}}
.badge-green {{
background: {COLORS["green_glow"]};
color: var(--green);
border: 1px solid {COLORS["green_dim"]};
}}
.badge-red {{
background: {COLORS["red_glow"]};
color: var(--red);
border: 1px solid {COLORS["red_dim"]};
}}
.badge-amber {{
background: rgba(245, 158, 11, 0.15);
color: var(--amber);
border: 1px solid {COLORS["amber_dim"]};
}}
.badge-blue {{
background: rgba(59, 130, 246, 0.15);
color: var(--blue);
border: 1px solid {COLORS["blue_dim"]};
}}
.badge-muted {{
background: rgba(100, 116, 139, 0.15);
color: var(--text-secondary);
border: 1px solid {COLORS["border"]};
}}
.signal-metrics {{
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}}
.signal-metric {{
text-align: center;
}}
.signal-metric-label {{
font-size: 0.6rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-family: 'DM Sans', sans-serif;
font-weight: 600;
}}
.signal-metric-value {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin-top: 0.15rem;
}}
.signal-thesis {{
font-family: 'DM Sans', sans-serif;
font-size: 0.82rem;
line-height: 1.55;
color: var(--text-secondary);
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 6px;
border-left: 3px solid var(--border);
}}
/* ---- Confidence bar ---- */
.conf-bar {{
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-top: 0.5rem;
}}
.conf-fill {{
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}}
/* ---- Strategy tag colors ---- */
.strat-momentum {{ border-left-color: var(--green) !important; }}
.strat-insider {{ border-left-color: var(--amber) !important; }}
.strat-earnings {{ border-left-color: var(--blue) !important; }}
.strat-volume {{ border-left-color: {COLORS["cyan"]} !important; }}
.strat-options {{ border-left-color: {COLORS["purple"]} !important; }}
/* ---- Table styling ---- */
.stDataFrame {{
background: var(--bg-card) !important;
border: 1px solid var(--border);
border-radius: 8px;
}}
div[data-testid="stDataFrame"] table {{
font-family: 'JetBrains Mono', monospace !important;
font-size: 0.8rem !important;
}}
div[data-testid="stDataFrame"] th {{
background: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
font-family: 'DM Sans', sans-serif !important;
font-weight: 600 !important;
text-transform: uppercase;
font-size: 0.7rem !important;
letter-spacing: 0.05em;
}}
/* ---- Expander ---- */
.streamlit-expanderHeader {{
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
font-family: 'DM Sans', sans-serif !important;
font-weight: 600;
color: var(--text-primary) !important;
}}
/* ---- Buttons ---- */
.stButton > button {{
font-family: 'DM Sans', sans-serif;
font-weight: 600;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
transition: all 0.15s ease;
}}
.stButton > button:hover {{
border-color: var(--green);
color: var(--green);
background: {COLORS["green_glow"]};
}}
/* ---- Divider ---- */
hr {{
border-color: var(--border) !important;
opacity: 0.5;
}}
/* ---- Subheader ---- */
.stMarkdown h2, .stMarkdown h3 {{
font-family: 'DM Sans', sans-serif !important;
color: var(--text-primary) !important;
font-weight: 700;
letter-spacing: -0.02em;
}}
/* ---- Selectbox & slider ---- */
div[data-baseweb="select"] {{
font-family: 'JetBrains Mono', monospace;
}}
/* ---- Scrollbar ---- */
::-webkit-scrollbar {{
width: 6px;
height: 6px;
}}
::-webkit-scrollbar-track {{
background: var(--bg-primary);
}}
::-webkit-scrollbar-thumb {{
background: var(--border);
border-radius: 3px;
}}
::-webkit-scrollbar-thumb:hover {{
background: {COLORS["text_muted"]};
}}
/* ---- Section title with mono accent ---- */
.section-title {{
font-family: 'DM Sans', sans-serif;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}}
.section-title .accent {{
font-family: 'JetBrains Mono', monospace;
color: var(--green);
font-size: 0.85rem;
}}
/* ---- Page header ---- */
.page-header {{
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}}
.page-header h1 {{
font-family: 'DM Sans', sans-serif;
font-weight: 700;
font-size: 1.6rem;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.02em;
}}
.page-header .subtitle {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}}
/* ---- Position row ---- */
.pos-row {{
display: grid;
grid-template-columns: 80px 1fr repeat(5, 90px);
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
}}
.pos-ticker {{
font-weight: 700;
color: var(--text-primary);
font-size: 0.95rem;
}}
</style>
"""
def kpi_card(label: str, value: str, delta: str = "", color: str = "blue") -> str:
"""Render a custom KPI card as HTML."""
delta_class = (
"positive"
if delta.startswith("+")
else ("negative" if delta.startswith("-") else "neutral")
)
delta_html = f'<div class="kpi-delta {delta_class}">{delta}</div>' if delta else ""
return f"""
<div class="kpi-card {color}">
<div class="kpi-label">{label}</div>
<div class="kpi-value">{value}</div>
{delta_html}
</div>
"""
def page_header(title: str, subtitle: str = "") -> str:
"""Render a page header as HTML."""
sub = f'<div class="subtitle">{subtitle}</div>' if subtitle else ""
return f"""
<div class="page-header">
<h1>{title}</h1>
{sub}
</div>
"""
def signal_card(
rank: int,
ticker: str,
score: int,
confidence: int,
strategy: str,
entry_price: float,
reason: str,
) -> str:
"""Render a recommendation signal card as HTML."""
# Confidence bar color
if confidence >= 8:
bar_color = COLORS["green"]
elif confidence >= 6:
bar_color = COLORS["amber"]
else:
bar_color = COLORS["red"]
# Score badge color
if score >= 40:
score_badge = "badge-green"
elif score >= 25:
score_badge = "badge-amber"
else:
score_badge = "badge-muted"
# Strategy badge
strat_badge = "badge-blue"
strat_css = ""
strat_lower = strategy.lower().replace(" ", "_")
if "momentum" in strat_lower:
strat_badge = "badge-green"
strat_css = "strat-momentum"
elif "insider" in strat_lower:
strat_badge = "badge-amber"
strat_css = "strat-insider"
elif "earnings" in strat_lower:
strat_badge = "badge-blue"
strat_css = "strat-earnings"
elif "volume" in strat_lower:
strat_badge = "badge-blue"
strat_css = "strat-volume"
entry_str = f"${entry_price:.2f}" if entry_price else "N/A"
conf_pct = confidence * 10
return f"""
<div class="signal-card {strat_css}">
<div class="signal-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
<span class="signal-ticker">{ticker}</span>
<span class="signal-rank">#{rank}</span>
</div>
</div>
<div class="signal-badges">
<span class="badge {strat_badge}">{strategy}</span>
<span class="badge {score_badge}">Score {score}</span>
<span class="badge badge-muted">Conf {confidence}/10</span>
</div>
<div class="signal-metrics">
<div class="signal-metric">
<div class="signal-metric-label">Entry</div>
<div class="signal-metric-value">{entry_str}</div>
</div>
<div class="signal-metric">
<div class="signal-metric-label">Score</div>
<div class="signal-metric-value">{score}</div>
</div>
<div class="signal-metric">
<div class="signal-metric-label">Confidence</div>
<div class="signal-metric-value">{confidence}/10</div>
</div>
<div class="signal-metric">
<div class="signal-metric-label">Strategy</div>
<div class="signal-metric-value" style="font-size:0.75rem;">{strategy.upper()}</div>
</div>
</div>
<div class="signal-thesis">{reason}</div>
<div class="conf-bar">
<div class="conf-fill" style="width:{conf_pct}%;background:{bar_color};"></div>
</div>
</div>
"""
def pnl_color(value: float) -> str:
"""Return green/red CSS color based on sign."""
if value > 0:
return COLORS["green"]
elif value < 0:
return COLORS["red"]
return COLORS["text_muted"]