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:
parent
8ebb42114d
commit
1ead4d9638
|
|
@ -0,0 +1,7 @@
|
||||||
|
[theme]
|
||||||
|
base = "dark"
|
||||||
|
primaryColor = "#22c55e"
|
||||||
|
backgroundColor = "#0a0e17"
|
||||||
|
secondaryBackgroundColor = "#111827"
|
||||||
|
textColor = "#e2e8f0"
|
||||||
|
font = "monospace"
|
||||||
|
|
@ -39,7 +39,7 @@ def render() -> None:
|
||||||
|
|
||||||
best_strat_name = "N/A"
|
best_strat_name = "N/A"
|
||||||
best_strat_wr = 0.0
|
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
|
wr = item.get("Win Rate", 0) or 0
|
||||||
if wr > best_strat_wr:
|
if wr > best_strat_wr:
|
||||||
best_strat_wr = wr
|
best_strat_wr = wr
|
||||||
|
|
@ -48,11 +48,25 @@ def render() -> None:
|
||||||
# ---- KPI Row ----
|
# ---- KPI Row ----
|
||||||
cols = st.columns(5)
|
cols = st.columns(5)
|
||||||
kpis = [
|
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"),
|
("Avg Return 7d", f"{avg_return_7d:+.2f}%", "", "green" if avg_return_7d > 0 else "red"),
|
||||||
("Open Positions", str(open_count), "", "blue"),
|
("Open Positions", str(open_count), "", "blue"),
|
||||||
("Total Signals", str(total_recs), "", "amber"),
|
("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):
|
for col, (label, value, delta, color) in zip(cols, kpis):
|
||||||
with col:
|
with col:
|
||||||
|
|
@ -80,21 +94,46 @@ def render() -> None:
|
||||||
size="Count",
|
size="Count",
|
||||||
color="Strategy",
|
color="Strategy",
|
||||||
hover_name="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 (%)"},
|
labels={"Win Rate": "Win Rate (%)", "Avg Return": "Avg Return (%)"},
|
||||||
size_max=40,
|
size_max=40,
|
||||||
)
|
)
|
||||||
|
|
||||||
fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4)
|
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_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(
|
||||||
fig.add_annotation(x=25, y=-5, text="LOSERS", showarrow=False, font=dict(size=10, color=COLORS["red"], family="JetBrains Mono"), opacity=0.3)
|
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(
|
fig.update_layout(
|
||||||
**template,
|
**template,
|
||||||
height=380,
|
height=380,
|
||||||
showlegend=True,
|
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")
|
st.plotly_chart(fig, width="stretch")
|
||||||
else:
|
else:
|
||||||
|
|
@ -118,9 +157,17 @@ def render() -> None:
|
||||||
entry = rec.get("entry_price")
|
entry = rec.get("entry_price")
|
||||||
entry_str = f"${entry:.2f}" if entry else "N/A"
|
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_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(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -156,7 +203,9 @@ def render() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(recs) > 6:
|
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:
|
else:
|
||||||
st.info("No signals generated today.")
|
st.info("No signals generated today.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,23 @@ import plotly.graph_objects as go
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from tradingagents.ui.theme import COLORS, get_plotly_template, page_header
|
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:
|
def render() -> None:
|
||||||
"""Render the performance analytics page."""
|
"""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()
|
strategy_metrics = load_strategy_metrics()
|
||||||
stats = load_statistics()
|
stats = load_statistics()
|
||||||
|
|
||||||
if not strategy_metrics:
|
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
|
return
|
||||||
|
|
||||||
template = get_plotly_template()
|
template = get_plotly_template()
|
||||||
|
|
@ -105,15 +110,17 @@ def render() -> None:
|
||||||
df_sorted = df.sort_values("Win Rate", ascending=True)
|
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"]]
|
colors = [COLORS["green"] if wr >= 50 else COLORS["red"] for wr in df_sorted["Win Rate"]]
|
||||||
|
|
||||||
fig_bar = go.Figure(go.Bar(
|
fig_bar = go.Figure(
|
||||||
x=df_sorted["Win Rate"],
|
go.Bar(
|
||||||
y=df_sorted["Strategy"],
|
x=df_sorted["Win Rate"],
|
||||||
orientation="h",
|
y=df_sorted["Strategy"],
|
||||||
marker_color=colors,
|
orientation="h",
|
||||||
text=[f"{wr:.0f}%" for wr in df_sorted["Win Rate"]],
|
marker_color=colors,
|
||||||
textposition="auto",
|
text=[f"{wr:.0f}%" for wr in df_sorted["Win Rate"]],
|
||||||
textfont=dict(family="JetBrains Mono", size=11, color=COLORS["text_primary"]),
|
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)
|
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"]
|
by_strat = stats["by_strategy"]
|
||||||
rows = []
|
rows = []
|
||||||
for strat_name, data in by_strat.items():
|
for strat_name, data in by_strat.items():
|
||||||
rows.append({
|
rows.append(
|
||||||
"Strategy": strat_name,
|
{
|
||||||
"Count": data.get("count", 0),
|
"Strategy": strat_name,
|
||||||
"Win Rate 1d": f"{data.get('win_rate_1d', 0):.0f}%" if "win_rate_1d" in data else "N/A",
|
"Count": data.get("count", 0),
|
||||||
"Win Rate 7d": f"{data.get('win_rate_7d', 0):.0f}%" if "win_rate_7d" in data else "N/A",
|
"Win Rate 1d": (
|
||||||
"Wins 1d": data.get("wins_1d", 0),
|
f"{data.get('win_rate_1d', 0):.0f}%" if "win_rate_1d" in data else "N/A"
|
||||||
"Losses 1d": data.get("losses_1d", 0),
|
),
|
||||||
"Wins 7d": data.get("wins_7d", 0),
|
"Win Rate 7d": (
|
||||||
"Losses 7d": data.get("losses_7d", 0),
|
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:
|
if rows:
|
||||||
st.dataframe(pd.DataFrame(rows), width="stretch", hide_index=True)
|
st.dataframe(pd.DataFrame(rows), width="stretch", hide_index=True)
|
||||||
|
|
|
||||||
|
|
@ -30,19 +30,23 @@ def render():
|
||||||
st.write("")
|
st.write("")
|
||||||
if st.button("Add Position"):
|
if st.button("Add Position"):
|
||||||
if ticker and entry_price > 0 and shares > 0:
|
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()
|
tracker = PositionTracker()
|
||||||
pos = tracker.create_position({
|
pos = tracker.create_position(
|
||||||
"ticker": ticker.upper(),
|
{
|
||||||
"entry_price": entry_price,
|
"ticker": ticker.upper(),
|
||||||
"shares": shares,
|
"entry_price": entry_price,
|
||||||
"recommendation_date": datetime.now().isoformat(),
|
"shares": shares,
|
||||||
"pipeline": "manual",
|
"recommendation_date": datetime.now().isoformat(),
|
||||||
"scanner": "manual",
|
"pipeline": "manual",
|
||||||
"strategy_match": "manual",
|
"scanner": "manual",
|
||||||
"confidence": 5,
|
"strategy_match": "manual",
|
||||||
})
|
"confidence": 5,
|
||||||
|
}
|
||||||
|
)
|
||||||
tracker.save_position(pos)
|
tracker.save_position(pos)
|
||||||
st.success(f"Added {ticker.upper()}")
|
st.success(f"Added {ticker.upper()}")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
@ -74,7 +78,12 @@ def render():
|
||||||
summary_kpis = [
|
summary_kpis = [
|
||||||
("Invested", f"${total_invested:,.0f}", "", "blue"),
|
("Invested", f"${total_invested:,.0f}", "", "blue"),
|
||||||
("Current Value", f"${total_current:,.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"),
|
("Positions", str(len(positions)), "", "amber"),
|
||||||
]
|
]
|
||||||
for col, (label, value, delta, color) in zip(cols, summary_kpis):
|
for col, (label, value, delta, color) in zip(cols, summary_kpis):
|
||||||
|
|
@ -145,15 +154,17 @@ def render():
|
||||||
data = []
|
data = []
|
||||||
for p in positions:
|
for p in positions:
|
||||||
pnl = (p["metrics"]["current_price"] - p["entry_price"]) * p.get("shares", 0)
|
pnl = (p["metrics"]["current_price"] - p["entry_price"]) * p.get("shares", 0)
|
||||||
data.append({
|
data.append(
|
||||||
"Ticker": p["ticker"],
|
{
|
||||||
"Entry": p["entry_price"],
|
"Ticker": p["ticker"],
|
||||||
"Current": p["metrics"]["current_price"],
|
"Entry": p["entry_price"],
|
||||||
"Shares": p.get("shares", 0),
|
"Current": p["metrics"]["current_price"],
|
||||||
"P/L": pnl,
|
"Shares": p.get("shares", 0),
|
||||||
"P/L %": p["metrics"]["current_return"],
|
"P/L": pnl,
|
||||||
"Days": p["metrics"]["days_held"],
|
"P/L %": p["metrics"]["current_return"],
|
||||||
})
|
"Days": p["metrics"]["days_held"],
|
||||||
|
}
|
||||||
|
)
|
||||||
st.dataframe(
|
st.dataframe(
|
||||||
pd.DataFrame(data),
|
pd.DataFrame(data),
|
||||||
width="stretch",
|
width="stretch",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ from tradingagents.ui.theme import COLORS, page_header
|
||||||
|
|
||||||
def render() -> None:
|
def render() -> None:
|
||||||
"""Render the configuration page."""
|
"""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
|
config = DEFAULT_CONFIG
|
||||||
discovery_config = config.get("discovery", {})
|
discovery_config = config.get("discovery", {})
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ def _load_price_history(ticker: str, period: str) -> pd.DataFrame:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
data = download_history(
|
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:
|
if data is None or data.empty:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
@ -62,9 +66,11 @@ def render():
|
||||||
# ---- Controls row ----
|
# ---- Controls row ----
|
||||||
ctrl_cols = st.columns([1, 1, 1, 1])
|
ctrl_cols = st.columns([1, 1, 1, 1])
|
||||||
with ctrl_cols[0]:
|
with ctrl_cols[0]:
|
||||||
pipelines = sorted(set(
|
pipelines = sorted(
|
||||||
(r.get("pipeline") or r.get("strategy_match") or "unknown") for r in recommendations
|
set(
|
||||||
))
|
(r.get("pipeline") or r.get("strategy_match") or "unknown") for r in recommendations
|
||||||
|
)
|
||||||
|
)
|
||||||
pipeline_filter = st.multiselect("Strategy", pipelines, default=pipelines)
|
pipeline_filter = st.multiselect("Strategy", pipelines, default=pipelines)
|
||||||
with ctrl_cols[1]:
|
with ctrl_cols[1]:
|
||||||
min_confidence = st.slider("Min Confidence", 1, 10, 1)
|
min_confidence = st.slider("Min Confidence", 1, 10, 1)
|
||||||
|
|
@ -79,7 +85,8 @@ def render():
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
filtered = [
|
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
|
if (r.get("pipeline") or r.get("strategy_match") or "unknown") in pipeline_filter
|
||||||
and r.get("confidence", 0) >= min_confidence
|
and r.get("confidence", 0) >= min_confidence
|
||||||
and r.get("final_score", 0) >= min_score
|
and r.get("final_score", 0) >= min_score
|
||||||
|
|
@ -130,7 +137,9 @@ def render():
|
||||||
history = _load_price_history(ticker, chart_window)
|
history = _load_price_history(ticker, chart_window)
|
||||||
if not history.empty:
|
if not history.empty:
|
||||||
template = get_plotly_template()
|
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
|
# Color line green if trending up, red if down
|
||||||
first_close = history["close"].iloc[0]
|
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_layout(margin=dict(l=0, r=0, t=0, b=0))
|
||||||
fig.update_xaxes(showticklabels=False, showgrid=False)
|
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")
|
st.plotly_chart(fig, width="stretch")
|
||||||
|
|
||||||
# Action buttons
|
# Action buttons
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
Loading…
Reference in New Issue