项目文件夹

文件
2026-07-13 12:36:27 +08:00

423 行
16 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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'<div style="{card}">'
'<div style="font-size:0.82rem;font-weight:600;color:#94a3b8;margin:0 0 6px 0;line-height:1.3;">'
f"{_e(code)}</div>"
'<div style="font-size:1.5rem;font-weight:700;color:#f8fafc;margin:0 0 6px 0;'
'line-height:1.15;letter-spacing:-0.03em;">'
f"{_e(value)}</div>"
f'<div style="font-size:0.88rem;font-weight:600;color:{ch_color};margin:0 0 8px 0;line-height:1.3;">'
f"{_e(change_vs_prev)}</div>"
'<div style="font-size:0.76rem;font-weight:400;color:#64748b;margin:0;line-height:1.45;">'
f"{_e(description)}</div>"
"</div>"
)
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'<div style="{row}">{inner}</div>'
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 = (
'<div style="display:flex;flex-direction:column;gap:0.65rem;width:100%;">'
+ "".join(rows)
+ "</div>"
)
_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 = (
'<div class="ta-digest-prose ta-digest-prose--block" style="'
"background:rgba(15,23,42,0.6);border:1px solid rgba(148,163,184,0.22);"
"border-radius:12px;border-left:4px solid #818cf8;padding:16px 20px 18px 20px;"
"margin-top:6px;font-size:0.92rem;line-height:1.75;color:#e2e8f0;"
'">'
f'<div class="ta-digest-inner">{inner}</div></div>'
)
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:
"""各 ##### 分节统一用 <details>;首节可默认展开(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'<details class="ta-digest-details"{open_attr}>'
f'<summary class="ta-digest-summary">{_e(label)}</summary>'
f'<div class="ta-digest-inner ta-digest-details-body">{inner}</div>'
"</details>"
)
else:
frag = (
f'<details class="ta-digest-details"{open_attr}>'
f'<summary class="ta-digest-summary">{_e(label)}</summary>'
f'<div class="ta-digest-details-body"><pre style="white-space:pre-wrap;margin:0;">'
f"{_e(raw)}</pre></div></details>"
)
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:
"""所有 ##### 分节统一为 <details>(首节默认展开),与「其他总结」折叠样式一致。"""
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'<span class="ta-news-date"> · {html.escape(pub)}</span>' 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 = [
'<article class="ta-news-card">',
f'<p class="ta-news-title">{title}{date_html}</p>',
f'<p class="ta-news-meta">{prov} · <span class="{stance_cls}">{stance_label}</span></p>',
]
if summary:
parts.append(f'<p class="ta-news-body">{html.escape(str(summary))}</p>')
if llm_text:
parts.append('<p class="ta-llm-hdr">大模型解读</p>')
parts.append(f'<p class="ta-llm-body">{html.escape(llm_text)}</p>')
if link:
safe_link = html.escape(str(link), quote=True)
parts.append(
'<p class="ta-news-link-row">'
f'<a href="{safe_link}" class="ta-news-link-original" target="_blank" '
'rel="noopener noreferrer">阅读原文</a>'
"</p>"
)
else:
parts.append('<p class="ta-news-hint">无媒体外链</p>')
parts.append("</article>")
return "".join(parts)
def render_dashboard() -> None:
st.markdown(
'<p class="ta-page-title">仪表盘</p>'
'<p class="ta-page-desc">市场概览与快讯;点击「更新市场数据」将拉取 Yahoo 行情与新闻,'
"并调用 Fast 模型生成新闻解读与大盘总结。</p>",
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"仪表盘 LLMQuick):`{lm}`")
if data is None:
return
st.markdown(
f'<p class="ta-data-as-of">{html.escape(_format_asof_line(data))}</p>',
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'<div class="ta-news-grid" role="list">{cells}</div>',
unsafe_allow_html=True,
)