From 36fda633dfb62bd01d90cdae20fd6bbcf6983fa2 Mon Sep 17 00:00:00 2001 From: wanghep Date: Sat, 18 Apr 2026 22:16:03 +0800 Subject: [PATCH] feat: improve stock detail charts and quote fallbacks --- README.md | 14 + backend/src/lhbfx/queries.py | 6 + backend/src/lhbfx/reporting.py | 32 +- backend/src/lhbfx/sources/tencent.py | 81 +++ .../components/StockActionTimelineChart.vue | 522 ++++++++++++++++++ frontend/src/components/StockDetailScreen.vue | 237 +++++--- .../src/components/WarningCenterScreen.vue | 23 +- frontend/src/composables/useDashboardData.ts | 12 +- frontend/src/utils/format.ts | 7 +- 9 files changed, 851 insertions(+), 83 deletions(-) create mode 100644 backend/src/lhbfx/sources/tencent.py create mode 100644 frontend/src/components/StockActionTimelineChart.vue diff --git a/README.md b/README.md index fbe71bc..1f418bd 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,20 @@ - 预警中心支持卖出预警、慢流出观察等风险信息查看。 - 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。 +## 最近界面与数据调整 + +- 个股详情页的“买卖明细”已改为“买卖力度趋势”图: + - 柱形图按日期展示买入和卖出,买入为正、卖出为负。 + - 折线展示“当日净额”和“累计净额”。 + - 明细仅在鼠标悬浮图表时显示。 +- 个股详情页顶部新增“预警列表”按钮: + - 有预警时红色高亮提示。 + - 点击后以弹层方式展示当前个股预警,不再挤占右侧图表区域。 +- 个股详情、首页候选股、游资详情页的数据补全逻辑已增强: + - 优先读取数据库中的股票元数据。 + - 外部快照失败时增加备用行情源兜底。 + - 收盘后更新流程会同步补全行业、市值、流通市值等字段。 + ## 环境要求 - Python 3.11+ diff --git a/backend/src/lhbfx/queries.py b/backend/src/lhbfx/queries.py index 5495600..67bab8b 100644 --- a/backend/src/lhbfx/queries.py +++ b/backend/src/lhbfx/queries.py @@ -8,6 +8,7 @@ from typing import Any from .db import db_cursor from .sources.eastmoney import EastMoneyClient from .sources.sina import SinaClient +from .sources.tencent import TencentClient def _normalize_value(value: Any) -> Any: @@ -467,6 +468,11 @@ def fetch_stock_detail(stock_code: str) -> dict[str, Any]: quote_snapshot = eastmoney.fetch_quote_snapshot(stock_code) except Exception: quote_snapshot = {} + if not quote_snapshot: + try: + quote_snapshot = TencentClient().fetch_quote_snapshot(stock_code) + except Exception: + quote_snapshot = {} if not market_daily: try: market_daily = SinaClient().fetch_daily_kline(stock_code) diff --git a/backend/src/lhbfx/reporting.py b/backend/src/lhbfx/reporting.py index 028f93b..911e5d0 100644 --- a/backend/src/lhbfx/reporting.py +++ b/backend/src/lhbfx/reporting.py @@ -10,6 +10,7 @@ from .config import AppConfig from .db import db_cursor from .queries import fetch_trader_actions, fetch_watchlist, fetch_warnings from .sources.eastmoney import EastMoneyClient +from .sources.tencent import TencentClient @dataclass(slots=True) @@ -130,30 +131,51 @@ def enrich_stock_metadata(*, config: AppConfig, stock_codes: set[str]) -> None: existing_rows = {row["stock_code"]: row for row in cursor.fetchall()} client = EastMoneyClient() + quote_client = TencentClient() for stock_code in sorted(stock_codes): row = existing_rows.get(stock_code) has_industry = bool(row and row.get("industry")) has_concepts = bool(row and row.get("concept_tags")) - if has_industry and has_concepts: + has_market_value = bool(row and row.get("total_market_value") is not None) + has_circulating_market_value = bool(row and row.get("circulating_market_value") is not None) + if has_industry and has_concepts and has_market_value and has_circulating_market_value: continue profile = client.fetch_company_profile(stock_code) + try: + quote_snapshot = quote_client.fetch_quote_snapshot(stock_code) + except Exception: + quote_snapshot = {} cursor.execute( """ - INSERT INTO stocks (stock_code, stock_name, market, industry, concept_tags) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO stocks ( + stock_code, + stock_name, + market, + industry, + concept_tags, + total_market_value, + circulating_market_value + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE stock_name = COALESCE(NULLIF(VALUES(stock_name), ''), stock_name), market = COALESCE(NULLIF(VALUES(market), ''), market), industry = COALESCE(NULLIF(VALUES(industry), ''), industry), - concept_tags = COALESCE(VALUES(concept_tags), concept_tags) + concept_tags = COALESCE(VALUES(concept_tags), concept_tags), + total_market_value = COALESCE(VALUES(total_market_value), total_market_value), + circulating_market_value = COALESCE(VALUES(circulating_market_value), circulating_market_value) """, ( stock_code, - profile.get("stock_name") or (row.get("stock_name") if row else stock_code), + profile.get("stock_name") + or quote_snapshot.get("stock_name") + or (row.get("stock_name") if row else stock_code), profile.get("market"), profile.get("industry"), json.dumps(profile.get("concept_tags") or [], ensure_ascii=False), + quote_snapshot.get("total_market_value"), + quote_snapshot.get("circulating_market_value"), ), ) diff --git a/backend/src/lhbfx/sources/tencent.py b/backend/src/lhbfx/sources/tencent.py new file mode 100644 index 0000000..54d07a4 --- /dev/null +++ b/backend/src/lhbfx/sources/tencent.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import Any + +import requests + + +DEFAULT_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" + ), + "Referer": "https://finance.qq.com/", +} + + +def infer_symbol(stock_code: str) -> str: + if stock_code.startswith(("6", "9", "5", "688")): + return f"sh{stock_code}" + return f"sz{stock_code}" + + +def _to_float(value: str | None) -> float | None: + if value in (None, "", "-"): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +class TencentClient: + def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]: + symbol = infer_symbol(stock_code) + response = requests.get( + "https://qt.gtimg.cn/q=" + symbol, + headers=DEFAULT_HEADERS, + timeout=20, + ) + response.raise_for_status() + text = response.text + if '"' not in text: + return {} + + payload = text.split('"', 1)[1].rsplit('"', 1)[0] + parts = payload.split("~") + if len(parts) < 49: + return {} + + latest_price = _to_float(parts[3]) + previous_close = _to_float(parts[4]) + open_price = _to_float(parts[5]) + price_chg = _to_float(parts[31]) + pct_chg = _to_float(parts[32]) + high_price = _to_float(parts[33]) + low_price = _to_float(parts[34]) + amount_wan = _to_float(parts[37]) + turnover_pct = _to_float(parts[38]) + amplitude_pct = _to_float(parts[43]) + circulating_market_value_yi = _to_float(parts[44]) + total_market_value_yi = _to_float(parts[45]) + + return { + "stock_code": parts[2] or stock_code, + "stock_name": parts[1] or None, + "latest_price": None if latest_price is None else latest_price * 100, + "high_price": None if high_price is None else high_price * 100, + "low_price": None if low_price is None else low_price * 100, + "open_price": None if open_price is None else open_price * 100, + "amount": None if amount_wan is None else amount_wan * 10000, + "previous_close": None if previous_close is None else previous_close * 100, + "turnover": None if turnover_pct is None else turnover_pct * 100, + "price_chg": None if price_chg is None else price_chg * 100, + "pct_chg": None if pct_chg is None else pct_chg * 100, + "amplitude": None if amplitude_pct is None else amplitude_pct * 100, + "circulating_market_value": ( + None if circulating_market_value_yi is None else circulating_market_value_yi * 100000000 + ), + "total_market_value": None if total_market_value_yi is None else total_market_value_yi * 100000000, + } diff --git a/frontend/src/components/StockActionTimelineChart.vue b/frontend/src/components/StockActionTimelineChart.vue new file mode 100644 index 0000000..915a05a --- /dev/null +++ b/frontend/src/components/StockActionTimelineChart.vue @@ -0,0 +1,522 @@ + + + + + diff --git a/frontend/src/components/StockDetailScreen.vue b/frontend/src/components/StockDetailScreen.vue index 449b8d9..7ae0222 100644 --- a/frontend/src/components/StockDetailScreen.vue +++ b/frontend/src/components/StockDetailScreen.vue @@ -1,6 +1,7 @@