from __future__ import annotations import html from datetime import datetime import streamlit as st try: from markdown_it import MarkdownIt except ImportError: # 极简环境可回退为原始 Markdown MarkdownIt = None # type: ignore[misc, assignment] from web.services.market_data import ( INDEX_SUBTITLE_CN, SECTOR_SUBTITLE_CN, load_dashboard_display, refresh_dashboard_cache, ) # 指标卡第 4 行「描述」文案来源(勿与 tradingagents 底层混淆;均在 web/services/market_data.py): # - 主要指数:字典 INDEX_SUBTITLE_CN(各 ticker 对应中文说明) # - 宏观与利率:常量 MACRO_ENTRIES 每项第 3 个元素 hint,经 get_macro_strip() 写入 macro_strip[].hint STANCE_LABEL_CN = { "bullish": "看多", "bearish": "看空", "neutral": "中性", "unknown": "未评估", } STANCE_CLASS = { "bullish": "ta-stance-bull", "bearish": "ta-stance-bear", "neutral": "ta-stance-neut", "unknown": "ta-stance-unknown", } def _metric_label_ticker(ticker: str) -> str: return "VIX" if ticker == "^VIX" else ticker def _e(s: object) -> str: return html.escape(str(s), quote=True) def _delta_direction(change_pct: float | None) -> bool | None: if change_pct is None: return None if change_pct > 0: return True if change_pct < 0: return False return None def _stat_card_html( code: str, value: str, change_vs_prev: str, description: str, *, positive: bool | None = None, ) -> str: """单卡四层:标的代码 → 数值 → 较昨收涨跌 → 描述(左对齐,内联样式)。""" if positive is True: ch_color = "#22c55e" elif positive is False: ch_color = "#ef4444" else: ch_color = "#94a3b8" card = ( "flex:1 1 148px;min-width:min(148px,100%);box-sizing:border-box;" "background:rgba(248,250,252,0.04);border:1px solid rgba(148,163,184,0.22);" "border-radius:12px;padding:14px 16px 12px 16px;text-align:left;" ) return ( f'
' '
' f"{_e(code)}
" '
' f"{_e(value)}
" f'
' f"{_e(change_vs_prev)}
" '
' f"{_e(description)}
" "
" ) def _stat_row_wrap(inner: str) -> str: row = ( "display:flex;flex-wrap:wrap;gap:0.65rem;margin-top:0.35rem;" "align-items:stretch;width:100%;" ) return f'
{inner}
' def _render_stat_row(html_fragment: str) -> None: """优先 st.html(对自定义 HTML/CSS 限制更少);否则回退 markdown。""" html_fn = getattr(st, "html", None) if callable(html_fn): html_fn(html_fragment, width="stretch") else: st.markdown(html_fragment, unsafe_allow_html=True) def _render_macro_two_rows(card_html_parts: list[str], per_row: int = 3) -> None: """宏观指标固定为两排:每排 per_row 张(默认 3+3)。""" rows: list[str] = [] for i in range(0, len(card_html_parts), per_row): rows.append(_stat_row_wrap("".join(card_html_parts[i : i + per_row]))) outer = ( '
' + "".join(rows) + "
" ) _render_stat_row(outer) def _parse_h5_sections(md: str) -> list[tuple[str, str]]: """按五级标题 `##### ` 切分。首个 `#####` 之前的正文会合并进第一节,避免「经济数据」漂在折叠区外。""" lines = md.splitlines() sections: list[tuple[str, str]] = [] buf: list[str] = [] pending_title: str | None = None lead_before_first: list[str] = [] for line in lines: stripped = line.strip() if stripped.startswith("##### ") and not stripped.startswith("######"): title = stripped[6:].strip() if pending_title is not None: body = "\n".join(buf).strip() if len(sections) == 0 and lead_before_first: pre = "\n".join(lead_before_first).strip() if pre: body = (pre + "\n\n" + body).strip() if body else pre lead_before_first = [] sections.append((pending_title, body)) buf = [] else: lead_before_first = list(buf) buf = [] pending_title = title else: buf.append(line) if pending_title is not None: body = "\n".join(buf).strip() if len(sections) == 0 and lead_before_first: pre = "\n".join(lead_before_first).strip() if pre: body = (pre + "\n\n" + body).strip() if body else pre sections.append((pending_title, body)) return sections def _render_digest_markdown(digest: str) -> None: """整段 Markdown → HTML 单容器(无 ##### 分节时使用)。""" if MarkdownIt is None: st.markdown(digest) return inner = MarkdownIt().enable("table").render(digest) wrap = ( '
' f'
{inner}
' ) html_fn = getattr(st, "html", None) if callable(html_fn): html_fn(wrap, width="stretch") else: st.markdown(wrap, unsafe_allow_html=True) def _render_digest_subsections_details( sections: list[tuple[str, str]], *, open_first: bool = True, ) -> None: """各 ##### 分节统一用
;首节可默认展开(open),避免第一节单独用边框区与后续不一致。""" for i, (title, body) in enumerate(sections): label = (title or "").strip() or "分节" raw = body.strip() or "_(本节无正文)_" open_attr = ' open' if (open_first and i == 0) else "" if MarkdownIt is not None: inner = MarkdownIt().enable("table").render(raw) frag = ( f'
' f'{_e(label)}' f'
{inner}
' "
" ) else: frag = ( f'
' f'{_e(label)}' f'
'
                f"{_e(raw)}
" ) html_fn = getattr(st, "html", None) if callable(html_fn): html_fn(frag, width="stretch") else: st.markdown(frag, unsafe_allow_html=True) def _render_digest_structured(digest: str) -> None: """所有 ##### 分节统一为
(首节默认展开),与「其他总结」折叠样式一致。""" sections = _parse_h5_sections(digest) if not sections: _render_digest_markdown(digest) return _render_digest_subsections_details(sections, open_first=True) def _format_asof_line(data: dict) -> str: """仪表盘顶部:优先展示上次手动点击「更新」的 UTC 时间。""" fa = (data.get("fetched_at_utc") or "").strip() if fa: try: dt = datetime.fromisoformat(fa.replace("Z", "+00:00")) utc_s = dt.strftime("%Y-%m-%d %H:%M UTC") local_s = dt.astimezone().strftime("%Y-%m-%d %H:%M") return f"当前页数据对应上次手动更新时间:{utc_s}(本地 {local_s})" except ValueError: return f"当前页数据对应上次手动更新时间:{fa}" et = (data.get("last_updated_et") or "").strip() if et: return ( f"快照行情观察时间(ET):{et}。" "尚未记录手动更新 UTC(请重新点击「更新市场数据」以写入归档时间)。" ) return "暂无更新时间记录;请点击「更新市场数据」。" def _news_article_html(n: dict) -> str: """速览卡片:外链「阅读原文」。""" title = html.escape(str(n.get("title", "N/A"))) pub = (n.get("published_at") or "").strip() date_html = f' · {html.escape(pub)}' if pub else "" prov = html.escape(str(n.get("provider", "Unknown"))) summary = n.get("summary") link = n.get("link", "") or "" stance = n.get("stance") or "unknown" stance_label = html.escape(STANCE_LABEL_CN.get(stance, "未评估")) stance_cls = STANCE_CLASS.get(stance, "ta-stance-unknown") llm_text = (n.get("llm_summary") or "").strip() parts = [ '
', f'

{title}{date_html}

', f'

{prov} · {stance_label}

', ] if summary: parts.append(f'

{html.escape(str(summary))}

') if llm_text: parts.append('

大模型解读

') parts.append(f'

{html.escape(llm_text)}

') if link: safe_link = html.escape(str(link), quote=True) parts.append( '" ) else: parts.append('

无媒体外链

') parts.append("
") return "".join(parts) def render_dashboard() -> None: st.markdown( '

仪表盘

' '

市场概览与快讯;点击「更新市场数据」将拉取 Yahoo 行情与新闻,' "并调用 Fast 模型生成新闻解读与大盘总结。

", unsafe_allow_html=True, ) col_btn, col_status = st.columns([1, 2], gap="large") with col_btn: clicked = st.button("更新市场数据", key="refresh_dashboard") if clicked: # 仪表盘 LLM 与个股分析表单的 last_params 解耦,避免沿用分析任务里的 OpenAI/gpt-5.4-mini data, news = refresh_dashboard_cache(limit=10) st.session_state.dashboard_data = data st.session_state.dashboard_news = news else: data = st.session_state.get("dashboard_data") news = st.session_state.get("dashboard_news") if data is None: cached_data, cached_news = load_dashboard_display() if cached_data is not None: data = cached_data news = cached_news st.session_state.dashboard_data = cached_data st.session_state.dashboard_news = cached_news with col_status: if data is None: st.warning("当前无缓存数据。请点击左侧「更新市场数据」拉取最新行情。") else: st.caption( f"市场状态 `{data.get('market_status', 'N/A')}` · " f"行情快照 ET `{data.get('last_updated_et', 'N/A')}`" ) lm = (data.get("llm_model") or "").strip() lp = (data.get("llm_provider") or "").strip() if lp and lm: st.caption(f"仪表盘 LLM:`{lp}` / `{lm}`") elif lm: st.caption(f"仪表盘 LLM(Quick):`{lm}`") if data is None: return st.markdown( f'

{html.escape(_format_asof_line(data))}

', unsafe_allow_html=True, ) digest = (data.get("market_digest_md") or "").strip() digest_err = (data.get("llm_digest_error") or "").strip() with st.expander("大盘总结(大模型)", expanded=True, key="dash_exp_digest"): if digest: _render_digest_structured(digest) else: st.info("总结暂不可用:模型未返回内容,或未配置密钥/调用失败。") if digest_err: st.code(digest_err, language="text") macro = data.get("macro_strip") or [] if macro: with st.expander("宏观经济数据", expanded=True, key="dash_exp_macro"): st.caption("以下为市场可交易品种或收益率指数,非劳工部 CPI、非农等官方发布序列。") macro_cards = [] for m in macro: ch = m.get("change_pct") dv = str(m.get("display_value", "N/A")) tk = str(m.get("ticker", "")) hint = str(m.get("hint", "")).strip() ch_s = f"{ch:+.2f}%" if ch is not None else "—" change_line = f"较昨收 {ch_s}" code = tk or "—" macro_cards.append( _stat_card_html( code, dv, change_line, hint or "(无说明)", positive=_delta_direction(ch), ) ) _render_macro_two_rows(macro_cards, per_row=3) with st.expander("主要指数", expanded=True, key="dash_exp_idx"): rows = data.get("indexes", []) if not rows: st.info("暂无指数数据。") else: idx_cards = [] for row in rows: price = f"{row.price:.2f}" if row.price is not None else "N/A" ch = row.change_pct ch_s = f"{ch:+.2f}%" if ch is not None else "—" desc = (INDEX_SUBTITLE_CN.get(row.ticker) or "").strip() or "(无说明)" idx_cards.append( _stat_card_html( _metric_label_ticker(row.ticker), price, f"较昨收 {ch_s}", desc, positive=_delta_direction(ch), ) ) _render_stat_row(_stat_row_wrap("".join(idx_cards))) with st.expander("板块强弱 Top 3(代理 ETF)", expanded=True, key="dash_exp_sector"): sec = data.get("top3_sectors") or [] if not sec: st.info("当前不可用,稍后重试。") else: sec_cards = [] for row in sec[:3]: price = f"{row.price:.2f}" if row.price is not None else "N/A" ch = row.change_pct ch_s = f"{ch:+.2f}%" if ch is not None else "—" desc = ( SECTOR_SUBTITLE_CN.get(row.ticker, f"{row.ticker} 板块/主题 ETF(代理)") ).strip() sec_cards.append( _stat_card_html( row.ticker, price, f"较昨收 {ch_s}", desc or "(无说明)", positive=_delta_direction(ch), ) ) _render_stat_row(_stat_row_wrap("".join(sec_cards))) news_list = news or st.session_state.get("dashboard_news") or [] news_err = (data.get("llm_news_error") or "").strip() with st.expander("Top 10 财经新闻", expanded=True, key="dash_exp_news"): if news_err: st.warning("新闻情绪解读未完全成功,以下为服务端返回摘要:" + news_err[:280]) if not news_list: st.info("暂无新闻数据(点击「更新市场数据」后可拉取)。") else: cells = "".join(_news_article_html(n) for n in news_list[:10]) st.markdown( f'
{cells}
', unsafe_allow_html=True, )