| """Evidence & Deltas β source-backed signals in one ranked, filterable stream. |
| |
| Replaces the former MD&A / Earnings Call / Risks / Quality & Tone tabs: |
| their content is normalized by dashboard/signal_feed.py and rendered with |
| the single components.signal_card renderer. Also hosts the one global |
| semantic search (filings + transcripts) and the on-demand news loader. |
| """ |
| from __future__ import annotations |
|
|
| import streamlit as st |
|
|
| from dashboard import fmt_period |
| from dashboard.i18n import t |
| from dashboard.theme import ( |
| GREEN, RED, GRAY, AMBER, |
| BG_MUTED, BORDER, TEXT, TEXT_MUTED, TEXT_FAINT, |
| BULL_BG, BEAR_BG, |
| FS_PAGE, FS_META, FS_EYEBROW, |
| ) |
| from dashboard.components import signal_card, section_header |
| from dashboard import provenance |
| from dashboard.signal_feed import ( |
| KIND_PRIORITY, Signal, aggregate_sentiment, build_feed, global_band, rank_feed, |
| ) |
|
|
| _FILTER_KEYS = ( |
| "signals_filter_type", "signals_filter_stance", |
| "signals_filter_source", "signals_high_sig", "signals_search", |
| ) |
|
|
| _SENT_SECTION_LABELS = { |
| "metrics": "sent_metrics", "guidance": "sent_guidance", "mda": "sent_mda", |
| "earnings_call": "sent_call", "news": "sent_news", |
| } |
|
|
| _TONE_STYLES = { |
| "bull": (GREEN, BULL_BG), "bear": (RED, BEAR_BG), "neutral": (GRAY, BG_MUTED), |
| } |
|
|
|
|
| |
|
|
| def _gauge_html(score: int) -> str: |
| """Redβgreen gradient bar with a dot at `score` (-2..+2).""" |
| pct = (score + 2) / 4 * 90 + 5 |
| dot_color = GREEN if score > 0 else (RED if score < 0 else GRAY) |
| return ( |
| f'<div style="margin:4px 0 2px;height:6px;border-radius:99px;position:relative;' |
| f'background:linear-gradient(90deg,#fecaca 0%,#fde68a 38%,#d1fae5 62%,#6ee7b7 100%);">' |
| f'<div style="position:absolute;top:50%;left:{pct:.1f}%;transform:translate(-50%,-50%);' |
| f'width:12px;height:12px;border-radius:50%;background:{dot_color};' |
| f'border:2px solid #fff;box-shadow:0 0 0 1.5px {dot_color};"></div>' |
| f'</div>' |
| ) |
|
|
|
|
| def _render_sentiment_strip(sentiment: dict | None) -> None: |
| if not sentiment: |
| return |
| agg = aggregate_sentiment(sentiment) |
| if agg is None: |
| return |
|
|
| band_key, tone = global_band(agg) |
| band_color, band_bg = _TONE_STYLES[tone] |
| sign = "+" if agg >= 0 else "" |
|
|
| cells_html = "" |
| for key, label_key in _SENT_SECTION_LABELS.items(): |
| label = t(label_key) |
| section = sentiment.get(key) |
| if section is None or section.get("score") is None: |
| cells_html += ( |
| f'<div style="padding:10px 12px;border-right:1px solid {BORDER};background:#fff;">' |
| f'<div style="font-size:{FS_EYEBROW};font-weight:700;text-transform:uppercase;' |
| f'letter-spacing:0.07em;color:{TEXT_MUTED};margin-bottom:6px;">{label}</div>' |
| f'<div style="font-size:0.68rem;color:{TEXT_MUTED};">β</div>' |
| f'</div>' |
| ) |
| else: |
| score = section["score"] |
| fg = GREEN if score > 0 else (RED if score < 0 else GRAY) |
| rationale = section.get("rationale", "") |
| cells_html += ( |
| f'<div style="padding:10px 12px;border-right:1px solid {BORDER};background:#fff;">' |
| f'<div style="font-size:{FS_EYEBROW};font-weight:700;text-transform:uppercase;' |
| f'letter-spacing:0.07em;color:{TEXT_MUTED};margin-bottom:4px;">{label}</div>' |
| f'{_gauge_html(score)}' |
| f'<div style="font-size:0.68rem;font-weight:600;color:{fg};margin-bottom:4px;">' |
| f'{section.get("label", "")}</div>' |
| f'<div style="font-size:{FS_META};color:{TEXT_MUTED};line-height:1.4;">{rationale}</div>' |
| f'</div>' |
| ) |
|
|
| st.markdown( |
| f'<div class="primer-card" style="display:grid;grid-template-columns:130px repeat(5,1fr);' |
| f'border:1px solid {BORDER};border-radius:12px;overflow:hidden;margin-bottom:8px;">' |
| f'<div style="background:{band_bg};border-right:2px solid {band_color};' |
| f'padding:12px 14px;display:flex;flex-direction:column;justify-content:center;">' |
| f'<div style="font-size:0.56rem;font-weight:700;text-transform:uppercase;' |
| f'letter-spacing:0.09em;color:{band_color};margin-bottom:4px;">{t("print_sentiment")}</div>' |
| f'<div style="font-size:1.75rem;font-weight:800;line-height:1;color:{band_color};">' |
| f'{sign}{agg:.1f}</div>' |
| f'<div style="font-size:0.72rem;font-weight:600;color:{band_color};margin-top:3px;">' |
| f'{t(band_key)}</div>' |
| f'</div>' |
| f'{cells_html}' |
| f'</div>', |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| def _sentiment_display_allowed(brief: dict) -> bool: |
| """Fail closed until aggregate sentiment has an explicit calibration flag.""" |
| policy = brief.get("display_policy") or {} |
| return isinstance(policy, dict) and policy.get("sentiment_calibrated") is True |
|
|
|
|
| def _render_experimental_notice() -> None: |
| st.markdown( |
| f'<div style="background:#fffbeb;border:1px solid #fde68a;' |
| f'border-left:3px solid {AMBER};border-radius:0 8px 8px 0;' |
| f'padding:9px 12px;margin:0 0 16px;font-size:{FS_META};' |
| f'line-height:1.5;color:{TEXT_MUTED};">' |
| f'<strong style="color:{TEXT};">Validation required.</strong> ' |
| f'AI hypotheses are experimental; delta detectors are heuristic. ' |
| f'Validate both against the cited source text.</div>', |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| |
|
|
| def _reset_filters_on_ticker_change(ticker: str) -> None: |
| """Pills options are feed-derived; clear stale selections when the active |
| ticker changes (must run before the filter widgets instantiate).""" |
| if st.session_state.get("_signals_ticker") != ticker: |
| for key in _FILTER_KEYS: |
| st.session_state.pop(key, None) |
| st.session_state["_signals_ticker"] = ticker |
|
|
|
|
| def _options_with_selection(present: list[str], state_key: str) -> list[str]: |
| """Feed-derived options βͺ current selection β a selected value absent from |
| the new options would otherwise raise inside st.pills.""" |
| selected = st.session_state.get(state_key) or [] |
| extras = [s for s in selected if s not in present] |
| return present + extras |
|
|
|
|
| def _render_filters(feed: list[Signal]) -> tuple[list[str], list[str], list[str], bool]: |
| kinds_present = sorted( |
| {s.kind for s in feed}, key=lambda k: KIND_PRIORITY.get(k, 99) |
| ) |
| stances_present = [ |
| s for s in ("bull", "bear", "neutral", "mixed") |
| if any(sig.stance == s for sig in feed) |
| ] |
| sources_present = sorted({s.source for s in feed if s.source}) |
|
|
| sel_kinds = st.pills( |
| t("filter_type"), |
| options=_options_with_selection(kinds_present, "signals_filter_type"), |
| selection_mode="multi", |
| format_func=lambda k: t(f"kind_{k}"), |
| key="signals_filter_type", |
| ) |
|
|
| col_stance, col_source, col_sig = st.columns([2, 2, 1]) |
| with col_stance: |
| sel_stances = st.pills( |
| t("filter_stance"), |
| options=_options_with_selection(stances_present, "signals_filter_stance"), |
| selection_mode="multi", |
| format_func=lambda s: t(f"stance_{s}"), |
| key="signals_filter_stance", |
| ) |
| with col_source: |
| sel_sources = st.pills( |
| t("filter_source"), |
| options=_options_with_selection(sources_present, "signals_filter_source"), |
| selection_mode="multi", |
| key="signals_filter_source", |
| ) |
| with col_sig: |
| high_only = st.toggle(t("filter_high_sig"), key="signals_high_sig") |
|
|
| return sel_kinds or [], sel_stances or [], sel_sources or [], bool(high_only) |
|
|
|
|
| def _apply_filters( |
| feed: list[Signal], |
| kinds: list[str], stances: list[str], sources: list[str], high_only: bool, |
| ) -> list[Signal]: |
| out = feed |
| if kinds: |
| out = [s for s in out if s.kind in kinds] |
| if stances: |
| out = [s for s in out if s.stance in stances] |
| if sources: |
| out = [s for s in out if s.source in sources] |
| if high_only: |
| out = [s for s in out if s.significance == "HIGH"] |
| return out |
|
|
|
|
| |
|
|
| def _render_search(ticker: str) -> None: |
| st.markdown(section_header(t("search_label"), accent=AMBER), unsafe_allow_html=True) |
|
|
| from storage.metrics_db import get_all_metrics |
| rows = get_all_metrics(ticker) |
| periods = [r.get("period", "") for r in rows if r.get("period")] |
|
|
| col_q, col_p = st.columns([3, 2]) |
| with col_q: |
| query = st.text_input( |
| t("search_label"), placeholder=t("search_placeholder"), |
| key="signals_search", label_visibility="collapsed", |
| ) |
| with col_p: |
| period_options = [""] + periods |
| selected_period = st.selectbox( |
| t("filter_source"), options=period_options, |
| format_func=lambda p: fmt_period(p) if p else "β", |
| key="signals_search_period", label_visibility="collapsed", |
| ) |
|
|
| if not query: |
| return |
|
|
| from storage import vector_store |
| from storage.reranker import rerank |
|
|
| with st.spinner(t("chat_searching")): |
| union: list[dict] = [] |
| for collection in ("filings", "transcripts"): |
| hits = vector_store.search( |
| collection, query, ticker, n_results=4, |
| period=selected_period or None, |
| ) |
| for h in hits: |
| h["collection"] = collection |
| union += hits |
| |
| |
| merged = rerank(query, union, top_k=6) |
|
|
| if not merged: |
| st.warning(t("search_no_results")) |
| return |
|
|
| for r in merged: |
| m = r.get("metadata", {}) |
| icon = "π" if r.get("collection") == "filings" else "ποΈ" |
| context = m.get("chunk_context") or " Β· ".join( |
| str(v) for v in ( |
| m.get("source", r.get("collection", "")), |
| m.get("section", ""), |
| m.get("filing_date", "") or m.get("date", ""), |
| ) if v |
| ) |
| with st.expander(f"{icon} {context}", expanded=False): |
| st.markdown( |
| f'<div style="font-size:0.85rem;line-height:1.7;color:{TEXT};">{r["text"]}</div>', |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| |
|
|
| def _load_news(ticker: str) -> None: |
| import os |
| try: |
| from tavily import TavilyClient |
| from storage.metrics_db import get_all_metrics |
|
|
| api_key = os.environ.get("TAVILY_API_KEY") |
| if not api_key: |
| st.error("TAVILY_API_KEY not set.") |
| return |
|
|
| rows = get_all_metrics(ticker) |
| company_name = rows[0]["company_name"] if rows else ticker |
| latest_filing_date = rows[0]["filing_date"] if rows else None |
|
|
| with st.spinner(t("chat_searching")): |
| client = TavilyClient(api_key=api_key) |
| results = client.search( |
| query=f"{ticker} {company_name} earnings results guidance", |
| max_results=6, |
| search_depth="basic", |
| days=60, |
| topic="news", |
| ).get("results", []) |
|
|
| if latest_filing_date: |
| results = [r for r in results if r.get("published_date", "") >= latest_filing_date] |
|
|
| if not results: |
| st.info(t("news_none")) |
| return |
|
|
| for item in results: |
| signal_card(Signal( |
| kind="news", |
| headline=item.get("title", ""), |
| body=(item.get("content", "") or "")[:300], |
| source="news", |
| significance="LOW", |
| extra={ |
| "url": item.get("url", ""), |
| "date": (item.get("published_date", "") or "")[:10], |
| }, |
| )) |
|
|
| except Exception as e: |
| st.error(f"Failed to load news: {e}") |
|
|
|
|
| |
|
|
| def render(brief: dict, ticker: str) -> None: |
| filing_date = brief.get("filing_date", "") |
| company_name = brief.get("company_name", "") |
|
|
| subtitle_parts: list[str] = [] |
| if company_name: |
| subtitle_parts.append(company_name) |
| if ticker and ticker not in company_name: |
| subtitle_parts.append(ticker) |
| if filing_date: |
| subtitle_parts.append(filing_date) |
| subtitle_parts.append(t("signals_subtitle")) |
| subtitle = " Β· ".join(subtitle_parts) |
|
|
| |
| st.markdown( |
| f'<div style="margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid {BORDER};">' |
| f'<div style="font-size:{FS_PAGE};font-weight:700;color:{TEXT};letter-spacing:-0.02em;">' |
| f'Evidence & Deltas</div>' |
| f'<div style="font-size:{FS_META};color:{TEXT_FAINT};margin-top:4px;">{subtitle}</div>' |
| f'</div>', |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| provenance.render_card(ticker, brief) |
| _render_experimental_notice() |
| if _sentiment_display_allowed(brief): |
| _render_sentiment_strip(brief.get("sentiment")) |
|
|
| |
| _reset_filters_on_ticker_change(ticker) |
| feed = rank_feed(build_feed(brief)) |
|
|
| if feed: |
| kinds, stances, sources, high_only = _render_filters(feed) |
| filtered = _apply_filters(feed, kinds, stances, sources, high_only) |
|
|
| st.caption(f"{len(filtered)} / {len(feed)}") |
| if filtered: |
| for sig in filtered: |
| signal_card(sig) |
| else: |
| st.info(t("signals_empty")) |
|
|
| |
| _render_search(ticker) |
|
|
| |
| st.markdown(section_header(t("news_title"), accent=AMBER), unsafe_allow_html=True) |
| if st.button(t("load_news"), key="signals_news_btn"): |
| _load_news(ticker) |
|
|