"""Visual, quote-free rendering of multi-quarter thematic sentiment.""" from __future__ import annotations from html import escape import streamlit as st from dashboard.components import section_header from dashboard.i18n import t from dashboard.theme import ( AMBER, BG_MUTED, BORDER, GREEN, RED, TEXT, TEXT_FAINT, TEXT_MUTED, FS_META, ) def _signed(value: int | float | None) -> str: if value is None: return "โ€”" return f"{value:+.0f}" def _signed_rate(value: float | None) -> str: """A signed rate keeps its decimal; _signed() rounds to whole index points.""" if value is None: return "โ€”" return f"{value:+.1f}" def _band(score: int | None) -> str: if score is None: return t("sent_band_na") if score >= 25: return t("sent_band_positive") if score <= -25: return t("sent_band_negative") return t("sent_band_neutral") def _summary_card(label: str, value: str, note: str, color: str) -> str: return ( f'
' f'
{escape(label)}
' f'
' f'{escape(value)}
' f'
' f'{escape(note)}
' ) def _render_summary(data: dict) -> None: improvement = data.get("biggest_improvement") deterioration = data.get("biggest_deterioration") emerging = data.get("emerging_themes") or [] cards: list[str] = [] if deterioration: cards.append(_summary_card( t("sent_deterioration"), deterioration["theme"], t("sent_points").format(value=_signed(deterioration["change"])), RED, )) if improvement: cards.append(_summary_card( t("sent_improvement"), improvement["theme"], t("sent_points").format(value=_signed(improvement["change"])), GREEN, )) if emerging: names = ", ".join(item["theme"] for item in emerging[:3]) cards.append(_summary_card( t("sent_new_themes"), names, t("sent_new_count").format(count=len(emerging)), AMBER, )) if cards: st.markdown( f'
' f'{"".join(cards)}
', unsafe_allow_html=True, ) def _render_heatmap(data: dict) -> None: import plotly.graph_objects as go periods = data["periods"] themes = data["themes"] z = [[point["score"] for point in row["periods"]] for row in themes] custom = [[ [point["attention_per_10k"], point["evidence_sentences"], point["confidence"]] for point in row["periods"] ] for row in themes] text = [["โ€”" if score is None else f"{score:+d}" for score in values] for values in z] fig = go.Figure(go.Heatmap( z=z, x=periods, y=[t(f"sent_theme_{row['key']}") for row in themes], zmin=-100, zmax=100, zmid=0, colorscale=[[0, "#A63A2B"], [0.5, "#F5F6F3"], [1, "#1E6B55"]], colorbar=dict(title=t("sent_index"), thickness=12, len=0.8), customdata=custom, text=text, texttemplate="%{text}", hovertemplate=( "%{y} ยท %{x}
" + t("sent_hover_score") + ": %{z}
" + t("sent_hover_attention") + ": %{customdata[0]}
" + t("sent_hover_evidence") + ": %{customdata[1]}
" + t("sent_hover_confidence") + ": %{customdata[2]}" ), hoverongaps=False, )) fig.update_layout( height=330, margin=dict(l=10, r=10, t=20, b=10), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=dict(family="IBM Plex Sans", color=TEXT_MUTED, size=11), xaxis=dict(side="top", showgrid=False), yaxis=dict(autorange="reversed", showgrid=False), ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) def _render_latest_metrics(data: dict) -> None: period = data["periods"][-1] rows = [] for row in data["themes"]: latest = row["periods"][-1] rows.append({ t("sent_col_theme"): t(f"sent_theme_{row['key']}"), t("sent_col_level"): _band(row["latest_score"]), t("sent_col_index"): _signed(row["latest_score"]), t("sent_col_change"): _signed(row["change"]), t("sent_col_attention"): latest["attention_per_10k"], t("sent_col_evidence"): latest["evidence_sentences"], t("sent_col_confidence"): t(f"sent_conf_{latest['confidence'].lower()}"), }) st.caption(t("sent_latest_caption").format(period=period)) st.dataframe(rows, use_container_width=True, hide_index=True) def _render_emerging(data: dict) -> None: emerging = data.get("emerging_themes") or [] st.markdown(section_header(t("sent_emerging_title"), accent=AMBER), unsafe_allow_html=True) if not emerging: st.caption(t("sent_emerging_none")) return rows = [{ t("sent_col_theme"): item["theme"], t("sent_col_status"): t(f"sent_status_{item['status'].lower()}"), t("sent_col_attention"): item["attention_per_10k"], t("sent_col_prior_attention"): item["prior_attention_per_10k"], t("sent_col_change"): f"{item['change_per_10k']:+.1f}", } for item in emerging] st.dataframe(rows, use_container_width=True, hide_index=True) def _render_terms(data: dict) -> None: """The named things behind each theme, one tab per theme. A heatmap cell on its own cannot be checked. This is the audit trail for it: what the company was actually calling a product when the Products row was scored, how often, and whether it volunteered the subject or was asked. """ themes = [row for row in data.get("themes", []) if row.get("terms")] st.markdown(section_header(t("terms_title"), accent=GREEN), unsafe_allow_html=True) if not themes: st.caption(t("terms_none")) return st.caption(t("terms_subtitle").format(period=data["periods"][-1])) for tab, row in zip(st.tabs([t(f"sent_theme_{r['key']}") for r in themes]), themes): with tab: table = [{ t("terms_col_term"): item["term"], t("terms_col_source"): ( t("terms_source_filed") if item["filed"] else t("terms_source_call") ), t("terms_col_mentions"): item["mentions"], t("terms_col_trend"): item["counts"], t("terms_col_rate"): item["rate"], # Pre-signed text rather than numbers: a term with too little # evidence has no tone at all, and a numeric column prints the # word "None" in the cell where the reader expects an em dash. t("terms_col_change"): _signed_rate(item["change"]), t("terms_col_prepared"): item["prepared"], t("terms_col_qa"): item["answers"], t("terms_col_tone"): _signed(item["score"]), } for item in row["terms"]] st.dataframe( table, use_container_width=True, hide_index=True, column_config={ t("terms_col_term"): st.column_config.TextColumn( t("terms_col_term"), width="medium", ), t("terms_col_trend"): st.column_config.LineChartColumn( t("terms_col_trend"), width="small", ), t("terms_col_rate"): st.column_config.NumberColumn( t("terms_col_rate"), format="%.1f", ), }, ) st.caption(t("terms_caveat")) def render(ticker: str) -> None: """Render thematic sentiment for *ticker*, with no source quotations.""" from analysis.theme_sentiment import compute data = compute(ticker) st.markdown(section_header(t("sent_evolution_title"), accent=GREEN), unsafe_allow_html=True) st.caption(t("sent_evolution_subtitle")) if not data: st.info(t("sent_evolution_empty")) return _render_summary(data) _render_heatmap(data) _render_latest_metrics(data) _render_terms(data) _render_emerging(data) with st.expander(t("sent_method_title"), expanded=False): st.markdown(t("sent_method_body")) st.markdown( f'
' f'{escape(t("sent_no_quotes"))}
', unsafe_allow_html=True, )