TradingAgents/tradingagents/ui/theme.py

693 lines
19 KiB
Python

"""
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.
"""
import html as _html
# -- 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; }}
/* ---- Company brief panel ---- */
.company-brief {{
margin: 0.45rem 0 0.7rem;
padding: 0.45rem 0.75rem 0.45rem 0.85rem;
border-left: 2px solid rgba(100, 116, 139, 0.35);
background: rgba(10, 14, 23, 0.45);
border-radius: 0 5px 5px 0;
position: relative;
}}
.company-brief-label {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.57rem;
font-weight: 600;
color: {COLORS["text_muted"]};
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 0.22rem;
display: flex;
align-items: center;
gap: 0.35rem;
}}
.company-brief-label::before {{
content: '//';
color: {COLORS["green"]};
font-weight: 700;
font-size: 0.6rem;
opacity: 0.7;
}}
.company-brief-text {{
font-family: 'DM Sans', sans-serif;
font-size: 0.79rem;
line-height: 1.52;
color: {COLORS["text_secondary"]};
font-style: italic;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}}
/* ---- 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,
company_name: str = "",
description: str = "",
risk_level: 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
# Escape all LLM-generated text to prevent HTML injection / rendering breaks
safe_reason = _html.escape(reason)
safe_company = _html.escape(company_name)
safe_desc = _html.escape(description)
safe_strategy = _html.escape(strategy)
safe_risk = _html.escape(risk_level)
# Risk level badge (built after escaping)
risk_badge_html = ""
if safe_risk:
risk_lower = safe_risk.lower()
if risk_lower == "low":
risk_badge_html = f'<span class="badge badge-green">{safe_risk.title()}</span>'
elif risk_lower == "moderate":
risk_badge_html = f'<span class="badge badge-blue">{safe_risk.title()}</span>'
elif risk_lower == "high":
risk_badge_html = f'<span class="badge badge-amber">{safe_risk.title()}</span>'
elif risk_lower == "speculative":
risk_badge_html = f'<span class="badge badge-muted" style="border-color:{COLORS["red"]};">{safe_risk.title()}</span>'
name_html = (
f'<span style="font-size:0.8rem;color:{COLORS["text_muted"]};font-weight:400;">{safe_company}</span>'
if company_name and company_name != ticker
else ""
)
desc_html = (
f'<div class="company-brief">'
f'<div class="company-brief-label">Company</div>'
f'<div class="company-brief-text">{safe_desc}</div>'
f"</div>"
if description
else ""
)
safe_risk_title = safe_risk.title() if safe_risk else ""
# Build HTML piece-by-piece to avoid blank/whitespace-only lines.
# CommonMark terminates a <div> HTML block at the FIRST blank line, so any
# empty interpolation (name_html, desc_html, risk_badge_html = "") would leave
# a whitespace-only line that makes Streamlit's markdown parser stop treating
# subsequent content as HTML and render it as literal text instead.
parts = [
f'<div class="signal-card {strat_css}">',
'<div class="signal-header">',
'<div style="display:flex;align-items:center;gap:0.75rem;">',
f'<span class="signal-ticker">{ticker}</span>',
f'<span class="signal-rank">#{rank}</span>',
]
if name_html:
parts.append(name_html)
parts += ["</div>", "</div>"]
if desc_html:
parts.append(desc_html)
parts += [
'<div class="signal-badges">',
f'<span class="badge {strat_badge}">{safe_strategy}</span>',
f'<span class="badge {score_badge}">Score {score}</span>',
f'<span class="badge badge-muted">Conf {confidence}/10</span>',
]
if risk_badge_html:
parts.append(risk_badge_html)
parts += [
"</div>",
'<div class="signal-metrics">',
'<div class="signal-metric">',
'<div class="signal-metric-label">Entry</div>',
f'<div class="signal-metric-value">{entry_str}</div>',
"</div>",
'<div class="signal-metric">',
'<div class="signal-metric-label">Score</div>',
f'<div class="signal-metric-value">{score}/100</div>',
"</div>",
'<div class="signal-metric">',
'<div class="signal-metric-label">Confidence</div>',
f'<div class="signal-metric-value">{confidence}/10</div>',
"</div>",
'<div class="signal-metric">',
'<div class="signal-metric-label">Risk</div>',
f'<div class="signal-metric-value" style="font-size:0.8rem;">{safe_risk_title}</div>',
"</div>",
"</div>",
f'<div class="signal-thesis">{safe_reason}</div>',
'<div class="conf-bar">',
f'<div class="conf-fill" style="width:{conf_pct}%;background:{bar_color};"></div>',
"</div>",
"</div>",
]
return "\n".join(parts)
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"]