Files
2026-07-13 12:36:27 +08:00

348 lines
12 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yfinance as yf
from web.services.dashboard_store import append_history, load_latest, save_latest
from web.services.dashboard_llm import build_llm_config, enrich_news_opinions, generate_market_digest
from web.services.time_utils import format_et, market_status_text, now_et
INDEX_TICKERS = {
"SPY": "S&P 500 (SPY)",
"QQQ": "Nasdaq 100 (QQQ)",
"DIA": "Dow Jones (DIA)",
"^VIX": "VIX",
}
INDEX_SUBTITLE_CN: dict[str, str] = {
"SPY": "标普500指数 ETF",
"QQQ": "纳斯达克100指数 ETF",
"DIA": "道琼斯工业平均指数 ETF",
"^VIX": "标普500波动率指数(恐慌指数)",
}
SECTOR_PROXIES = [
"XLB", # Materials
"XLC", # Communication Services
"XLE", # Energy
"XLF", # Financials
"XLI", # Industrials
"XLK", # Technology
"XLP", # Consumer Staples
"XLRE", # Real Estate
"XLU", # Utilities
"XLV", # Health Care
"XLY", # Consumer Discretionary
"SOXX", # Semiconductors (theme proxy)
]
SECTOR_SUBTITLE_CN: dict[str, str] = {
"XLB": "原材料板块 ETF",
"XLC": "通信服务板块 ETF",
"XLE": "能源板块 ETF",
"XLF": "金融板块 ETF",
"XLI": "工业板块 ETF",
"XLK": "信息技术板块 ETF",
"XLP": "日常消费板块 ETF",
"XLRE": "房地产板块 ETF",
"XLU": "公用事业板块 ETF",
"XLV": "医疗保健板块 ETF",
"XLY": "可选消费板块 ETF",
"SOXX": "半导体板块 ETF",
}
# Yahoo 可交易代理:美债收益率曲线、美元、通胀保值 ETF(非官方 CPI)
MACRO_ENTRIES: list[tuple[str, str, str]] = [
("^IRX", "美国13周国债收益率", "短期无风险利率锚,影响资金成本预期。"),
("^FVX", "美国5年期国债收益率", "中期利率与增长预期参考。"),
("^TNX", "美国10年期国债收益率", "全球资产定价常用贴现率与风险偏好锚。"),
("^TYX", "美国30年期国债收益率", "长期通胀与期限溢价参考。"),
("DX-Y.NYB", "美元指数", "美元相对一篮子货币强弱。"),
("TIP", "通胀保值债券 ETF", "与通胀预期大致同向;非官方 CPI 读数。"),
]
CACHE_PATH = Path("data/cache/market_snapshot.json")
@dataclass
class QuoteRow:
ticker: str
label: str
price: float | None
prev_close: float | None
change: float | None
change_pct: float | None
def _quote(symbol: str, label: str) -> QuoteRow:
try:
t = yf.Ticker(symbol)
fast = t.fast_info
price = float(fast.get("lastPrice")) if fast.get("lastPrice") is not None else None
prev_close = (
float(fast.get("previousClose")) if fast.get("previousClose") is not None else None
)
if price is not None and prev_close:
change = price - prev_close
change_pct = change / prev_close * 100
else:
change, change_pct = None, None
return QuoteRow(symbol, label, price, prev_close, change, change_pct)
except Exception:
return QuoteRow(symbol, label, None, None, None, None)
def get_macro_strip() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for ticker, label, hint in MACRO_ENTRIES:
q = _quote(ticker, label)
if q.price is not None:
if ticker.startswith("^"):
display_value = f"{q.price:.2f}"
else:
display_value = f"{q.price:.2f}"
else:
display_value = "N/A"
rows.append(
{
"ticker": ticker,
"label": label,
"hint": hint,
"price": q.price,
"change_pct": q.change_pct,
"display_value": display_value,
}
)
return rows
def _format_published_at(item: dict[str, Any], content: dict[str, Any]) -> str:
pub = content.get("pubDate")
if pub:
try:
dt = datetime.fromisoformat(str(pub).replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M")
except ValueError:
pass
ts = item.get("providerPublishTime")
if ts is not None:
try:
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M")
except (TypeError, ValueError, OSError):
pass
return ""
def get_top_news(limit: int = 10) -> list[dict[str, Any]]:
try:
proxy = yf.Ticker("QQQ")
raw = proxy.get_news(count=max(20, limit))
except Exception:
return []
out: list[dict[str, Any]] = []
for item in raw:
content = item.get("content", {})
title = content.get("title", "") or item.get("title", "")
if not title:
continue
summary = content.get("summary", "")
provider = (content.get("provider") or {}).get("displayName", "Unknown")
url_obj = content.get("canonicalUrl") or content.get("clickThroughUrl") or {}
link = url_obj.get("url", "") if isinstance(url_obj, dict) else ""
published_at = _format_published_at(item, content)
out.append(
{
"title": title,
"summary": summary,
"provider": provider,
"link": link,
"published_at": published_at,
}
)
if len(out) >= limit:
break
return out
def get_dashboard_market_snapshot() -> dict[str, Any]:
index_rows = [_quote(tk, name) for tk, name in INDEX_TICKERS.items()]
sectors = [_quote(tk, tk) for tk in SECTOR_PROXIES]
sectors = [s for s in sectors if s.change_pct is not None]
sectors.sort(key=lambda s: s.change_pct or -999, reverse=True)
macro_strip = get_macro_strip()
return {
"indexes": index_rows,
"top3_sectors": sectors[:3],
"top6_sectors": sectors[:6],
"macro_strip": macro_strip,
"market_status": market_status_text(),
"last_updated_et": format_et(now_et()),
}
def _quote_to_dict(q: QuoteRow) -> dict[str, Any]:
return {
"ticker": q.ticker,
"label": q.label,
"price": q.price,
"prev_close": q.prev_close,
"change": q.change,
"change_pct": q.change_pct,
}
def _dict_to_quote(d: dict[str, Any]) -> QuoteRow:
return QuoteRow(
ticker=d.get("ticker", ""),
label=d.get("label", ""),
price=d.get("price"),
prev_close=d.get("prev_close"),
change=d.get("change"),
change_pct=d.get("change_pct"),
)
def _snapshot_from_stored(snap: dict[str, Any]) -> dict[str, Any]:
top6_raw = snap.get("top6_sectors")
if top6_raw is None:
top6_raw = snap.get("top3_sectors", [])
return {
"indexes": [_dict_to_quote(x) for x in snap.get("indexes", [])],
"top3_sectors": [_dict_to_quote(x) for x in top6_raw[:3]],
"top6_sectors": [_dict_to_quote(x) for x in top6_raw],
"macro_strip": snap.get("macro_strip") or [],
"market_status": snap.get("market_status", "N/A"),
"last_updated_et": snap.get("last_updated_et", "N/A"),
"market_digest_md": snap.get("market_digest_md") or "",
"fetched_at_utc": snap.get("fetched_at_utc") or "",
"llm_model": snap.get("llm_model") or "",
"llm_provider": snap.get("llm_provider") or "",
"llm_news_error": snap.get("llm_news_error") or "",
"llm_digest_error": snap.get("llm_digest_error") or "",
}
def save_dashboard_cache(snapshot: dict[str, Any], news: list[dict[str, Any]]) -> None:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = {
"indexes": [_quote_to_dict(x) for x in snapshot.get("indexes", [])],
"top3_sectors": [_quote_to_dict(x) for x in snapshot.get("top6_sectors", [])[:3]],
"top6_sectors": [_quote_to_dict(x) for x in snapshot.get("top6_sectors", [])],
"macro_strip": snapshot.get("macro_strip") or [],
"market_digest_md": snapshot.get("market_digest_md") or "",
"market_status": snapshot.get("market_status", "N/A"),
"last_updated_et": snapshot.get("last_updated_et", "N/A"),
"fetched_at_utc": snapshot.get("fetched_at_utc") or "",
"llm_model": snapshot.get("llm_model") or "",
"llm_provider": snapshot.get("llm_provider") or "",
"llm_news_error": snapshot.get("llm_news_error") or "",
"llm_digest_error": snapshot.get("llm_digest_error") or "",
"news": news,
}
CACHE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def load_dashboard_cache() -> tuple[dict[str, Any] | None, list[dict[str, Any]] | None]:
if not CACHE_PATH.exists():
return None, None
try:
raw = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
snapshot = _snapshot_from_stored(raw)
return snapshot, raw.get("news", [])
except Exception:
return None, None
def load_dashboard_display() -> tuple[dict[str, Any] | None, list[dict[str, Any]] | None]:
"""优先 data/dashboard/latest.json,否则回退 data/cache/market_snapshot.json。"""
latest = load_latest()
if latest and latest.get("snapshot") is not None:
snap = _snapshot_from_stored(latest["snapshot"])
if not snap.get("fetched_at_utc") and latest.get("fetched_at_utc"):
snap["fetched_at_utc"] = latest["fetched_at_utc"]
if not snap.get("llm_model") and latest.get("llm_model"):
snap["llm_model"] = latest["llm_model"]
if not snap.get("llm_provider") and latest.get("llm_provider"):
snap["llm_provider"] = latest["llm_provider"]
return snap, latest.get("news", [])
return load_dashboard_cache()
def _build_store_payload(
snapshot: dict[str, Any],
news: list[dict[str, Any]],
llm_model: str,
fetched_at_utc: str,
) -> dict[str, Any]:
snap_out = {
"indexes": [_quote_to_dict(x) for x in snapshot.get("indexes", [])],
"top3_sectors": [_quote_to_dict(x) for x in snapshot.get("top6_sectors", [])[:3]],
"top6_sectors": [_quote_to_dict(x) for x in snapshot.get("top6_sectors", [])],
"macro_strip": snapshot.get("macro_strip") or [],
"market_digest_md": snapshot.get("market_digest_md") or "",
"market_status": snapshot.get("market_status", "N/A"),
"last_updated_et": snapshot.get("last_updated_et", "N/A"),
"fetched_at_utc": fetched_at_utc,
"llm_model": llm_model,
"llm_provider": snapshot.get("llm_provider") or "",
"llm_news_error": snapshot.get("llm_news_error") or "",
"llm_digest_error": snapshot.get("llm_digest_error") or "",
}
return {
"snapshot": snap_out,
"news": news,
"llm_model": llm_model,
"llm_provider": snapshot.get("llm_provider") or "",
"fetched_at_utc": fetched_at_utc,
}
def refresh_dashboard_cache(
limit: int = 10,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""拉取行情与新闻并调用仪表盘专用 LLM(始终 build_llm_config(None),与个股分析 last_params 无关)。"""
snapshot = get_dashboard_market_snapshot()
news: list[dict[str, Any]] = get_top_news(limit=limit)
cfg = build_llm_config(None)
model_name = cfg.get("quick_think_llm", "")
provider_name = str(cfg.get("llm_provider") or "")
digest_md = ""
news_err = ""
digest_err = ""
try:
news = enrich_news_opinions(news, cfg)
except Exception as e:
news_err = f"{type(e).__name__}: {e}"[:500]
for n in news:
n.setdefault("stance", "unknown")
n.setdefault("llm_summary", "")
try:
digest_md = generate_market_digest(snapshot, news, cfg)
except Exception as e:
digest_err = f"{type(e).__name__}: {e}"[:500]
digest_md = ""
fetched_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
snapshot["market_digest_md"] = digest_md
snapshot["fetched_at_utc"] = fetched_at
snapshot["llm_model"] = model_name
snapshot["llm_provider"] = provider_name
snapshot["llm_news_error"] = news_err
snapshot["llm_digest_error"] = digest_err if not digest_md.strip() else ""
save_dashboard_cache(snapshot, news)
payload = _build_store_payload(snapshot, news, model_name, fetched_at)
save_latest(payload)
append_history(payload)
return snapshot, news