From a4264326bdcf4f27d6ef261eed378a9f86c1d553 Mon Sep 17 00:00:00 2001 From: leefer Date: Fri, 31 Jul 2026 01:03:43 +0800 Subject: [PATCH] migration: preserve market data and search slice --- app/backend/application.py | 931 +------ app/backend/bootstrap/container.py | 6 +- app/backend/data/__init__.py | 9 +- app/backend/data/gateway.py | 8 +- app/backend/data/providers/ifind.py | 2 +- app/backend/data/providers/ifind_client.py | 385 +++ app/backend/data/providers/tushare.py | 2 +- app/backend/data/providers/tushare_client.py | 2175 ++++++++++++++++ app/backend/data/realtime.py | 426 ++++ app/backend/features/market/__init__.py | 13 + app/backend/features/market/charts.py | 497 ++++ app/backend/features/market/repository.py | 222 ++ app/backend/features/market/service.py | 949 +++++++ app/chart_data_provider.py | 498 +--- app/database.py | 219 +- app/ifind_client.py | 386 +-- app/realtime_aggregator.py | 427 +--- app/tests/test_data_gateway.py | 6 +- app/tests/test_preservation_slice_market.py | 159 ++ app/tests/test_stock_detail_realtime.py | 12 +- app/tools/move_class_methods.py | 76 + app/tushare_client.py | 2176 +---------------- docs/migration/evidence/slice-02/README.md | 61 + .../evidence/slice-02/app-light-1280x720.png | Bin 0 -> 94270 bytes docs/migration/保真迁移状态.json | 10 +- docs/migration/保真迁移账本.md | 14 +- 26 files changed, 5035 insertions(+), 4634 deletions(-) create mode 100644 app/backend/data/providers/ifind_client.py create mode 100644 app/backend/data/providers/tushare_client.py create mode 100644 app/backend/data/realtime.py create mode 100644 app/backend/features/market/__init__.py create mode 100644 app/backend/features/market/charts.py create mode 100644 app/backend/features/market/repository.py create mode 100644 app/backend/features/market/service.py create mode 100644 app/tests/test_preservation_slice_market.py create mode 100644 app/tools/move_class_methods.py create mode 100644 docs/migration/evidence/slice-02/README.md create mode 100644 docs/migration/evidence/slice-02/app-light-1280x720.png diff --git a/app/backend/application.py b/app/backend/application.py index 4295902..b38b31a 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -18,7 +18,7 @@ from backend.bootstrap.container import build_application_container from backend.bootstrap.settings import load_runtime_settings from backend.http import HttpTransportMixin from backend.llm import LLMGateway, LLMGatewayError -from chart_data_provider import ChartDataError +from backend.features.market import ChartDataError, MarketServiceMixin from backend.bootstrap.config import ( DATA_DIR, MENTOR_SKILLS_DIR, @@ -39,7 +39,7 @@ from heaven_engine import ( build_personal_field, hexagram_from_lines, ) -from ifind_client import IfindError +from backend.data.providers.ifind_client import IfindError from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection from mentor_agent import MentorAgentError, stream_with_mentor from market_insights import MarketInsightsService @@ -61,7 +61,7 @@ from sentiment_engine import ( build_sentiment_history, latest_contiguous_history, ) -from tushare_client import TushareClient, TushareError, _sector_coverage_issue +from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue SCREENER_LIBRARY_VERSION = 8 @@ -103,23 +103,6 @@ LEGACY_SECRET_KEYS = { "LLM_FALLBACK_MODEL", } -SEARCH_INDEXES = ( - {"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"}, - {"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"}, - {"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"}, -) -SEARCH_TYPE_LABELS = { - "stock": "股票", - "sector": "板块", - "theme": "题材", - "index": "指数", -} -THS_SEARCH_TYPES = { - "I": ("sector", "行业板块"), - "R": ("sector", "地域板块"), - "N": ("theme", "概念题材"), -} - MENTOR_DATA_PROFILES = { "emotion": { "kobe92-perspective", "niepanchongsheng-perspective", @@ -158,7 +141,7 @@ MENTOR_ETF_UNIVERSE = ( ) -class DashboardService: +class DashboardService(MarketServiceMixin): def __init__(self) -> None: runtime = load_runtime_settings() self.vault = SecretVault(runtime.encryption_key) @@ -213,12 +196,6 @@ class DashboardService: initial_delay_seconds=3, ) - def _tushare_client(self) -> TushareClient: - gateway = getattr(self, "data_gateway", None) - if gateway is not None: - return gateway.tushare() - # Compatibility for isolated legacy unit-test service stubs. - return TushareClient(self.token) def _load_system_credentials(self, environment: dict[str, str]) -> dict[str, Any]: encrypted = self.database.get_system_setting("credentials") @@ -756,181 +733,6 @@ class DashboardService: def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]: return AccountService.public_personal_profile(personal) - def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - now = datetime.now().astimezone() - if ( - normalized_date == now.strftime("%Y%m%d") - and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time() - ): - previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True) - if previous: - carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情") - return self._apply_reason_overrides(self._with_storage(carried, cached=True)) - if not force: - snapshot = self.database.get_snapshot(normalized_date) - if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo": - snapshot = copy.deepcopy(snapshot) - if normalized_date != now.strftime("%Y%m%d"): - snapshot.setdefault("meta", {}).update( - {"realtime": False, "market_status": "closed"} - ) - if not self._dashboard_sentiment_ready(snapshot): - snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) - self.database.save_snapshot( - normalized_date, - str((snapshot.get("meta") or {}).get("source") or "tushare"), - snapshot, - ) - snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date) - return self._apply_reason_overrides(self._with_storage(snapshot, cached=True)) - resolved = self.database.get_data_snapshot( - "dashboard_request_v1", normalized_date - ) - if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo": - resolved = copy.deepcopy(resolved) - resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date( - normalized_date - ) - return self._apply_reason_overrides( - self._with_storage(resolved, cached=True) - ) - if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5: - previous = self.database.get_latest_real_snapshot(normalized_date) - if previous: - carried = self._carry_dashboard( - previous, - normalized_date, - "非交易日沿用最近交易日收盘行情", - ) - self.database.save_data_snapshot( - "dashboard_request_v1", normalized_date, "sqlite", carried - ) - return self._apply_reason_overrides( - self._with_storage(carried, cached=True) - ) - return self.sync_dashboard(normalized_date) - - @staticmethod - def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool: - overview = dashboard.get("overview") or {} - return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all( - key in overview - for key in ( - "sentiment_score", - "sentiment_label", - "sentiment_phase", - "sentiment_direction", - "sentiment_components", - ) - ) - - @staticmethod - def _display_compact_date(compact: str) -> str: - return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}" - - def _carry_dashboard( - self, snapshot: dict[str, Any], requested_date: str, reason: str - ) -> dict[str, Any]: - carried = copy.deepcopy(snapshot) - meta = carried.setdefault("meta", {}) - meta.update( - { - "requested_date": self._display_compact_date(requested_date), - "carried_forward": True, - "realtime": False, - "market_status": "closed", - "notice": reason, - } - ) - return carried - - def _realtime_snapshot_due( - self, - normalized_date: str, - snapshot: dict[str, Any], - ) -> bool: - if not self.configured or normalized_date != date.today().strftime("%Y%m%d"): - return False - now = datetime.now().astimezone() - local_time = now.time().replace(tzinfo=None) - realtime_start = datetime.strptime("09:15", "%H:%M").time() - morning_end = datetime.strptime("11:35", "%H:%M").time() - afternoon_start = datetime.strptime("12:55", "%H:%M").time() - realtime_end = datetime.strptime("15:05", "%H:%M").time() - in_session = ( - realtime_start <= local_time < morning_end - or afternoon_start <= local_time < realtime_end - ) - if not in_session: - return False - meta = snapshot.get("meta") or {} - snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "") - if snapshot_trade_date and snapshot_trade_date != normalized_date: - return False - if not meta.get("realtime"): - return True - try: - updated_at = datetime.fromisoformat(str(meta.get("updated_at") or "")) - if updated_at.tzinfo is None: - updated_at = updated_at.replace(tzinfo=now.tzinfo) - except ValueError: - return True - age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds() - return age_seconds >= 8 - - def sync_dashboard(self, trade_date: str) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - source = "tushare" - with self.sync_lock: - sync_id = self.database.start_sync(normalized_date, source) - try: - if not self.configured: - raise TushareError("公共行情尚未配置") - dashboard = self._tushare_client().dashboard(normalized_date) - - dashboard["meta"]["source"] = source - dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date) - dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) - record_count = self._record_count(dashboard) - actual_date = normalize_date( - str(dashboard.get("meta", {}).get("trade_date") or normalized_date) - ) - self.database.save_snapshot(actual_date, source, dashboard) - if actual_date != normalized_date: - dashboard.setdefault("meta", {}).update( - { - "carried_forward": True, - "realtime": False, - "market_status": "closed", - } - ) - self.database.save_data_snapshot( - "dashboard_request_v1", normalized_date, source, dashboard - ) - self.database.finish_sync( - sync_id, - "success", - record_count, - dashboard.get("meta", {}).get("notice", ""), - source, - ) - return self._apply_reason_overrides(self._with_storage(dashboard, cached=False)) - except TushareError as exc: - fallback = self.database.get_latest_real_snapshot(normalized_date) - if fallback: - carried = self._carry_dashboard( - fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}" - ) - self.database.finish_sync( - sync_id, "fallback", self._record_count(carried), str(exc), "tushare" - ) - return self._apply_reason_overrides(self._with_storage(carried, cached=True)) - self.database.finish_sync(sync_id, "failed", message=str(exc)) - raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc - except Exception as exc: - self.database.finish_sync(sync_id, "failed", message=str(exc)) - raise def _enrich_dashboard_sentiment( self, @@ -1126,9 +928,6 @@ class DashboardService: **self.database.status(), } - def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]: - sector = validate_text(sector, "板块名称", 50) - return self.realtime_aggregator.health_snapshot(sector) def _market_insights(self) -> MarketInsightsService: if not self.configured: @@ -3580,663 +3379,6 @@ class DashboardService: "rows": [], } - def _search_market_directory(self) -> list[dict[str, Any]]: - cached = self.database.get_data_snapshot("search_directory", "ths") or {} - cached_items = list(cached.get("items") or []) - if cached_items and int(cached.get("schema_version") or 0) >= 2: - return cached_items - if not self.configured: - return cached_items - - try: - rows = self._tushare_client().query( - "ths_index", - {}, - "ts_code,name,count,exchange,list_date,type", - ) - except TushareError: - return cached_items - - items = [] - for row in rows: - mapping = THS_SEARCH_TYPES.get(str(row.get("type") or "").upper()) - code = str(row.get("ts_code") or "").strip().upper() - name = str(row.get("name") or "").strip() - if not mapping or not code or not name or str(row.get("exchange") or "").upper() != "A": - continue - entity_type, subtitle = mapping - items.append( - { - "id": code, - "code": code, - "name": name, - "type": entity_type, - "subtitle": subtitle, - "member_count": int(float(row.get("count") or 0)), - } - ) - if items: - self.database.save_data_snapshot( - "search_directory", "ths", "tushare", {"schema_version": 2, "items": items} - ) - return items - - @staticmethod - def _search_match_score(item: dict[str, Any], query: str) -> tuple[int, int, str]: - name = str(item.get("name") or "").casefold() - code = str(item.get("code") or item.get("id") or "").casefold() - needle = query.casefold() - if code == needle: - rank = 0 - elif name == needle: - rank = 1 - elif code.startswith(needle): - rank = 2 - elif name.startswith(needle): - rank = 3 - else: - rank = 4 - return rank, len(name), code - - def search_entities(self, query: str, trade_date: str) -> dict[str, Any]: - needle = str(query or "").strip() - normalized_date = normalize_date(trade_date) - groups: dict[str, list[dict[str, Any]]] = { - "stocks": [], - "sectors": [], - "themes": [], - "indices": [], - } - if not needle: - return {"query": "", "trade_date": normalized_date, "groups": groups} - - stocks = [] - for row in self.database.search_stock_master(needle, 12): - stocks.append( - { - "id": str(row.get("code") or ""), - "code": str(row.get("code") or ""), - "name": str(row.get("name") or "--"), - "type": "stock", - "type_label": SEARCH_TYPE_LABELS["stock"], - "industry": str(row.get("industry") or "其他"), - "market": str(row.get("market") or ""), - "subtitle": " · ".join( - part for part in (str(row.get("industry") or ""), str(row.get("market") or "")) if part - ) or "A股", - } - ) - groups["stocks"] = stocks[:8] - - market_items = list(self._search_market_directory()) + [dict(item) for item in SEARCH_INDEXES] - matched = [ - item for item in market_items - if needle.casefold() in str(item.get("name") or "").casefold() - or needle.casefold() in str(item.get("code") or "").casefold() - ] - matched.sort(key=lambda item: self._search_match_score(item, needle)) - group_keys = {"sector": "sectors", "theme": "themes", "index": "indices"} - for item in matched: - group_key = group_keys.get(str(item.get("type") or "")) - if not group_key or len(groups[group_key]) >= 8: - continue - groups[group_key].append( - { - **item, - "type_label": SEARCH_TYPE_LABELS[str(item["type"])], - } - ) - return {"query": needle, "trade_date": normalized_date, "groups": groups} - - def get_search_detail( - self, entity_type: str, identifier: str, trade_date: str - ) -> dict[str, Any]: - entity_type = str(entity_type or "").strip().lower() - identifier = str(identifier or "").strip().upper() - normalized_date = normalize_date(trade_date) - if entity_type not in {"sector", "theme", "index"}: - raise ValueError("搜索详情类型不支持。") - if not re.fullmatch(r"[A-Z0-9.]{3,24}", identifier): - raise ValueError("搜索详情标识无效。") - if not self.configured: - raise ValueError("行情数据源尚未配置。") - - if entity_type == "index": - index_basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None) - if not index_basic: - raise ValueError("暂不支持该指数详情。") - return self._index_search_detail(index_basic, normalized_date) - - directory = self._search_market_directory() - basic = next( - ( - item for item in directory - if item.get("id") == identifier and item.get("type") == entity_type - ), - None, - ) - if not basic: - raise ValueError("未找到对应的板块或题材。") - return self._ths_search_detail(basic, normalized_date) - - def get_intraday_chart( - self, entity_type: str, identifier: str - ) -> dict[str, Any]: - entity_type = str(entity_type or "").strip().lower() - identifier = str(identifier or "").strip().upper() - if entity_type == "stock": - code = validate_stock_code(identifier) - chart = self.chart_data.stock_intraday(code) - type_label = SEARCH_TYPE_LABELS["stock"] - elif entity_type == "index": - basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None) - if not basic: - raise ValueError("暂不支持该指数分时行情。") - chart = self.chart_data.index_intraday(identifier) - type_label = SEARCH_TYPE_LABELS["index"] - elif entity_type in {"sector", "theme"}: - basic = next( - ( - item for item in self._search_market_directory() - if item.get("id") == identifier and item.get("type") == entity_type - ), - None, - ) - if not basic: - raise ValueError("未找到对应的板块或题材。") - chart = self.chart_data.board_intraday(identifier, str(basic.get("name") or "")) - type_label = SEARCH_TYPE_LABELS[entity_type] - else: - raise ValueError("分时行情类型不支持。") - - return { - "meta": { - "trade_date": str(chart.get("trade_date") or ""), - "previous_close": float(chart.get("previous_close") or 0), - }, - "entity": { - "id": identifier, - "code": str(chart.get("code") or identifier), - "name": str(chart.get("name") or ""), - "type": entity_type, - "type_label": type_label, - }, - "points": list(chart.get("points") or []), - } - - def _ths_search_detail( - self, basic: dict[str, Any], trade_date: str - ) -> dict[str, Any]: - client = self._tushare_client() - resolved_date, _ = client.resolve_trade_context(trade_date) - end = datetime.strptime(resolved_date, "%Y%m%d") - start_date = (end - timedelta(days=190)).strftime("%Y%m%d") - identifier = str(basic["id"]) - snapshot = client.sector_snapshot(identifier, resolved_date) - rows = client.query( - "ths_daily", - {"ts_code": identifier, "start_date": start_date, "end_date": resolved_date}, - "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate,total_mv,float_mv", - ) - rows.sort(key=lambda item: str(item.get("trade_date") or "")) - series = [ - { - "trade_date": self._display_compact_date(str(row.get("trade_date") or "")), - "open": float(row.get("open") or 0), - "high": float(row.get("high") or 0), - "low": float(row.get("low") or 0), - "close": float(row.get("close") or 0), - "change": float(row.get("pct_change") or 0), - "volume": float(row.get("vol") or 0), - "turnover_rate": float(row.get("turnover_rate") or 0), - } - for row in rows[-90:] - ] - try: - chart_series = self.chart_data.board_daily(identifier, resolved_date, 90) - if chart_series: - series = chart_series - except (AttributeError, ChartDataError): - pass - latest = series[-1] if series else {} - snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date - change = float( - snapshot.get("change") - if snapshot_is_current and snapshot.get("change") is not None - else latest.get("change") or 0 - ) - if latest.get("realtime"): - change = float(latest.get("change") or 0) - turnover_rate = float( - snapshot.get("turnover_rate") - if snapshot_is_current and snapshot.get("turnover_rate") is not None - else latest.get("turnover_rate") or 0 - ) - metrics = [ - {"label": "涨跌幅", "value": round(change, 2), "unit": "%", "tone": "change"}, - {"label": "换手率", "value": round(turnover_rate, 2), "unit": "%"}, - {"label": "成份数量", "value": int(float(basic.get("member_count") or 0)), "unit": "只"}, - ] - up_count = int(float(snapshot.get("up_count") or 0)) - down_count = int(float(snapshot.get("down_count") or 0)) - if up_count or down_count: - metrics.extend( - [ - {"label": "上涨家数", "value": up_count, "unit": "家"}, - {"label": "下跌家数", "value": down_count, "unit": "家"}, - ] - ) - leader = str(snapshot.get("leader") or "").strip() - if leader and leader != "--": - metrics.extend( - [ - {"label": "领涨标的", "value": leader, "unit": ""}, - {"label": "领涨幅", "value": round(float(snapshot.get("leading_pct") or 0), 2), "unit": "%", "tone": "change"}, - ] - ) - return { - "meta": { - "trade_date": self._display_compact_date(resolved_date), - "realtime": bool(snapshot.get("realtime")), - }, - "entity": { - "id": identifier, - "code": identifier, - "name": str(snapshot.get("name") or basic.get("name") or "--"), - "type": str(basic.get("type") or "sector"), - "type_label": SEARCH_TYPE_LABELS[str(basic.get("type") or "sector")], - "subtitle": str(basic.get("subtitle") or ""), - "value": float(latest.get("close") or 0), - "change": change, - }, - "series": series, - "metrics": metrics, - } - - def _index_search_detail( - self, basic: dict[str, Any], trade_date: str - ) -> dict[str, Any]: - client = self._tushare_client() - resolved_date, _ = client.resolve_trade_context(trade_date) - payload = ( - client.realtime_market_indices(resolved_date) - if client.should_use_realtime(trade_date, resolved_date) - else client.market_indices(resolved_date, 90) - ) - current = next( - (item for item in payload.get("indices") or [] if item.get("ts_code") == basic["id"]), - None, - ) - if not current: - raise ValueError("该指数暂无可用行情。") - end = datetime.strptime(resolved_date, "%Y%m%d") - rows = client.query( - "index_daily", - { - "ts_code": basic["id"], - "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"), - "end_date": resolved_date, - }, - "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", - ) - rows.sort(key=lambda item: str(item.get("trade_date") or "")) - series = [ - { - "trade_date": self._display_compact_date(str(row.get("trade_date") or "")), - "open": float(row.get("open") or 0), - "high": float(row.get("high") or 0), - "low": float(row.get("low") or 0), - "close": float(row.get("close") or 0), - "change": float(row.get("pct_chg") or 0), - "volume": float(row.get("vol") or 0), - } - for row in rows[-90:] - ] - try: - chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90) - if chart_series: - series = chart_series - except (AttributeError, ChartDataError): - pass - latest = series[-1] if series else {} - latest_close = float(latest.get("close") or current.get("close") or 0) - latest_change = float(latest.get("change") or current.get("pct_chg") or 0) - - def series_return(days: int) -> float: - if len(series) <= days: - return 0.0 - previous = float(series[-days - 1].get("close") or 0) - return (latest_close / previous - 1) * 100 if previous > 0 else 0.0 - return { - "meta": { - "trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)), - "realtime": bool(payload.get("realtime")), - }, - "entity": { - **basic, - "type_label": SEARCH_TYPE_LABELS["index"], - "value": latest_close, - "change": latest_change, - }, - "series": series, - "metrics": [ - {"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"}, - {"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"}, - {"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"}, - {"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"}, - ], - } - - def get_stock_detail( - self, code: str, trade_date: str, force: bool = False - ) -> dict[str, Any]: - code = validate_stock_code(code) - normalized_date = normalize_date(trade_date) - cache_key = f"{code}:{normalized_date}" - if not force: - cached = self.database.get_data_snapshot("stock_detail", cache_key) - if cached and str((cached.get("meta") or {}).get("source") or "") != "demo": - if not self._stock_detail_cache_needs_refresh(cached, normalized_date): - cached["meta"] = {**cached.get("meta", {}), "cached": True} - return self._prepare_stock_detail(cached, code, normalized_date) - - name, sector = self._stock_identity(code, normalized_date) - source = "tushare" - if self.configured: - try: - payload = self._tushare_client().stock_detail( - tushare_code(code), normalized_date - ) - if not payload.get("prices"): - raise TushareError("No price history returned") - except TushareError as exc: - payload = self.database.get_latest_data_snapshot( - "stock_detail", f"{code}:", cache_key, exclude_source="demo" - ) - if not payload: - raise ValueError(f"暂无 {code} 的真实行情数据:{exc}") from exc - payload = copy.deepcopy(payload) - payload["meta"] = { - **payload.get("meta", {}), - "cached": True, - "notice": "最新行情暂不可用,已沿用最近真实收盘数据。", - } - return self._prepare_stock_detail(payload, code, normalized_date) - else: - payload = self.database.get_latest_data_snapshot( - "stock_detail", f"{code}:", cache_key, exclude_source="demo" - ) - if not payload: - raise ValueError(f"暂无 {code} 的真实行情数据,请等待后台完成首次同步。") - payload = copy.deepcopy(payload) - payload["meta"] = { - **payload.get("meta", {}), - "cached": True, - "notice": "公共行情尚未配置,已沿用最近真实收盘数据。", - } - return self._prepare_stock_detail(payload, code, normalized_date) - payload["meta"]["source"] = source - payload["meta"]["cached"] = False - self.database.save_data_snapshot("stock_detail", cache_key, source, payload) - return self._prepare_stock_detail(payload, code, normalized_date) - - @staticmethod - def _stock_detail_bar_date(payload: dict[str, Any]) -> str: - prices = list(payload.get("prices") or []) - return str((prices[-1] if prices else {}).get("trade_date") or "").replace("-", "") - - def _stock_detail_cache_needs_refresh( - self, payload: dict[str, Any], requested_date: str - ) -> bool: - now = datetime.now().astimezone() - return ( - requested_date == now.strftime("%Y%m%d") - and now.time().replace(tzinfo=None) >= dt_time(15, 0) - and self._stock_detail_bar_date(payload) < requested_date - ) - - def _prepare_stock_detail( - self, payload: dict[str, Any], code: str, requested_date: str - ) -> dict[str, Any]: - result = copy.deepcopy(payload) - now = datetime.now().astimezone() - try: - result["prices"] = self.chart_data.stock_daily(code, requested_date, 90) - result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"} - except (AttributeError, ChartDataError): - pass - result = self._sanitize_stock_detail_prices(result, now) - actual_date = self._stock_detail_bar_date(result) - if actual_date: - result["meta"] = { - **(result.get("meta") or {}), - "trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}", - } - today = now.strftime("%Y%m%d") - should_merge = ( - requested_date == today - and actual_date <= today - and now.weekday() < 5 - and now.time().replace(tzinfo=None) >= dt_time(9, 30) - ) - if should_merge: - quote = self._ifind_realtime_stock_quote(code) - if quote and self._valid_realtime_stock_quote(quote, today): - self._merge_realtime_stock_detail(result, quote, requested_date) - elif self.configured and actual_date < today: - client = self._tushare_client() - try: - resolved_date, _ = client.resolve_trade_context(requested_date) - if resolved_date == today: - quote = client.realtime_stock_quote(tushare_code(code), requested_date) - if self._valid_realtime_stock_quote(quote, today): - self._merge_realtime_stock_detail(result, quote, requested_date) - except TushareError: - pass - return self._enrich_stock_detail(result) - - @staticmethod - def _sanitize_stock_detail_prices( - payload: dict[str, Any], market_now: datetime - ) -> dict[str, Any]: - result = copy.deepcopy(payload) - raw_prices = list(result.get("prices") or []) - raw_latest_date = str( - (raw_prices[-1] if raw_prices else {}).get("trade_date") or "" - ).replace("-", "") - prices = [] - for bar in raw_prices: - open_price = float(bar.get("open") or 0) - high = float(bar.get("high") or 0) - low = float(bar.get("low") or 0) - close = float(bar.get("close") or 0) - if ( - open_price > 0 - and high >= max(open_price, close) - and 0 < low <= min(open_price, close) - and close > 0 - ): - prices.append(bar) - - today = market_now.strftime("%Y%m%d") - market_open = ( - market_now.weekday() < 5 - and market_now.time().replace(tzinfo=None) >= dt_time(9, 30) - ) - if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today: - current = prices[-1] - has_market_activity = ( - float(current.get("volume") or 0) > 0 - or float(current.get("amount_billion") or 0) > 0 - ) - if not market_open or not has_market_activity: - prices.pop() - - if raw_latest_date == today and ( - not prices - or str(prices[-1].get("trade_date") or "").replace("-", "") != today - ): - result["meta"] = {**(result.get("meta") or {}), "realtime": False} - - result["prices"] = prices - if prices: - latest = prices[-1] - stock = dict(result.get("stock") or {}) - stock.update( - { - "price": float(latest.get("close") or 0), - "change": float(latest.get("change") or 0), - "amount_billion": float(latest.get("amount_billion") or 0), - } - ) - result["stock"] = stock - return result - - @staticmethod - def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool: - price = float(quote.get("price") or 0) - open_price = float(quote.get("open") or 0) - high = float(quote.get("high") or 0) - low = float(quote.get("low") or 0) - volume = float(quote.get("volume") or 0) - amount = float(quote.get("amount_billion") or 0) - quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "") - return ( - price > 0 - and open_price > 0 - and high >= max(open_price, price) - and 0 < low <= min(open_price, price) - and (volume > 0 or amount > 0) - and (not quote_date or quote_date == trade_date) - ) - - def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None: - ifind = getattr(self, "ifind", None) - if not ifind or not ifind.configured: - return None - try: - rows = ifind.real_time( - tushare_code(code), - [ - "open", "high", "low", "latest", "preClose", - "volume", "amount", "turnoverRatio", - ], - cache_ttl=10, - ) - except IfindError: - return None - row = rows[0] if rows else {} - price = float(row.get("latest") or 0) - previous_close = float(row.get("preClose") or 0) - if price <= 0: - return None - change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0 - stock = self._stock_identity(code, date.today().strftime("%Y%m%d")) - return { - "name": stock[0], - "sector": stock[1], - "price": price, - "open": float(row.get("open") or price), - "high": float(row.get("high") or price), - "low": float(row.get("low") or price), - "change": round(change, 4), - "volume": float(row.get("volume") or 0), - "volume_unit": "lots", - "amount_billion": float(row.get("amount") or 0) / 100_000_000, - "turnover_rate": float(row.get("turnoverRatio") or 0), - "quote_time": str(row.get("time") or ""), - } - - @staticmethod - def _merge_realtime_stock_detail( - payload: dict[str, Any], quote: dict[str, Any], trade_date: str - ) -> None: - display_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}" - realtime_bar = { - "trade_date": display_date, - "open": quote["open"], - "high": quote["high"], - "low": quote["low"], - "close": quote["price"], - "change": quote["change"], - "volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100, - "amount_billion": quote["amount_billion"], - "realtime": True, - } - prices = list(payload.get("prices") or []) - if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == trade_date: - prices[-1] = realtime_bar - else: - prices.append(realtime_bar) - payload["prices"] = prices[-90:] - stock = dict(payload.get("stock") or {}) - stock.update( - { - "name": quote["name"], - "industry": quote["sector"], - "price": quote["price"], - "change": quote["change"], - "amount_billion": quote["amount_billion"], - "turnover_rate": quote["turnover_rate"], - } - ) - payload["stock"] = stock - payload["meta"] = { - **(payload.get("meta") or {}), - "trade_date": display_date, - "realtime": True, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - } - - def get_stock_preview( - self, code: str, trade_date: str, force: bool = False - ) -> dict[str, Any]: - code = validate_stock_code(code) - # Hover previews deliberately follow the latest market day, independent - # from the review date selected by the page. - detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force) - detail_meta = detail.get("meta") or {} - resolved_date = str(detail_meta.get("trade_date") or trade_date) - intraday_points: list[dict[str, Any]] = [] - intraday_status = "unavailable" - intraday_notice = "分时行情暂不可用。" - - intraday_trade_date = "" - intraday_previous_close = 0.0 - try: - intraday = self.chart_data.stock_intraday(code) - intraday_points = list(intraday.get("points") or []) - intraday_trade_date = str(intraday.get("trade_date") or "") - intraday_previous_close = float(intraday.get("previous_close") or 0) - if intraday_points: - intraday_status = "available" - intraday_notice = "" - else: - intraday_status = "empty" - intraday_notice = "最近交易日暂无分时数据。" - except ChartDataError: - intraday_status = "unavailable" - intraday_notice = "分时行情暂不可用,请稍后重试。" - - prices = list(detail.get("prices") or [])[-60:] - stock = dict(detail.get("stock") or {"code": code}) - realtime = bool(detail_meta.get("realtime")) - return { - "meta": { - "trade_date": resolved_date, - "source": detail_meta.get("source") or "unavailable", - "notice": detail_meta.get("notice") or "", - "intraday_status": intraday_status, - "intraday_notice": intraday_notice, - "intraday_trade_date": intraday_trade_date, - "intraday_previous_close": intraday_previous_close, - "realtime": realtime, - "refresh_interval_seconds": 10 if realtime else 0, - }, - "stock": stock, - "prices": prices, - "intraday": intraday_points, - } def save_reason(self, trade_date: str, code: str, reason: str) -> None: normalized_date = normalize_date(trade_date) @@ -4246,55 +3388,6 @@ class DashboardService: raise ValueError("涨停原因应为 1 至 200 个字符。") self.database.save_reason_override(normalized_date, code, reason) - def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]: - start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date() - end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date() - if start > end: - raise ValueError("开始日期不能晚于结束日期。") - weekdays = [] - current = start - while current <= end: - if current.weekday() < 5: - weekdays.append(current) - current += timedelta(days=1) - if len(weekdays) > 15: - raise ValueError("单次最多回补 15 个工作日。") - results = [] - for day in weekdays: - dashboard = self.sync_dashboard(day.strftime("%Y%m%d")) - results.append( - { - "requested_date": day.isoformat(), - "trade_date": dashboard["meta"]["trade_date"], - "source": dashboard["meta"]["source"], - "records": self._record_count(dashboard), - } - ) - return results - - def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]: - snapshot = self.database.get_snapshot(trade_date) or {} - for key in ("limits", "broken", "down_limits"): - for row in snapshot.get(key) or []: - if str(row.get("code")) == code: - return row.get("name") or "--", row.get("sector") or "其他" - for item in self.database.list_watchlist(self.current_user_id): - if item["code"] == code: - return item["name"], item["sector"] or "其他" - return "--", "其他" - - def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]: - result = dict(payload) - stock = dict(payload.get("stock") or {}) - code = str(stock.get("code") or "") - watched = { - item["code"]: item - for item in self.database.list_watchlist(self.current_user_id) - } - stock["watchlist"] = watched.get(code) - result["stock"] = stock - result["notes"] = self.database.list_notes(self.current_user_id, code=code) - return result def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]: trade_date = str(dashboard.get("meta", {}).get("trade_date", "")).replace("-", "") @@ -4578,22 +3671,6 @@ class DashboardService: } return result - def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]: - result = dict(dashboard) - result["meta"] = { - **dashboard.get("meta", {}), - "storage": "sqlite", - "cached": cached, - } - return result - - @staticmethod - def _record_count(dashboard: dict[str, Any]) -> int: - return sum( - len(dashboard.get(key) or []) - for key in ("limits", "broken", "down_limits", "yesterday_limits") - ) - SERVICE = DashboardService() diff --git a/app/backend/bootstrap/container.py b/app/backend/bootstrap/container.py index 6131c40..0929a06 100644 --- a/app/backend/bootstrap/container.py +++ b/app/backend/bootstrap/container.py @@ -10,12 +10,12 @@ from backend.features.alerts import AlertService from backend.features.review import TradeJournalService from backend.features.screener import StrategyTrackingService from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository -from chart_data_provider import MarketChartClient from database import ReviewDatabase -from ifind_client import IfindHttpClient from mentor_agent import MentorSkillRegistry -from realtime_aggregator import WebRealtimeAggregator from screener import ScreenerEngine +from backend.data.providers.ifind_client import IfindHttpClient +from backend.data.realtime import WebRealtimeAggregator +from backend.features.market.charts import MarketChartClient @dataclass(frozen=True) diff --git a/app/backend/data/__init__.py b/app/backend/data/__init__.py index 2b06ec9..1c8c7bd 100644 --- a/app/backend/data/__init__.py +++ b/app/backend/data/__init__.py @@ -1,4 +1,3 @@ -from .gateway import DataGateway, build_data_gateway from .policy import DataPolicyError, DataSourcePolicy from .quality import DataQualityError, DataQualityGate, QualityEvidence, QualityReport @@ -12,3 +11,11 @@ __all__ = [ "QualityReport", "build_data_gateway", ] + + +def __getattr__(name: str): + if name in {"DataGateway", "build_data_gateway"}: + from .gateway import DataGateway, build_data_gateway + + return {"DataGateway": DataGateway, "build_data_gateway": build_data_gateway}[name] + raise AttributeError(name) diff --git a/app/backend/data/gateway.py b/app/backend/data/gateway.py index 933bc93..fcd436c 100644 --- a/app/backend/data/gateway.py +++ b/app/backend/data/gateway.py @@ -8,10 +8,10 @@ from backend.data.contracts import DataUsage from backend.data.policy import DataSourcePolicy from backend.data.providers import IfindProvider, TushareProvider from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport -from chart_data_provider import EastmoneyChartClient, MarketChartClient -from ifind_client import IfindHttpClient -from realtime_aggregator import WebRealtimeAggregator -from tushare_client import TushareClient +from backend.data.providers.ifind_client import IfindHttpClient +from backend.data.providers.tushare_client import TushareClient +from backend.data.realtime import WebRealtimeAggregator +from backend.features.market.charts import EastmoneyChartClient, MarketChartClient @dataclass(frozen=True) diff --git a/app/backend/data/providers/ifind.py b/app/backend/data/providers/ifind.py index c64a4dd..b663416 100644 --- a/app/backend/data/providers/ifind.py +++ b/app/backend/data/providers/ifind.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ifind_client import IfindHttpClient +from backend.data.providers.ifind_client import IfindHttpClient class IfindProvider: diff --git a/app/backend/data/providers/ifind_client.py b/app/backend/data/providers/ifind_client.py new file mode 100644 index 0000000..2b30fd9 --- /dev/null +++ b/app/backend/data/providers/ifind_client.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import copy +import json +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime, timedelta +from typing import Any + + +class IfindError(RuntimeError): + pass + + +class IfindHttpClient: + BASE_URL = "https://quantapi.51ifind.com/api/v1" + AUTH_ENDPOINT = "get_access_token" + AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303} + + def __init__( + self, + refresh_token: str = "", + access_token: str = "", + timeout: int = 15, + ) -> None: + self.timeout = max(3, int(timeout)) + self._refresh_token = str(refresh_token or "").strip() + self._access_token = str(access_token or "").strip() + self._access_expires_at: datetime | None = None + self._token_lock = threading.Lock() + self._cache_lock = threading.Lock() + self._cache: dict[str, dict[str, Any]] = {} + + @property + def configured(self) -> bool: + return bool(self._refresh_token or self._access_token) + + def set_credentials(self, refresh_token: str, access_token: str = "") -> None: + refresh_token = str(refresh_token or "").strip() + access_token = str(access_token or "").strip() + with self._token_lock: + refresh_changed = refresh_token != self._refresh_token + self._refresh_token = refresh_token + if access_token or refresh_changed: + self._access_token = access_token + self._access_expires_at = None + if refresh_changed: + with self._cache_lock: + self._cache.clear() + + def status(self) -> dict[str, Any]: + return { + "configured": self.configured, + "access_ready": bool(self._access_token), + "access_expires_at": ( + self._access_expires_at.isoformat(timespec="seconds") + if self._access_expires_at + else "" + ), + } + + def test_connection(self) -> dict[str, Any]: + payload = self.real_time( + "000001.SH", + ["open", "high", "low", "latest", "preClose"], + cache_ttl=0, + ) + return { + "ok": bool(payload), + "sample_time": str(payload[0].get("time") or "") if payload else "", + } + + def real_time( + self, + codes: str | list[str], + indicators: list[str], + cache_ttl: int = 10, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "real_time_quotation", + {"codes": code_text, "indicators": ",".join(indicators)}, + cache_key=f"rq:{code_text}:{','.join(indicators)}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def history( + self, + codes: str | list[str], + indicators: list[str], + start_date: str, + end_date: str, + cache_ttl: int = 300, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "cmd_history_quotation", + { + "codes": code_text, + "indicators": ",".join(indicators), + "startdate": self._display_date(start_date), + "enddate": self._display_date(end_date), + "functionpara": {"CPS": "forward1", "Fill": "Omit"}, + }, + cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def intraday( + self, + code: str, + start_time: str, + end_time: str, + cache_ttl: int = 20, + ) -> list[dict[str, Any]]: + indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"] + payload = self._request( + "high_frequency", + { + "codes": self._codes(code), + "indicators": ",".join(indicators), + "starttime": start_time, + "endtime": end_time, + "functionpara": { + "CPS": "forward1", + "Fill": "Previous", + "Timeformat": "LocalTime", + "Interval": "1", + "Limitstart": "09:30:00", + "Limitend": "15:00:00", + }, + }, + cache_key=f"hf:{code}:{start_time}:{end_time}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def snapshots( + self, + codes: str | list[str], + indicators: list[str], + start_time: str, + end_time: str, + cache_ttl: int = 8, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "snap_shot", + { + "codes": code_text, + "indicators": ",".join(indicators), + "starttime": start_time, + "endtime": end_time, + }, + cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]: + normalized = " ".join(str(query or "").split()) + if not normalized: + raise IfindError("问财查询不能为空。") + payload = self._request( + "smart_stock_picking", + {"searchstring": normalized, "searchtype": search_type}, + cache_key=f"wc:{search_type}:{normalized}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def report_query( + self, + codes: str | list[str], + begin_date: str, + end_date: str, + cache_ttl: int = 300, + ) -> list[dict[str, Any]]: + code_text = self._codes(codes) + payload = self._request( + "report_query", + { + "codes": code_text, + "beginrDate": self._display_date(begin_date), + "endrDate": self._display_date(end_date), + "outputpara": ( + "reportDate:Y,thscode:Y,secName:Y,ctime:Y," + "reportTitle:Y,pdfURL:Y,seq:Y" + ), + }, + cache_key=f"report:{code_text}:{begin_date}:{end_date}", + cache_ttl=cache_ttl, + ) + return self._table_rows(payload) + + def _request( + self, + endpoint: str, + body: dict[str, Any], + cache_key: str = "", + cache_ttl: int = 0, + ) -> dict[str, Any]: + if not self.configured: + raise IfindError("iFinD 尚未配置。") + if cache_key and cache_ttl > 0: + cached = self._cached(cache_key, cache_ttl) + if cached is not None: + return cached + + payload = self._post(endpoint, body, self._ensure_access_token()) + if self._is_auth_error(payload) and self._refresh_token: + self._invalidate_access_token() + payload = self._post(endpoint, body, self._ensure_access_token(force=True)) + self._validate_payload(payload) + if cache_key and cache_ttl > 0: + with self._cache_lock: + self._cache[cache_key] = { + "created_at": time.time(), + "payload": copy.deepcopy(payload), + } + return payload + + def _ensure_access_token(self, force: bool = False) -> str: + with self._token_lock: + now = datetime.now().astimezone().replace(tzinfo=None) + token_valid = bool(self._access_token) and ( + self._access_expires_at is None + or self._access_expires_at > now + timedelta(minutes=2) + ) + if token_valid and not force: + return self._access_token + if not self._refresh_token: + if self._access_token: + return self._access_token + raise IfindError("iFinD Refresh Token 尚未配置。") + payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token) + self._validate_payload(payload) + data = payload.get("data") or {} + token = str(data.get("access_token") or "").strip() + if not token: + raise IfindError("iFinD 未返回 Access Token。") + expires_at = self._parse_datetime(data.get("expired_time")) + self._access_token = token + self._access_expires_at = expires_at + return token + + def _post( + self, + endpoint: str, + body: dict[str, Any], + access_token: str, + refresh_token: str = "", + ) -> dict[str, Any]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "XiaobaiReviewWeb/1.0", + "ifindlang": "cn", + } + if access_token: + headers["access_token"] = access_token + if refresh_token: + headers["refresh_token"] = refresh_token + request = urllib.request.Request( + f"{self.BASE_URL}/{endpoint}", + data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail_payload = json.loads(exc.read().decode("utf-8", errors="replace")) + detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "") + except (json.JSONDecodeError, OSError): + pass + raise IfindError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + raise IfindError("iFinD 数据请求失败。") from exc + if not isinstance(payload, dict): + raise IfindError("iFinD 返回格式不正确。") + return payload + + def _cached(self, key: str, ttl: int) -> dict[str, Any] | None: + with self._cache_lock: + cached = self._cache.get(key) + if not cached: + return None + if time.time() - float(cached.get("created_at") or 0) > ttl: + self._cache.pop(key, None) + return None + return copy.deepcopy(cached["payload"]) + + def _invalidate_access_token(self) -> None: + with self._token_lock: + self._access_token = "" + self._access_expires_at = None + + @classmethod + def _validate_payload(cls, payload: dict[str, Any]) -> None: + try: + error_code = int(payload.get("errorcode") or 0) + except (TypeError, ValueError): + error_code = -1 + if error_code != 0: + message = str(payload.get("errmsg") or "未知错误") + raise IfindError(f"iFinD 返回错误:{message[:200]}") + + @classmethod + def _is_auth_error(cls, payload: dict[str, Any]) -> bool: + try: + error_code = int(payload.get("errorcode") or 0) + except (TypeError, ValueError): + error_code = 0 + message = str(payload.get("errmsg") or "").casefold() + return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message + + @staticmethod + def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + tables = payload.get("tables") or [] + if isinstance(tables, dict): + tables = [tables] + rows: list[dict[str, Any]] = [] + for block in tables if isinstance(tables, list) else []: + if not isinstance(block, dict): + continue + table = block.get("table") or {} + if not isinstance(table, dict): + continue + times = block.get("time") or [] + codes = block.get("thscode") or block.get("thscodes") or [] + if isinstance(codes, str): + codes = [codes] + lengths = [len(value) for value in table.values() if isinstance(value, list)] + row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0]) + for index in range(row_count): + row: dict[str, Any] = {} + if isinstance(times, list) and index < len(times): + row["time"] = times[index] + if codes: + row["thscode"] = codes[index] if index < len(codes) else codes[0] + for field, values in table.items(): + if isinstance(values, list): + row[field] = values[index] if index < len(values) else None + elif index == 0: + row[field] = values + rows.append(row) + return rows + + @staticmethod + def _codes(codes: str | list[str]) -> str: + if isinstance(codes, list): + values = [str(code or "").strip().upper() for code in codes] + else: + values = [part.strip().upper() for part in str(codes or "").split(",")] + values = [value for value in values if value] + if not values: + raise IfindError("iFinD 证券代码不能为空。") + if len(values) > 100: + raise IfindError("iFinD 单次证券代码过多。") + return ",".join(values) + + @staticmethod + def _display_date(value: str) -> str: + compact = str(value or "").replace("-", "") + if len(compact) != 8 or not compact.isdigit(): + raise IfindError("iFinD 日期格式不正确。") + return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}" + + @staticmethod + def _parse_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text) + except ValueError: + return None diff --git a/app/backend/data/providers/tushare.py b/app/backend/data/providers/tushare.py index a2eeda0..cfae27b 100644 --- a/app/backend/data/providers/tushare.py +++ b/app/backend/data/providers/tushare.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from tushare_client import TushareClient +from backend.data.providers.tushare_client import TushareClient class TushareProvider: diff --git a/app/backend/data/providers/tushare_client.py b/app/backend/data/providers/tushare_client.py new file mode 100644 index 0000000..a2f65b0 --- /dev/null +++ b/app/backend/data/providers/tushare_client.py @@ -0,0 +1,2175 @@ +from __future__ import annotations + +import json +import math +import re +import urllib.error +import urllib.request +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, time as dt_time, timedelta +from threading import Lock +from typing import Any, ClassVar + +from sentiment_engine import apply_sentiment_to_dashboard + + +TUSHARE_URL = "http://api.tushare.pro" + + +class TushareError(RuntimeError): + pass + + +@dataclass +class TushareClient: + token: str + timeout: int = 30 + _realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {} + _realtime_reference_lock: ClassVar[Lock] = Lock() + _capital_cache: ClassVar[dict[str, dict[str, Any]]] = {} + _latest_realtime_market: ClassVar[dict[str, dict[str, Any]]] = {} + _stock_activity_cache: ClassVar[dict[str, dict[str, Any]]] = {} + _stock_listing_cache: ClassVar[dict[str, Any]] = {} + _stock_listing_lock: ClassVar[Lock] = Lock() + _suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {} + _suspension_lock: ClassVar[Lock] = Lock() + + def query( + self, + api_name: str, + params: dict[str, Any] | None = None, + fields: str = "", + ) -> list[dict[str, Any]]: + payload = json.dumps( + { + "api_name": api_name, + "token": self.token, + "params": params or {}, + "fields": fields, + } + ).encode("utf-8") + request = urllib.request.Request( + TUSHARE_URL, + data=payload, + headers={"Content-Type": "application/json", "User-Agent": "XiaobaiReviewWeb/0.2"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + result = json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + raise TushareError(f"Tushare request failed: {exc}") from exc + + if result.get("code") != 0: + raise TushareError(result.get("msg") or "Tushare returned an unknown error") + + data = result.get("data") or {} + columns = data.get("fields") or [] + return [dict(zip(columns, item)) for item in data.get("items") or []] + + def dashboard(self, requested_date: str) -> dict[str, Any]: + trade_date, previous_trade_date = self.resolve_trade_context(requested_date) + if self.should_use_realtime(requested_date, trade_date): + return self._realtime_dashboard( + requested_date, + trade_date, + previous_trade_date, + ) + + daily = self._load_daily(trade_date) + if ( + not daily + and requested_date == datetime.now().astimezone().strftime("%Y%m%d") + and trade_date == requested_date + and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15) + ): + return self._realtime_dashboard( + requested_date, + trade_date, + previous_trade_date, + ) + if not daily: + raise TushareError(f"No daily data returned for {trade_date}") + + notices: list[str] = [] + try: + limit_rows = self._load_limit_lists(trade_date) + previous_limit_rows = self._load_limit_type(previous_trade_date, "U") + if not limit_rows: + notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。") + limit_rows = self._derive_limits(trade_date, daily) + except TushareError as exc: + notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}") + limit_rows = self._derive_limits(trade_date, daily) + previous_daily = self._load_daily(previous_trade_date) + previous_limit_rows = [ + row for row in self._derive_limits(previous_trade_date, previous_daily) + if row.get("limit_type") == "U" + ] + + up_rows = [row for row in limit_rows if row.get("limit_type") == "U"] + down_rows = [row for row in limit_rows if row.get("limit_type") == "D"] + broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"] + limits = [self._normalize_limit(row, "涨停") for row in up_rows] + broken = [self._normalize_limit(row, "炸板") for row in broken_rows] + down_limits = [self._normalize_limit(row, "跌停") for row in down_rows] + previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows] + yesterday_limits = _build_yesterday_performance( + previous_limits, + daily, + limits, + broken, + down_limits, + ) + sectors = _build_sectors(limits) + previous_sectors = _build_sectors(previous_limits) + + dashboard = { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(trade_date), + "previous_trade_date": _display_date(previous_trade_date), + "source": "tushare", + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": ";".join(notices), + }, + "overview": _build_overview(daily, up_rows, down_rows, broken_rows), + "limits": limits, + "broken": broken, + "down_limits": down_limits, + "yesterday_limits": yesterday_limits, + "limit_performance": _build_limit_performance(yesterday_limits), + "ladders": _build_ladders(limits), + "sectors": sectors, + "sector_rotation": _build_sector_rotation(sectors, previous_sectors), + } + return apply_sentiment_to_dashboard(dashboard) + + @staticmethod + def should_use_realtime(requested_date: str, trade_date: str) -> bool: + """Use rt_k for today's open market until end-of-day datasets settle.""" + now = datetime.now().astimezone() + today = now.strftime("%Y%m%d") + return ( + requested_date == today + and trade_date == today + and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30) + ) + + def _realtime_dashboard( + self, + requested_date: str, + trade_date: str, + previous_trade_date: str, + ) -> dict[str, Any]: + reference = self._load_realtime_reference(trade_date, previous_trade_date) + basic_rows = list(reference["basic_rows"]) + codes = ",".join( + str(row.get("ts_code") or "") for row in basic_rows if row.get("ts_code") + ) + if not codes: + raise TushareError("No active stock codes available for rt_k") + quotes = self.query("rt_k", {"ts_code": codes}) + if not quotes: + raise TushareError(f"No realtime data returned for {trade_date}") + + basic_map = {str(row.get("ts_code") or ""): row for row in basic_rows} + daily: list[dict[str, Any]] = [] + for quote in quotes: + close = _number(quote.get("close")) + previous_close = _number(quote.get("pre_close")) + if close <= 0 or previous_close <= 0: + continue + basic = basic_map.get(str(quote.get("ts_code") or ""), {}) + daily.append( + { + **quote, + "trade_date": trade_date, + "name": str(quote.get("name") or basic.get("name") or "--").strip(), + "industry": basic.get("industry") or "其他", + "pct_chg": round((close / previous_close - 1) * 100, 4), + "amount_unit": "yuan", + } + ) + with self._realtime_reference_lock: + self._latest_realtime_market[trade_date] = { + "rows": daily, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + if len(self._latest_realtime_market) > 3: + oldest = next(iter(self._latest_realtime_market)) + self._latest_realtime_market.pop(oldest, None) + + limit_rows = self._derive_limits( + trade_date, + daily, + price_limits=list(reference["price_limits"]), + basic_rows=basic_rows, + previous_limit_rows=list(reference["previous_limit_rows"]), + capital_rows=list(reference["capital_rows"]), + ) + previous_limit_rows = list(reference["previous_limit_rows"]) + up_rows = [row for row in limit_rows if row.get("limit_type") == "U"] + down_rows = [row for row in limit_rows if row.get("limit_type") == "D"] + broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"] + limits = [self._normalize_limit(row, "涨停") for row in up_rows] + broken = [self._normalize_limit(row, "炸板") for row in broken_rows] + down_limits = [self._normalize_limit(row, "跌停") for row in down_rows] + previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows] + yesterday_limits = _build_yesterday_performance( + previous_limits, + daily, + limits, + broken, + down_limits, + ) + sectors = _build_sectors(limits) + previous_sectors = _build_sectors(previous_limits) + now = datetime.now().astimezone() + market_status = _realtime_market_status(now.time().replace(tzinfo=None)) + dashboard = { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(trade_date), + "previous_trade_date": _display_date(previous_trade_date), + "source": "tushare", + "mode": "realtime", + "realtime": True, + "market_status": market_status, + "refresh_mode": "manual", + "auto_refresh": False, + "quote_count": len(daily), + "updated_at": now.isoformat(timespec="seconds"), + "notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。", + }, + "overview": _build_overview(daily, up_rows, down_rows, broken_rows), + "limits": limits, + "broken": broken, + "down_limits": down_limits, + "yesterday_limits": yesterday_limits, + "limit_performance": _build_limit_performance(yesterday_limits), + "ladders": _build_ladders(limits), + "sectors": sectors, + "sector_rotation": _build_sector_rotation(sectors, previous_sectors), + } + return apply_sentiment_to_dashboard(dashboard) + + def _load_realtime_reference( + self, + trade_date: str, + previous_trade_date: str, + ) -> dict[str, Any]: + cache_key = f"{trade_date}:{previous_trade_date}" + with self._realtime_reference_lock: + cached = self._realtime_reference_cache.get(cache_key) + if cached: + return cached + + basic_rows = self.query( + "stock_basic", + {"exchange": "", "list_status": "L"}, + "ts_code,name,industry,market,list_date", + ) + price_limits = self.query( + "stk_limit", + {"trade_date": trade_date}, + "ts_code,trade_date,up_limit,down_limit", + ) + previous_limit_rows = self._load_limit_type(previous_trade_date, "U") + capital_rows = self.query( + "daily_basic", + {"trade_date": previous_trade_date}, + "ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv", + ) + if not basic_rows or not price_limits: + raise TushareError(f"Realtime reference data is incomplete for {trade_date}") + result = { + "basic_rows": basic_rows, + "price_limits": price_limits, + "previous_limit_rows": previous_limit_rows, + "capital_rows": capital_rows, + } + with self._realtime_reference_lock: + self._realtime_reference_cache[cache_key] = result + if len(self._realtime_reference_cache) > 3: + oldest = next(iter(self._realtime_reference_cache)) + self._realtime_reference_cache.pop(oldest, None) + return result + + def realtime_stock_quote( + self, + ts_code: str, + reference_date: str = "", + ) -> dict[str, Any]: + rows = self.query("rt_k", {"ts_code": ts_code}) + if not rows: + raise TushareError(f"No realtime quote returned for {ts_code}") + row = rows[0] + close = _number(row.get("close")) + previous_close = _number(row.get("pre_close")) + if close <= 0 or previous_close <= 0: + raise TushareError(f"Realtime quote is unavailable for {ts_code}") + + basic: dict[str, Any] = {} + with self._realtime_reference_lock: + references = list(self._realtime_reference_cache.values()) + for reference in reversed(references): + basic = next( + ( + item for item in reference.get("basic_rows") or [] + if str(item.get("ts_code") or "") == ts_code + ), + {}, + ) + if basic: + break + if not basic: + basics = self.query( + "stock_basic", + {"ts_code": ts_code}, + "ts_code,name,industry,market,list_date", + ) + basic = basics[0] if basics else {} + capital = self._latest_capital(ts_code, reference_date) + float_share = _number(capital.get("float_share")) + # rt_k volume is shares; daily_basic float_share is reported in 10k shares. + turnover_rate = _number(row.get("vol")) / float_share / 100 if float_share else 0 + market_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d") + self._ensure_realtime_market_cache(market_date) + with self._realtime_reference_lock: + market_rows = list((self._latest_realtime_market.get(market_date) or {}).get("rows") or []) + references = list(self._realtime_reference_cache.values()) + capital_map: dict[str, dict[str, Any]] = {} + for reference in reversed(references): + capital_map = { + str(item.get("ts_code") or ""): item + for item in reference.get("capital_rows") or [] + } + if capital_map: + break + market_amounts = [_number(item.get("amount")) for item in market_rows if _number(item.get("amount")) > 0] + amount_percentile = _value_percentile(_number(row.get("amount")), market_amounts) + market_turnovers = [] + for item in market_rows: + item_capital = capital_map.get(str(item.get("ts_code") or ""), {}) + item_float_share = _number(item_capital.get("float_share")) + if item_float_share: + market_turnovers.append(_number(item.get("vol")) / item_float_share / 100) + market_turnover = ( + sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0 + ) + turnover_relative = turnover_rate / market_turnover if market_turnover else 0 + activity = self._stock_activity_metrics( + ts_code, + market_date, + _number(row.get("vol")) / 100, + ) + return { + "code": ts_code.split(".")[0], + "ts_code": ts_code, + "name": str(row.get("name") or basic.get("name") or "--").strip(), + "sector": basic.get("industry") or "其他", + "price": round(close, 3), + "change": round((close / previous_close - 1) * 100, 4), + "open": round(_number(row.get("open")), 3), + "high": round(_number(row.get("high")), 3), + "low": round(_number(row.get("low")), 3), + "previous_close": round(previous_close, 3), + "amount_billion": round(_number(row.get("amount")) / 100000000, 3), + "volume": _number(row.get("vol")), + "trade_count": int(_number(row.get("num"))), + "turnover_rate": round(turnover_rate, 4), + "market_turnover_rate": round(market_turnover, 4), + "turnover_relative": round(turnover_relative, 4), + "amount_percentile": round(amount_percentile * 100, 2), + "volume_activity_ratio": activity.get("volume_activity_ratio", 0), + "activity_history_date": activity.get("history_trade_date", ""), + "activity_source": activity.get("source", "unavailable"), + "float_share_10k": float_share, + "capital_trade_date": str(capital.get("trade_date") or ""), + "turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable", + "data_source": "tushare", + "realtime": True, + } + + def _stock_activity_metrics( + self, + ts_code: str, + reference_date: str, + current_volume_lots: float, + ) -> dict[str, Any]: + cache_key = f"{ts_code}:{reference_date}" + with self._realtime_reference_lock: + history = self._stock_activity_cache.get(cache_key) + if history is None: + try: + end = datetime.strptime(reference_date, "%Y%m%d") + except ValueError: + end = datetime.now().astimezone().replace(tzinfo=None) + rows = self.query( + "daily", + { + "ts_code": ts_code, + "start_date": (end - timedelta(days=30)).strftime("%Y%m%d"), + "end_date": reference_date, + }, + "ts_code,trade_date,vol,amount", + ) + completed = [ + item for item in rows + if str(item.get("trade_date") or "") < reference_date and _number(item.get("vol")) > 0 + ] + completed.sort(key=lambda item: str(item.get("trade_date") or "")) + recent = completed[-5:] + history = { + "average_volume_lots": ( + sum(_number(item.get("vol")) for item in recent) / len(recent) + if recent else 0 + ), + "history_trade_date": str(recent[-1].get("trade_date") or "") if recent else "", + } + with self._realtime_reference_lock: + self._stock_activity_cache[cache_key] = history + if len(self._stock_activity_cache) > 256: + oldest = next(iter(self._stock_activity_cache)) + self._stock_activity_cache.pop(oldest, None) + average_volume = _number(history.get("average_volume_lots")) + progress = _trading_session_progress(datetime.now().astimezone().time().replace(tzinfo=None)) + expected_volume = average_volume * progress + ratio = current_volume_lots / expected_volume if expected_volume else 0 + return { + **history, + "volume_activity_ratio": round(ratio, 4), + "session_progress": round(progress, 4), + "source": "rt_volume/5d_average_at_same_progress" if expected_volume else "unavailable", + } + + def realtime_factor_snapshot(self, requested_date: str) -> dict[str, Any]: + trade_date, previous_trade_date = self.resolve_trade_context(requested_date) + reference = self._load_realtime_reference(trade_date, previous_trade_date) + codes = [ + str(row.get("ts_code") or "") + for row in reference.get("basic_rows") or [] + if row.get("ts_code") + ] + quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") + capital_map = { + str(row.get("ts_code") or ""): row + for row in reference.get("capital_rows") or [] + } + rows = [] + for quote in quotes: + ts_code = str(quote.get("ts_code") or "") + close = _number(quote.get("close")) + previous_close = _number(quote.get("pre_close")) + if not ts_code or close <= 0 or previous_close <= 0: + continue + capital = capital_map.get(ts_code, {}) + float_share = _number(capital.get("float_share")) + rows.append( + { + "ts_code": ts_code, + "trade_date": trade_date, + "open": _number(quote.get("open")), + "high": _number(quote.get("high")), + "low": _number(quote.get("low")), + "close": close, + "pct_chg": (close / previous_close - 1) * 100, + "vol": _number(quote.get("vol")) / 100, + "amount": _number(quote.get("amount")), + "turnover_rate": ( + _number(quote.get("vol")) / float_share / 100 if float_share else 0 + ), + "capital_trade_date": str(capital.get("trade_date") or ""), + } + ) + if not rows: + raise TushareError(f"No realtime factor snapshot returned for {trade_date}") + return { + "trade_date": trade_date, + "previous_trade_date": previous_trade_date, + "source": "tushare_rt_k", + "realtime": True, + "rows": rows, + } + + def _ensure_realtime_market_cache(self, requested_date: str) -> list[dict[str, Any]]: + with self._realtime_reference_lock: + cached = list( + (self._latest_realtime_market.get(requested_date) or {}).get("rows") or [] + ) + if cached: + return cached + trade_date, previous_trade_date = self.resolve_trade_context(requested_date) + if trade_date != requested_date: + return [] + reference = self._load_realtime_reference(trade_date, previous_trade_date) + codes = [ + str(row.get("ts_code") or "") + for row in reference.get("basic_rows") or [] + if row.get("ts_code") + ] + quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") + rows = [ + row for row in quotes + if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0 + ] + with self._realtime_reference_lock: + self._latest_realtime_market[trade_date] = { + "rows": rows, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + return rows + + def _latest_capital(self, ts_code: str, reference_date: str = "") -> dict[str, Any]: + end_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d") + cache_key = f"{ts_code}:{end_date}" + with self._realtime_reference_lock: + cached = self._capital_cache.get(cache_key) + if cached: + return cached + try: + end = datetime.strptime(end_date, "%Y%m%d") + except ValueError: + end = datetime.now().astimezone().replace(tzinfo=None) + end_date = end.strftime("%Y%m%d") + start_date = (end - timedelta(days=20)).strftime("%Y%m%d") + rows = self.query( + "daily_basic", + {"ts_code": ts_code, "start_date": start_date, "end_date": end_date}, + "ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share," + "free_share,total_mv,circ_mv", + ) + rows.sort(key=lambda item: str(item.get("trade_date") or "")) + result = rows[-1] if rows else {} + with self._realtime_reference_lock: + self._capital_cache[cache_key] = result + if len(self._capital_cache) > 256: + oldest = next(iter(self._capital_cache)) + self._capital_cache.pop(oldest, None) + return result + + def market_indices(self, requested_date: str, lookback_days: int = 45) -> dict[str, Any]: + trade_date, _ = self.resolve_trade_context(requested_date) + end = datetime.strptime(trade_date, "%Y%m%d") + start_date = (end - timedelta(days=max(30, lookback_days * 2))).strftime("%Y%m%d") + index_names = { + "000001.SH": "上证指数", + "399001.SZ": "深证成指", + "399006.SZ": "创业板指", + } + indices = [] + for ts_code, name in index_names.items(): + rows = self.query( + "index_daily", + {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, + "ts_code,trade_date,close,pct_chg,vol,amount", + ) + rows.sort(key=lambda item: str(item.get("trade_date") or "")) + if not rows: + continue + latest = rows[-1] + close = _number(latest.get("close")) + close_5d = _number(rows[-6].get("close")) if len(rows) >= 6 else _number(rows[0].get("close")) + close_20d = _number(rows[-21].get("close")) if len(rows) >= 21 else _number(rows[0].get("close")) + indices.append( + { + "ts_code": ts_code, + "name": name, + "trade_date": str(latest.get("trade_date") or trade_date), + "close": close, + "pct_chg": round(_number(latest.get("pct_chg")), 3), + "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0, + "return_20d": round((close / close_20d - 1) * 100, 3) if close_20d else 0, + "amount_billion": round(_number(latest.get("amount")) / 100000, 2), + } + ) + if not indices: + raise TushareError(f"No index data returned for {trade_date}") + return { + "trade_date": trade_date, + "source": "tushare", + "realtime": False, + "precise": all(item["trade_date"] == trade_date for item in indices), + "indices": indices, + "aggregate": { + "average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3), + "average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3), + "average_return_20d": round(sum(item["return_20d"] for item in indices) / len(indices), 3), + }, + } + + def realtime_market_indices(self, requested_date: str) -> dict[str, Any]: + trade_date, _ = self.resolve_trade_context(requested_date) + index_names = { + "000001.SH": "上证指数", + "399001.SZ": "深证成指", + "399006.SZ": "创业板指", + } + rows = self.query("rt_idx_k", {"ts_code": ",".join(index_names)}, "") + row_map = {str(row.get("ts_code") or ""): row for row in rows} + indices = [] + for ts_code, name in index_names.items(): + row = row_map.get(ts_code) + if not row: + continue + close = _number(row.get("close")) + previous_close = _number(row.get("pre_close")) + if close <= 0 or previous_close <= 0: + continue + history = self.query( + "index_daily", + { + "ts_code": ts_code, + "start_date": (datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)).strftime("%Y%m%d"), + "end_date": trade_date, + }, + "ts_code,trade_date,close,pct_chg", + ) + history.sort(key=lambda item: str(item.get("trade_date") or "")) + previous_closes = [ + _number(item.get("close")) for item in history + if str(item.get("trade_date") or "") < trade_date and _number(item.get("close")) > 0 + ] + close_5d = previous_closes[-5] if len(previous_closes) >= 5 else previous_closes[0] if previous_closes else previous_close + indices.append( + { + "ts_code": ts_code, + "name": str(row.get("name") or name).strip(), + "trade_date": trade_date, + "close": close, + "pct_chg": round((close / previous_close - 1) * 100, 3), + "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0, + "amount_billion": round(_number(row.get("amount")) / 100000000, 2), + } + ) + if len(indices) != len(index_names): + raise TushareError("Realtime index quotes are incomplete") + return { + "trade_date": trade_date, + "source": "tushare_rt_idx_k", + "realtime": True, + "precise": True, + "indices": indices, + "aggregate": { + "average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3), + "average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3), + "average_return_20d": 0, + }, + } + + def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]: + """Return the Shenwan industry active for a stock on trade_date.""" + rows = [] + for is_new in ("Y", "N"): + rows.extend( + self.query( + "index_member_all", + {"ts_code": ts_code, "is_new": is_new}, + "l1_code,l1_name,l2_code,l2_name,l3_code,l3_name," + "ts_code,name,in_date,out_date,is_new", + ) + ) + rows = _reconcile_membership_rows(rows) + matched = [row for row in rows if _membership_active_on(row, trade_date)] + if not matched: + matched = [ + row for row in rows + if row.get("is_new") == "Y" + and str(row.get("in_date") or "") <= trade_date + ] + if not matched: + raise TushareError(f"No Shenwan industry returned for {ts_code}") + row = max( + matched, + key=lambda item: ( + str(item.get("in_date") or ""), + 1 if item.get("is_new") == "Y" else 0, + str(item.get("l3_code") or item.get("l2_code") or ""), + ), + ) + return { + "l1_code": str(row.get("l1_code") or ""), + "l1_name": str(row.get("l1_name") or ""), + "l2_code": str(row.get("l2_code") or ""), + "l2_name": str(row.get("l2_name") or ""), + "l3_code": str(row.get("l3_code") or ""), + "l3_name": str(row.get("l3_name") or ""), + "in_date": str(row.get("in_date") or ""), + "out_date": str(row.get("out_date") or ""), + "is_new": str(row.get("is_new") or ""), + } + + def sw_sector_snapshot( + self, + ts_code: str, + requested_date: str, + realtime_expected: bool = False, + allow_realtime_close: bool = False, + ) -> dict[str, Any]: + """Build the single Shenwan L2 sector context used by heaven trend.""" + trade_date, previous_trade_date = self.resolve_trade_context(requested_date) + industry = self.sw_stock_industry(ts_code, trade_date) + sector_code = str(industry.get("l2_code") or "") + if not sector_code: + raise TushareError(f"Shenwan L2 code is unavailable for {ts_code}") + members = self._sw_sector_members(sector_code, trade_date) + if not members: + raise TushareError(f"No Shenwan members returned for {sector_code}") + raw_member_count = len(members) + members, excluded_members = _filter_members_by_listing( + members, + self._stock_listing_reference(), + trade_date, + ) + if not members: + raise TushareError(f"No listed Shenwan members returned for {sector_code}") + + if realtime_expected: + snapshot = self._sw_realtime_sector_snapshot( + industry, + members, + trade_date, + previous_trade_date, + finalized=False, + ) + snapshot.update({ + "raw_member_count": raw_member_count, + "excluded_member_count": len(excluded_members), + "excluded_members": excluded_members, + }) + return snapshot + + member_set = {str(item.get("ts_code") or "") for item in members} + member_names = { + str(item.get("ts_code") or ""): str(item.get("name") or "") + for item in members + } + member_rows = [ + row for row in self._load_daily(trade_date) + if str(row.get("ts_code") or "") in member_set + ] + quoted_codes = {str(row.get("ts_code") or "") for row in member_rows} + suspended_members = self._confirmed_suspended_members( + members, quoted_codes, trade_date + ) + up_count = sum(_number(row.get("pct_chg")) > 0 for row in member_rows) + down_count = sum(_number(row.get("pct_chg")) < 0 for row in member_rows) + leader = max(member_rows, key=lambda row: _number(row.get("pct_chg")), default={}) + leader_code = str(leader.get("ts_code") or "") + equal_change = ( + sum(_number(row.get("pct_chg")) for row in member_rows) / len(member_rows) + if member_rows else 0 + ) + coverage = len(member_rows) / max(len(members), 1) * 100 + explained_count = len(member_rows) + len(suspended_members) + explained_coverage = explained_count / max(len(members), 1) * 100 + coverage_issue = _sector_coverage_issue( + len(members), + len(member_rows), + explained_coverage, + explained_count, + ) + inner_precise = not coverage_issue + inner_error = coverage_issue + amount_billion = sum(_number(row.get("amount")) for row in member_rows) / 100000 + rows = self.query( + "sw_daily", + {"ts_code": sector_code, "trade_date": trade_date}, + "ts_code,trade_date,name,close,pct_change,vol,amount,pe,pb,float_mv,total_mv", + ) + daily = rows[0] if rows else {} + actual_trade_date = str(daily.get("trade_date") or "") + outer_precise = actual_trade_date == trade_date + outer_error = "" if outer_precise else ( + f"No Shenwan daily returned for {sector_code} on {trade_date}" + ) + if not outer_precise and allow_realtime_close: + try: + return self._sw_realtime_sector_snapshot( + industry, + members, + trade_date, + previous_trade_date, + finalized=True, + ) + except TushareError as exc: + outer_error = f"{outer_error}; realtime close fallback failed: {exc}" + + official_change = _number(daily.get("pct_change")) if outer_precise else None + return { + "code": sector_code, + "name": industry.get("l2_name") or daily.get("name") or sector_code, + "leader": str(leader.get("name") or member_names.get(leader_code) or "--"), + "leader_code": leader_code, + "leading_pct": round(_number(leader.get("pct_chg")), 3), + "change": round(official_change, 3) if official_change is not None else None, + "member_equal_change": round(equal_change, 3), + "turnover_rate": 0, + "up_count": up_count, + "down_count": down_count, + "flat_count": len(member_rows) - up_count - down_count, + "member_count": len(members), + "raw_member_count": raw_member_count, + "excluded_member_count": len(excluded_members), + "excluded_members": excluded_members, + "quote_count": len(member_rows), + "coverage": round(coverage, 1), + "explained_count": explained_count, + "explained_coverage": round(explained_coverage, 1), + "suspended_count": len(suspended_members), + "suspended_members": suspended_members, + "strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1), + "amount_billion": round(amount_billion, 2), + "count": 0, + "max_streak": 0, + "source": "tushare_sw_daily+member_daily" if outer_precise else "tushare_member_daily", + "inner_source": "tushare_member_daily", + "outer_source": "tushare_sw_daily" if outer_precise else "unavailable", + "taxonomy": "sw_l2", + "industry": industry, + "trade_date": trade_date, + "inner_trade_date": trade_date if member_rows else "", + "outer_trade_date": actual_trade_date, + "realtime": False, + "finalized": True, + "inner_precise": inner_precise, + "outer_precise": outer_precise, + "precise": inner_precise and outer_precise, + "inner_error": inner_error, + "outer_error": outer_error, + "schema_version": 6, + "methodology": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合", + } + + def _sw_sector_members( + self, + sector_code: str, + trade_date: str, + ) -> list[dict[str, Any]]: + rows = [] + for is_new in ("Y", "N"): + rows.extend( + self.query( + "index_member_all", + {"l2_code": sector_code, "is_new": is_new}, + "l2_code,l2_name,ts_code,name,in_date,out_date,is_new", + ) + ) + deduped: dict[str, dict[str, Any]] = {} + for row in _reconcile_membership_rows(rows): + code = str(row.get("ts_code") or "") + if code and _membership_active_on(row, trade_date): + current = deduped.get(code) + if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""): + deduped[code] = row + return list(deduped.values()) + + def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]: + """Return constituents active in a Shenwan L2 industry on the target date.""" + return self._sw_sector_members(sector_code, trade_date) + + def _stock_listing_reference(self) -> dict[str, dict[str, Any]]: + now = datetime.now().astimezone() + with self._stock_listing_lock: + loaded_at = self._stock_listing_cache.get("loaded_at") + cached = self._stock_listing_cache.get("rows") + if ( + isinstance(loaded_at, datetime) + and isinstance(cached, dict) + and now - loaded_at < timedelta(hours=6) + ): + return cached + + rows: list[dict[str, Any]] = [] + try: + for status in ("L", "D", "P"): + rows.extend(self.query( + "stock_basic", + {"list_status": status}, + "ts_code,name,list_status,list_date,delist_date", + )) + except TushareError: + # Unknown status must remain in the denominator so a reference-data + # failure cannot silently improve coverage. + return {} + reference = { + str(row.get("ts_code") or ""): dict(row) + for row in rows + if row.get("ts_code") + } + with self._stock_listing_lock: + type(self)._stock_listing_cache = {"loaded_at": now, "rows": reference} + return reference + + def _confirmed_suspended_members( + self, + members: list[dict[str, Any]], + quoted_codes: set[str], + trade_date: str, + ) -> list[dict[str, str]]: + suspended: list[dict[str, str]] = [] + for member in members: + code = str(member.get("ts_code") or "") + if not code or code in quoted_codes: + continue + cache_key = f"{trade_date}:{code}" + with self._suspension_lock: + cached = self._suspension_cache.get(cache_key, "missing") + if cached == "missing": + try: + rows = self.query( + "suspend_d", + {"ts_code": code}, + "ts_code,suspend_date,resume_date,ann_date,suspend_reason,reason_type", + ) + except TushareError: + rows = [] + active = [ + row for row in rows + if str(row.get("suspend_date") or "") + and str(row.get("suspend_date") or "") <= trade_date + and ( + not str(row.get("resume_date") or "") + or trade_date < str(row.get("resume_date") or "") + ) + ] + row = max( + active, + key=lambda item: str(item.get("suspend_date") or ""), + default=None, + ) + cached = ({ + "ts_code": code, + "name": str(member.get("name") or code), + "suspend_date": str(row.get("suspend_date") or ""), + "resume_date": str(row.get("resume_date") or ""), + "reason": str(row.get("suspend_reason") or row.get("reason_type") or "已确认停牌"), + } if row else None) + with self._suspension_lock: + type(self)._suspension_cache[cache_key] = cached + if isinstance(cached, dict): + suspended.append(cached) + return suspended + + def _sw_realtime_sector_snapshot( + self, + industry: dict[str, Any], + members: list[dict[str, Any]], + trade_date: str, + previous_trade_date: str, + finalized: bool = False, + ) -> dict[str, Any]: + sector_code = str(industry.get("l2_code") or "") + sw_rows = self.query( + "rt_sw_k", + {"ts_code": sector_code}, + "ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change", + ) + sw_row = sw_rows[0] if sw_rows else {} + trade_time = str(sw_row.get("trade_time") or "") + quote_date = trade_time[:10].replace("-", "") + quote_clock = trade_time[11:19] if len(trade_time) >= 19 else "" + outer_precise = bool(sw_row and quote_date == trade_date) + if finalized and (not quote_clock or quote_clock < "15:00:00"): + outer_precise = False + official_change = _number(sw_row.get("pct_change")) + if not official_change: + close = _number(sw_row.get("close")) + pre_close = _number(sw_row.get("pre_close")) + official_change = (close / pre_close - 1) * 100 if close and pre_close else 0 + if not outer_precise: + official_change = None + outer_error = "" + if not sw_row: + outer_error = f"No Shenwan realtime index returned for {sector_code}" + elif quote_date != trade_date: + outer_error = f"Shenwan realtime index date is {quote_date or 'unknown'}, expected {trade_date}" + elif finalized and (not quote_clock or quote_clock < "15:00:00"): + outer_error = f"Shenwan realtime index is not a close snapshot ({trade_time})" + + valid: list[dict[str, Any]] = [] + codes: list[str] = [] + reference: dict[str, Any] = {} + inner_error = "" + try: + reference = self._load_realtime_reference(trade_date, previous_trade_date) + active_codes = { + str(row.get("ts_code") or "") + for row in reference.get("basic_rows") or [] + if row.get("ts_code") + } + codes = [ + str(row.get("ts_code") or "") + for row in members + if str(row.get("ts_code") or "") in active_codes + ] + if codes: + quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") + for row in quotes: + close = _number(row.get("close")) + previous_close = _number(row.get("pre_close")) + if close <= 0 or previous_close <= 0: + continue + valid.append({**row, "change": (close / previous_close - 1) * 100}) + else: + inner_error = f"No active Shenwan members returned for {sector_code}" + except TushareError as exc: + inner_error = str(exc) + + coverage = len(valid) / max(len(codes), 1) * 100 + valid_codes = {str(item.get("ts_code") or "") for item in valid} + suspended_members = self._confirmed_suspended_members( + members, valid_codes, trade_date + ) + explained_count = len(valid) + len(suspended_members) + explained_coverage = explained_count / max(len(codes), 1) * 100 + coverage_issue = _sector_coverage_issue( + len(codes), len(valid), explained_coverage, explained_count + ) + inner_precise = bool(codes) and not coverage_issue + if not inner_precise and not inner_error: + inner_error = coverage_issue or "申万实时有效成分为空" + up_count = sum(item["change"] > 0 for item in valid) + down_count = sum(item["change"] < 0 for item in valid) + leader = max(valid, key=lambda item: item["change"], default={}) + leader_code = str(leader.get("ts_code") or "") + member_names = { + str(item.get("ts_code") or ""): str(item.get("name") or "") + for item in members + } + equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0 + amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000 + try: + self._ensure_realtime_market_cache(trade_date) + with self._realtime_reference_lock: + market_rows = list( + (self._latest_realtime_market.get(trade_date) or {}).get("rows") or [] + ) + except TushareError as exc: + market_rows = [] + inner_precise = False + inner_error = inner_error or str(exc) + capital_map = { + str(item.get("ts_code") or ""): item + for item in reference.get("capital_rows") or [] + } + sector_turnovers = [] + for item in valid: + capital = capital_map.get(str(item.get("ts_code") or ""), {}) + float_share = _number(capital.get("float_share")) + if float_share: + sector_turnovers.append(_number(item.get("vol")) / float_share / 100) + market_turnovers = [] + for item in market_rows: + capital = capital_map.get(str(item.get("ts_code") or ""), {}) + float_share = _number(capital.get("float_share")) + if float_share: + market_turnovers.append(_number(item.get("vol")) / float_share / 100) + average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0 + market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0 + relative_turnover = average_turnover / market_turnover if market_turnover else 0 + if not relative_turnover: + inner_precise = False + inner_error = inner_error or "Shenwan member relative turnover is unavailable" + return { + "code": sector_code, + "name": str(industry.get("l2_name") or sw_row.get("name") or ""), + "leader": str(leader.get("name") or member_names.get(leader_code) or "--").strip(), + "leader_code": leader_code, + "leading_pct": round(_number(leader.get("change")), 3), + "change": round(official_change, 3) if official_change is not None else None, + "member_equal_change": round(equal_change, 3), + "turnover_rate": round(average_turnover, 4), + "market_turnover_rate": round(market_turnover, 4), + "relative_turnover": round(relative_turnover, 4), + "up_count": up_count, + "down_count": down_count, + "flat_count": len(valid) - up_count - down_count, + "member_count": len(codes), + "quote_count": len(valid), + "coverage": round(coverage, 1), + "explained_count": explained_count, + "explained_coverage": round(explained_coverage, 1), + "suspended_count": len(suspended_members), + "suspended_members": suspended_members, + "strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1), + "amount_billion": round(amount_billion, 2), + "count": sum(item["change"] >= 9.5 for item in valid), + "max_streak": 0, + "source": "tushare_rt_sw_k+sw_members_rt_k", + "inner_source": "tushare_sw_members+rt_k", + "outer_source": "tushare_rt_sw_k", + "taxonomy": "sw_l2", + "industry": industry, + "trade_date": trade_date, + "inner_trade_date": trade_date if valid else "", + "outer_trade_date": quote_date, + "trade_time": trade_time, + "realtime": True, + "finalized": finalized, + "inner_precise": inner_precise, + "outer_precise": outer_precise, + "precise": inner_precise and outer_precise, + "inner_error": inner_error, + "outer_error": outer_error, + "schema_version": 6, + "methodology": "外显使用申万官方 rt_sw_k;内核独立使用申万成分 rt_k 宽度与相对换手聚合", + } + + def sector_snapshot( + self, + identifier: str, + requested_date: str, + realtime_expected: bool | None = None, + ) -> dict[str, Any]: + trade_date, _ = self.resolve_trade_context(requested_date) + raw_identifier = identifier.strip() + if not raw_identifier: + raise TushareError("Sector identifier is empty") + errors = [] + now = datetime.now().astimezone() + if realtime_expected is None: + realtime_expected = ( + trade_date == now.strftime("%Y%m%d") + and dt_time(9, 15) <= now.time().replace(tzinfo=None) <= dt_time(15, 5) + ) + try: + dc_params = {"trade_date": trade_date} + if re.fullmatch(r"[A-Z0-9.]+", raw_identifier.upper()) and "." in raw_identifier: + dc_params["ts_code"] = raw_identifier.upper() + else: + dc_params["name"] = raw_identifier + dc_rows = self.query( + "dc_index", + dc_params, + "ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct," + "total_mv,turnover_rate,up_num,down_num", + ) + if not dc_rows and "name" in dc_params: + dc_rows = self.query( + "dc_index", + {"trade_date": trade_date}, + "ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct," + "total_mv,turnover_rate,up_num,down_num", + ) + dc_row = _match_sector_row(dc_rows, raw_identifier) + if dc_row and not realtime_expected: + change = _number(dc_row.get("pct_change")) + actual_trade_date = str(dc_row.get("trade_date") or "") + return { + "code": dc_row.get("ts_code") or "", + "name": dc_row.get("name") or raw_identifier, + "leader": dc_row.get("leading") or "--", + "leader_code": dc_row.get("leading_code") or "", + "leading_pct": _number(dc_row.get("leading_pct")), + "change": change, + "turnover_rate": _number(dc_row.get("turnover_rate")), + "up_count": int(_number(dc_row.get("up_num"))), + "down_count": int(_number(dc_row.get("down_num"))), + "total_mv": _number(dc_row.get("total_mv")), + "strength": round(max(0, min(100, 50 + change * 5)), 1), + "amount_billion": 0, + "count": 0, + "max_streak": 0, + "source": "tushare_dc", + "trade_date": actual_trade_date, + "realtime": False, + "precise": actual_trade_date == trade_date, + } + except TushareError as exc: + errors.append(f"DC: {exc}") + + ts_code = raw_identifier.upper() + if re.fullmatch(r"\d{6}", ts_code): + ts_code = f"{ts_code}.TI" + try: + if re.fullmatch(r"\d{6}\.TI", ts_code): + index_rows = self.query( + "ths_index", + {"ts_code": ts_code}, + "ts_code,name,count,exchange,list_date,type", + ) + else: + index_rows = self.query( + "ths_index", + {}, + "ts_code,name,count,exchange,list_date,type", + ) + basic = _match_sector_row(index_rows, raw_identifier) + if not basic: + raise TushareError(f"No THS sector returned for {raw_identifier}") + except TushareError as exc: + errors.append(f"THS: {exc}") + raise TushareError("; ".join(errors)) from exc + actual_code = str(basic.get("ts_code") or ts_code) + if realtime_expected: + try: + realtime_sector = self._realtime_sector_snapshot(actual_code, basic, trade_date) + if realtime_sector: + return realtime_sector + except TushareError as exc: + errors.append(f"THS realtime members: {exc}") + daily_rows = self.query( + "ths_daily", + {"ts_code": actual_code, "trade_date": trade_date}, + "ts_code,trade_date,close,pct_change,vol,turnover_rate,total_mv,float_mv", + ) + daily = daily_rows[0] if daily_rows else {} + actual_trade_date = str(daily.get("trade_date") or "") + change = _number(daily.get("pct_change")) + return { + "code": actual_code, + "name": basic.get("name") or raw_identifier, + "leader": "--", + "change": change, + "leading_pct": change, + "turnover_rate": _number(daily.get("turnover_rate")), + "up_count": 0, + "down_count": 0, + "strength": round(max(0, min(100, 50 + change * 5)), 1), + "amount_billion": 0, + "count": 0, + "max_streak": 0, + "source": "tushare_ths", + "trade_date": actual_trade_date, + "realtime": False, + "precise": actual_trade_date == trade_date, + } + + def _realtime_sector_snapshot( + self, + sector_code: str, + basic: dict[str, Any], + trade_date: str, + ) -> dict[str, Any] | None: + members = self.query( + "ths_member", + {"ts_code": sector_code, "is_new": "Y"}, + "ts_code,con_code,con_name,is_new", + ) + codes = [str(row.get("con_code") or "") for row in members if row.get("con_code")] + if not codes: + return None + quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") + valid = [] + for row in quotes: + close = _number(row.get("close")) + previous_close = _number(row.get("pre_close")) + if close <= 0 or previous_close <= 0: + continue + valid.append( + { + **row, + "change": (close / previous_close - 1) * 100, + } + ) + minimum = max(1, math.ceil(len(codes) * 0.9)) + if len(valid) < minimum: + raise TushareError( + f"Realtime sector coverage is insufficient ({len(valid)}/{len(codes)})" + ) + up_count = sum(item["change"] > 0 for item in valid) + down_count = sum(item["change"] < 0 for item in valid) + flat_count = len(valid) - up_count - down_count + leader = max(valid, key=lambda item: item["change"]) + change = sum(item["change"] for item in valid) / len(valid) + amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000 + self._ensure_realtime_market_cache(trade_date) + with self._realtime_reference_lock: + references = list(self._realtime_reference_cache.values()) + market_rows = list((self._latest_realtime_market.get(trade_date) or {}).get("rows") or []) + capital_map: dict[str, dict[str, Any]] = {} + for reference in reversed(references): + capital_map = { + str(item.get("ts_code") or ""): item + for item in reference.get("capital_rows") or [] + } + if capital_map: + break + sector_turnovers = [] + for item in valid: + capital = capital_map.get(str(item.get("ts_code") or ""), {}) + float_share = _number(capital.get("float_share")) + if float_share: + sector_turnovers.append(_number(item.get("vol")) / float_share / 100) + market_turnovers = [] + for item in market_rows: + capital = capital_map.get(str(item.get("ts_code") or ""), {}) + float_share = _number(capital.get("float_share")) + if float_share: + market_turnovers.append(_number(item.get("vol")) / float_share / 100) + average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0 + market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0 + relative_turnover = average_turnover / market_turnover if market_turnover else 0 + return { + "code": sector_code, + "name": basic.get("name") or sector_code, + "leader": str(leader.get("name") or "--").strip(), + "leader_code": leader.get("ts_code") or "", + "leading_pct": round(leader["change"], 3), + "change": round(change, 3), + "turnover_rate": round(average_turnover, 4), + "market_turnover_rate": round(market_turnover, 4), + "relative_turnover": round(relative_turnover, 4), + "up_count": up_count, + "down_count": down_count, + "flat_count": flat_count, + "member_count": len(codes), + "quote_count": len(valid), + "coverage": round(len(valid) / len(codes) * 100, 1), + "strength": round(max(0, min(100, 50 + change * 5)), 1), + "amount_billion": round(amount_billion, 2), + "count": sum(item["change"] >= 9.5 for item in valid), + "max_streak": 0, + "source": "tushare_rt_ths_members", + "trade_date": trade_date, + "realtime": True, + "precise": True, + "methodology": "同花顺行业最新成分股的 rt_k 等权涨跌、宽度与成交额聚合", + } + + def hot_money_profiles(self) -> dict[str, Any]: + rows = self.query("hm_list", {}, "name,desc,orgs") + profiles: list[dict[str, Any]] = [] + seen_names: set[str] = set() + for row in rows: + name = str(row.get("name") or "").strip() + if not name or name in seen_names: + continue + seen_names.add(name) + description = _text(row.get("desc")) + organization_text = _text(row.get("orgs")) + parsed_organizations: Any = None + if organization_text.startswith("["): + try: + parsed_organizations = json.loads(organization_text) + except json.JSONDecodeError: + parsed_organizations = None + organization_parts = ( + parsed_organizations + if isinstance(parsed_organizations, list) + else re.split(r"[,,;;\n]+", organization_text) + ) + organizations = list(dict.fromkeys( + _text(part) + for part in organization_parts + if _text(part) + )) + profiles.append( + { + "id": f"hot-money-profile-{len(profiles) + 1}", + "name": name, + "description": description, + "organizations": organizations, + "organization_count": len(organizations), + } + ) + return { + "meta": { + "source": "tushare", + "status": "success" if profiles else "empty", + "schema_version": 1, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": "", + }, + "summary": { + "profile_count": len(profiles), + "described_count": sum(bool(item["description"]) for item in profiles), + "organization_count": sum(item["organization_count"] for item in profiles), + }, + "profiles": profiles, + } + + def dragon_tiger(self, requested_date: str) -> dict[str, Any]: + trade_date, _ = self.resolve_trade_context(requested_date) + detail_rows = self.query( + "hm_detail", + {"trade_date": trade_date}, + "trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount," + "hm_name,hm_orgs,tag", + ) + + notices: list[str] = [] + try: + directory_rows = self.query("hm_list", {}, "name,desc,orgs") + except TushareError as exc: + directory_rows = [] + notices.append(f"游资名录暂不可用:{exc}") + directory = { + str(row.get("name") or "").strip(): { + "description": _text(row.get("desc")), + "orgs": _text(row.get("orgs")), + } + for row in directory_rows + if str(row.get("name") or "").strip() + } + + # 个股龙虎榜仅用于补充涨幅和上榜原因,不参与游资身份识别。 + try: + top_rows = self.query( + "top_list", + {"trade_date": trade_date}, + "trade_date,ts_code,name,pct_change,reason", + ) + except TushareError as exc: + top_rows = [] + notices.append(f"个股龙虎榜辅助信息暂不可用:{exc}") + stock_context: dict[str, dict[str, Any]] = {} + for row in top_rows: + ts_code = str(row.get("ts_code") or "") + if ts_code and ts_code not in stock_context: + stock_context[ts_code] = row + + groups: dict[str, dict[str, Any]] = {} + for row in detail_rows: + trader_name = str(row.get("hm_name") or "未命名游资").strip() + ts_code = str(row.get("ts_code") or "").strip() + stock = stock_context.get(ts_code, {}) + directory_item = directory.get(trader_name, {}) + seat_name = _text(row.get("hm_orgs")) or directory_item.get("orgs") or "--" + buy = round(_number(row.get("buy_amount")) / 1000000, 2) + sell = round(_number(row.get("sell_amount")) / 1000000, 2) + net_buy = round(_number(row.get("net_amount")) / 1000000, 2) + group = groups.setdefault( + trader_name, + { + "name": trader_name, + "description": directory_item.get("description") or "", + "directory_orgs": directory_item.get("orgs") or "", + "identity_type": "trader", + "identity_source": "tushare_hm", + "recognized": True, + "buy_million": 0.0, + "sell_million": 0.0, + "net_buy_million": 0.0, + "seat_names": set(), + "stock_codes": set(), + "operations": [], + }, + ) + group["buy_million"] += buy + group["sell_million"] += sell + group["net_buy_million"] += net_buy + if seat_name != "--": + group["seat_names"].add(seat_name) + code = ts_code.split(".")[0] + if code: + group["stock_codes"].add(code) + group["operations"].append( + { + "code": code, + "ts_code": ts_code, + "name": row.get("ts_name") or stock.get("name") or "--", + "change": ( + _number(stock.get("pct_change")) + if stock.get("pct_change") is not None + else None + ), + "direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平", + "buy_million": buy, + "sell_million": sell, + "net_buy_million": net_buy, + "seat_name": seat_name, + "seat_alias": trader_name, + "tag": _text(row.get("tag")) or "--", + "reason": _text(stock.get("reason")) or "--", + } + ) + + traders = list(groups.values()) + traders.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True) + for index, group in enumerate(traders, start=1): + group["id"] = f"hot-money-{index}" + group["buy_million"] = round(group["buy_million"], 2) + group["sell_million"] = round(group["sell_million"], 2) + group["net_buy_million"] = round(group["net_buy_million"], 2) + group["seat_count"] = len(group.pop("seat_names")) + group["stock_count"] = len(group.pop("stock_codes")) + group["operation_count"] = len(group["operations"]) + group["operations"].sort( + key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True + ) + + operation_count = sum(item["operation_count"] for item in traders) + active_stocks = { + operation["code"] for item in traders for operation in item["operations"] + if operation["code"] + } + net_buy_total = round(sum(item["net_buy_million"] for item in traders), 2) + status = "success" if detail_rows else "partial" if top_rows else "empty" + if not detail_rows: + notices.insert( + 0, + f"当日有 {len(stock_context)} 只股票上榜,但未返回可识别的游资每日明细。" + if top_rows + else "该交易日未返回龙虎榜或游资每日明细。", + ) + return { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(trade_date), + "source": "tushare", + "status": status, + "schema_version": 3, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": ";".join(notices), + }, + "summary": { + "trader_count": len(traders), + "identity_count": len(traders), + "operation_count": operation_count, + "active_stock_count": len(active_stocks), + "seat_net_buy_million": net_buy_total, + "unclassified_count": 0, + "directory_count": len(directory), + "official_stock_count": len(stock_context), + }, + "traders": traders, + "unclassified_seats": [], + "rows": [], + } + + def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]: + trade_date, _ = self.resolve_trade_context(requested_date) + end = datetime.strptime(trade_date, "%Y%m%d") + start_date = (end - timedelta(days=190)).strftime("%Y%m%d") + daily = self.query( + "daily", + {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ) + factors = self.query( + "adj_factor", + {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, + "ts_code,trade_date,adj_factor", + ) + basics = self.query( + "stock_basic", + {"ts_code": ts_code}, + "ts_code,symbol,name,area,industry,market,list_date", + ) + daily_basics = self.query( + "daily_basic", + {"ts_code": ts_code, "trade_date": trade_date}, + "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv", + ) + moneyflow = self.query( + "moneyflow", + {"ts_code": ts_code, "trade_date": trade_date}, + "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," + "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", + ) + factor_map = {row["trade_date"]: _number(row.get("adj_factor"), 1) for row in factors} + latest_factor = max(factor_map.values(), default=1) or 1 + prices = [] + for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-90:]: + factor = factor_map.get(row.get("trade_date"), latest_factor) + ratio = factor / latest_factor + prices.append( + { + "trade_date": _display_date(str(row.get("trade_date", ""))), + "open": round(_number(row.get("open")) * ratio, 3), + "high": round(_number(row.get("high")) * ratio, 3), + "low": round(_number(row.get("low")) * ratio, 3), + "close": round(_number(row.get("close")) * ratio, 3), + "change": _number(row.get("pct_chg")), + "volume": _number(row.get("vol")), + "amount_billion": round(_number(row.get("amount")) / 100000, 2), + } + ) + flow = moneyflow[0] if moneyflow else {} + basic = basics[0] if basics else {} + daily_basic = daily_basics[0] if daily_basics else {} + latest = prices[-1] if prices else {} + actual_trade_date = max( + (str(row.get("trade_date") or "") for row in daily), + default=trade_date, + ) or trade_date + return { + "meta": { + "requested_date": _display_date(requested_date), + "trade_date": _display_date(actual_trade_date), + "source": "tushare", + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "notice": "", + }, + "stock": { + "code": ts_code.split(".")[0], + "ts_code": ts_code, + "name": basic.get("name") or "--", + "industry": basic.get("industry") or "其他", + "area": basic.get("area") or "--", + "market": basic.get("market") or "--", + "list_date": _display_date(str(basic.get("list_date") or "")), + "price": latest.get("close", 0), + "change": latest.get("change", 0), + "turnover_rate": _number(daily_basic.get("turnover_rate")), + "volume_ratio": _number(daily_basic.get("volume_ratio")), + "amount_billion": latest.get("amount_billion", 0), + }, + "prices": prices, + "moneyflow": { + "net_million": round(_number(flow.get("net_mf_amount")) / 100, 2), + "large_million": round( + (_number(flow.get("buy_lg_amount")) + _number(flow.get("buy_elg_amount")) + - _number(flow.get("sell_lg_amount")) - _number(flow.get("sell_elg_amount"))) / 100, + 2, + ), + "medium_million": round( + (_number(flow.get("buy_md_amount")) - _number(flow.get("sell_md_amount"))) / 100, + 2, + ), + "small_million": round( + (_number(flow.get("buy_sm_amount")) - _number(flow.get("sell_sm_amount"))) / 100, + 2, + ), + }, + } + + def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]: + trade_date, _ = self.resolve_trade_context(requested_date) + display_date = _display_date(trade_date) + rows = self.query( + "stk_mins", + { + "ts_code": ts_code, + "freq": "1min", + "start_date": f"{display_date} 09:00:00", + "end_date": f"{display_date} 15:30:00", + }, + "ts_code,trade_time,open,close,high,low,vol,amount", + ) + points = [] + for row in sorted(rows, key=lambda item: str(item.get("trade_time") or "")): + trade_time = str(row.get("trade_time") or "") + if not trade_time: + continue + points.append( + { + "time": trade_time[-8:-3] if len(trade_time) >= 8 else trade_time, + "open": round(_number(row.get("open")), 3), + "high": round(_number(row.get("high")), 3), + "low": round(_number(row.get("low")), 3), + "close": round(_number(row.get("close")), 3), + "volume": _number(row.get("vol")), + "amount": _number(row.get("amount")), + } + ) + return {"trade_date": display_date, "points": points} + + def resolve_trade_context(self, requested: str) -> tuple[str, str]: + requested_rows = self.query( + "trade_cal", + {"exchange": "SSE", "start_date": requested, "end_date": requested}, + "cal_date,is_open,pretrade_date", + ) + if not requested_rows: + trade_date = requested + else: + row = requested_rows[0] + trade_date = row["cal_date"] if row.get("is_open") == 1 else row.get("pretrade_date", requested) + + resolved_rows = self.query( + "trade_cal", + {"exchange": "SSE", "start_date": trade_date, "end_date": trade_date}, + "cal_date,is_open,pretrade_date", + ) + previous = resolved_rows[0].get("pretrade_date") if resolved_rows else "" + return trade_date, previous or trade_date + + def _load_daily(self, trade_date: str) -> list[dict[str, Any]]: + return self.query( + "daily", + {"trade_date": trade_date}, + "ts_code,trade_date,open,high,low,close,pct_chg,amount", + ) + + def _load_limit_type(self, trade_date: str, limit_type: str) -> list[dict[str, Any]]: + fields = ( + "trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount," + "float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time," + "open_times,up_stat,limit_times" + ) + rows = self.query( + "limit_list_d", + {"trade_date": trade_date, "limit_type": limit_type}, + fields, + ) + for row in rows: + row["limit_type"] = limit_type + row["amount_unit"] = "yuan" + return rows + + def _load_limit_lists(self, trade_date: str) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for limit_type in ("U", "D", "Z"): + rows.extend(self._load_limit_type(trade_date, limit_type)) + return rows + + def _derive_limits( + self, + trade_date: str, + daily: list[dict[str, Any]], + price_limits: list[dict[str, Any]] | None = None, + basic_rows: list[dict[str, Any]] | None = None, + previous_limit_rows: list[dict[str, Any]] | None = None, + capital_rows: list[dict[str, Any]] | None = None, + ) -> list[dict[str, Any]]: + if price_limits is None: + price_limits = self.query( + "stk_limit", + {"trade_date": trade_date}, + "ts_code,trade_date,up_limit,down_limit", + ) + limit_map = {row["ts_code"]: row for row in price_limits} + if basic_rows is None: + basic_rows = self.query( + "stock_basic", + {"list_status": "L"}, + "ts_code,name,industry", + ) + basic_map = {row["ts_code"]: row for row in basic_rows} + previous_limit_map = { + str(row.get("ts_code") or ""): row for row in (previous_limit_rows or []) + } + capital_map = { + str(row.get("ts_code") or ""): row for row in (capital_rows or []) + } + + result: list[dict[str, Any]] = [] + for row in daily: + bounds = limit_map.get(row.get("ts_code")) + if not bounds or row.get("close") is None: + continue + limit_type = "" + if _prices_equal(row["close"], bounds.get("up_limit")): + limit_type = "U" + elif _prices_equal(row["close"], bounds.get("down_limit")): + limit_type = "D" + elif _prices_equal(row.get("high"), bounds.get("up_limit")): + limit_type = "Z" + if not limit_type: + continue + basic = basic_map.get(row["ts_code"], {}) + previous_limit = previous_limit_map.get(str(row.get("ts_code") or ""), {}) + streak = ( + max(1, int(_number(previous_limit.get("limit_times"), 1)) + 1) + if limit_type == "U" and previous_limit + else 1 + ) + item = { + **row, + "name": basic.get("name", "--"), + "industry": basic.get("industry") or "其他", + "limit_type": limit_type, + "limit_times": streak, + "open_times": 1 if limit_type == "Z" else 0, + "amount_unit": row.get("amount_unit") or "thousand_yuan", + } + if row.get("amount_unit") == "yuan": + capital = capital_map.get(str(row.get("ts_code") or ""), {}) + if not capital and capital_rows is None: + capital = self._latest_capital(str(row.get("ts_code") or ""), trade_date) + float_share = _number(capital.get("float_share")) + item["turnover_ratio"] = ( + _number(row.get("vol")) / float_share / 100 if float_share else 0 + ) + item["turnover_source"] = ( + "rt_volume/latest_float_share" if float_share else "unavailable" + ) + item["capital_trade_date"] = str(capital.get("trade_date") or "") + result.append(item) + return result + + @staticmethod + def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]: + amount = _number(row.get("amount")) + if row.get("amount_unit") == "thousand_yuan": + amount_billion = amount / 100000 + else: + amount_billion = amount / 100000000 + return { + "code": str(row.get("ts_code", "")).split(".")[0], + "ts_code": row.get("ts_code", ""), + "name": row.get("name") or "--", + "price": _number(row.get("close")), + "change": _number(row.get("pct_chg")), + "sector": row.get("industry") or "其他", + "reason": row.get("industry") or "待补充", + "first_time": _display_time(row.get("first_time")), + "last_time": _display_time(row.get("last_time")), + "open_times": int(_number(row.get("open_times"))), + "streak": max(1, int(_number(row.get("limit_times"), 1))), + "turnover_rate": _number(row.get("turnover_ratio")), + "turnover_source": row.get("turnover_source") or "provider", + "capital_trade_date": row.get("capital_trade_date") or "", + "amount_billion": round(amount_billion, 2), + "seal_amount_million": round(_number(row.get("fd_amount")) / 10000, 0), + "float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1), + "status": status, + } + + +def _number(value: Any, default: float = 0.0) -> float: + try: + number = float(value) + return number if math.isfinite(number) else default + except (TypeError, ValueError): + return default + + +def _text(value: Any) -> str: + if isinstance(value, (list, tuple, set)): + return "、".join(str(item).strip() for item in value if str(item).strip()) + return str(value or "").strip() + + +def _filter_members_by_listing( + members: list[dict[str, Any]], + listing_reference: dict[str, dict[str, Any]], + trade_date: str, +) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + eligible: list[dict[str, Any]] = [] + excluded: list[dict[str, str]] = [] + for member in members: + code = str(member.get("ts_code") or "") + listing = listing_reference.get(code) + if not listing: + eligible.append(member) + continue + list_date = str(listing.get("list_date") or "") + delist_date = str(listing.get("delist_date") or "") + reason = "" + effective_date = "" + if delist_date and delist_date <= trade_date: + reason = "目标日期前已退市" + effective_date = delist_date + elif list_date and list_date > trade_date: + reason = "目标日期尚未上市" + effective_date = list_date + if not reason: + eligible.append(member) + continue + excluded.append({ + "ts_code": code, + "name": str(member.get("name") or listing.get("name") or code), + "reason": reason, + "effective_date": effective_date, + }) + return eligible, excluded + + +def _sector_coverage_issue( + member_count: int, + quote_count: int, + coverage: float | None = None, + explained_count: int | None = None, +) -> str: + members = max(0, int(member_count or 0)) + quotes = max(0, min(int(quote_count or 0), members)) + if members <= 0: + if coverage is not None and float(coverage) >= 90: + return "" + if coverage is not None: + return "行业成分行情覆盖率低于90%" + return "申万有效成分为空" + explained = quotes if explained_count is None else max( + quotes, min(int(explained_count or 0), members) + ) + actual_coverage = ( + float(coverage) + if coverage is not None + else explained / members * 100 + ) + missing = members - explained + if members <= 7 and missing: + return f"小型行业有效成分状态仅确认 {explained}/{members},要求全部可解释" + if members <= 20 and (actual_coverage < 90 or missing > 1): + return f"中型行业有效成分状态仅确认 {explained}/{members},要求覆盖率至少90%且最多缺1只" + if members > 20 and actual_coverage < 90: + return f"行业有效成分状态仅确认 {explained}/{members},覆盖率低于90%" + return "" + + +def _membership_active_on(row: dict[str, Any], trade_date: str) -> bool: + start = str(row.get("in_date") or "") + end = str(row.get("out_date") or "") + return (not start or start <= trade_date) and (not end or end > trade_date) + + +def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Merge duplicate Y/N membership rows before evaluating their date interval.""" + reconciled: dict[tuple[str, str, str, str, str], dict[str, Any]] = {} + for raw in rows: + row = dict(raw) + key = ( + str(row.get("ts_code") or ""), + str(row.get("l1_code") or ""), + str(row.get("l2_code") or ""), + str(row.get("l3_code") or ""), + str(row.get("in_date") or ""), + ) + current = reconciled.get(key) + if current is None: + reconciled[key] = row + continue + current_end = str(current.get("out_date") or "") + candidate_end = str(row.get("out_date") or "") + if candidate_end and not current_end: + current["out_date"] = candidate_end + current["is_new"] = row.get("is_new") or current.get("is_new") + for field, value in row.items(): + if not current.get(field) and value not in (None, ""): + current[field] = value + return list(reconciled.values()) + + +def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None: + if not rows: + return None + target = identifier.strip().upper() + code_match = next( + (row for row in rows if str(row.get("ts_code") or "").strip().upper() == target), + None, + ) + if code_match: + return code_match + + def normalized(value: Any) -> str: + text = str(value or "").strip().replace(" ", "") + for suffix in ("板块", "概念", "行业"): + text = text.removesuffix(suffix) + aliases = { + "元器件": "元件", + "电子元器件": "元件", + } + return aliases.get(text, text) + + target_name = normalized(identifier) + exact = [row for row in rows if normalized(row.get("name")) == target_name] + if exact: + return min(exact, key=_sector_match_priority) + fuzzy = [ + row for row in rows + if target_name and ( + target_name in normalized(row.get("name")) + or normalized(row.get("name")) in target_name + ) + ] + return min( + fuzzy, + key=lambda row: (len(normalized(row.get("name"))), *_sector_match_priority(row)), + ) if fuzzy else None + + +def _sector_match_priority(row: dict[str, Any]) -> tuple[int, int, int]: + code = str(row.get("ts_code") or "") + exchange = str(row.get("exchange") or "").upper() + return ( + 0 if exchange == "A" else 1, + 0 if code.startswith("881") else 1, + 0 if _number(row.get("count")) > 0 else 1, + ) + + +def _prices_equal(left: Any, right: Any) -> bool: + if left is None or right is None: + return False + return abs(_number(left) - _number(right)) < 0.005 + + +def _value_percentile(value: float, population: list[float]) -> float: + valid = sorted(item for item in population if item >= 0) + if not valid: + return 0.0 + below = sum(item < value for item in valid) + equal = sum(item == value for item in valid) + return (below + equal * 0.5) / len(valid) + + +def _trading_session_progress(current_time: dt_time) -> float: + morning_start = dt_time(9, 30) + morning_end = dt_time(11, 30) + afternoon_start = dt_time(13, 0) + afternoon_end = dt_time(15, 0) + if current_time <= morning_start: + return 0.05 + if current_time <= morning_end: + minutes = (current_time.hour * 60 + current_time.minute) - (9 * 60 + 30) + return max(0.05, min(0.5, minutes / 240)) + if current_time < afternoon_start: + return 0.5 + if current_time <= afternoon_end: + minutes = (current_time.hour * 60 + current_time.minute) - 13 * 60 + return max(0.5, min(1.0, 0.5 + minutes / 240)) + return 1.0 + + +def _display_time(value: Any) -> str: + raw = str(value or "").replace(":", "").zfill(6) + if not raw.strip("0"): + return "--" + return f"{raw[:2]}:{raw[2:4]}:{raw[4:6]}" + + +def _display_date(value: str) -> str: + return f"{value[:4]}-{value[4:6]}-{value[6:8]}" if len(value) == 8 else value + + +def _realtime_market_status(current_time: dt_time) -> str: + if current_time < dt_time(9, 25): + return "pre_open" + if current_time < dt_time(9, 30): + return "auction" + if current_time <= dt_time(11, 30) or dt_time(13, 0) <= current_time <= dt_time(15, 0): + return "trading" + if current_time < dt_time(13, 0): + return "lunch_break" + return "closed" + + +def _build_overview( + daily: list[dict[str, Any]], + up_rows: list[dict[str, Any]], + down_rows: list[dict[str, Any]], + broken_rows: list[dict[str, Any]], +) -> dict[str, Any]: + up_count = sum(1 for row in daily if _number(row.get("pct_chg")) > 0) + down_count = sum(1 for row in daily if _number(row.get("pct_chg")) < 0) + flat_count = len(daily) - up_count - down_count + amount_billion = sum( + _number(row.get("amount")) + / (100000000 if row.get("amount_unit") == "yuan" else 100000) + for row in daily + ) + limit_count = len(up_rows) + broken_count = len(broken_rows) + seal_rate = round(limit_count / max(limit_count + broken_count, 1) * 100, 1) + return { + "up_count": up_count, + "down_count": down_count, + "flat_count": flat_count, + "limit_up_count": limit_count, + "limit_down_count": len(down_rows), + "broken_count": broken_count, + "amount_billion": round(amount_billion, 1), + "seal_rate": seal_rate, + } + + +def _build_ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + groups: dict[int, list[dict[str, Any]]] = {} + for row in rows: + groups.setdefault(int(row.get("streak") or 1), []).append(row) + return [ + { + "level": level, + "label": "首板" if level == 1 else f"{level}板", + "count": len(stocks), + "stocks": sorted(stocks, key=lambda item: item.get("first_time") or "99:99:99"), + } + for level, stocks in sorted(groups.items(), reverse=True) + ] + + +def _build_sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + counts = Counter(row.get("sector") or "其他" for row in rows) + result: list[dict[str, Any]] = [] + for name, count in counts.most_common(20): + stocks = [row for row in rows if (row.get("sector") or "其他") == name] + max_streak = max(item.get("streak", 1) for item in stocks) + leader = max(stocks, key=lambda item: (item.get("streak", 1), item.get("amount_billion", 0))) + result.append( + { + "name": name, + "count": count, + "strength": min(100, 44 + count * 8 + max_streak * 5), + "amount_billion": round(sum(item.get("amount_billion", 0) for item in stocks), 1), + "leader": leader.get("name", "--"), + "change": round(sum(item.get("change", 0) for item in stocks) / count, 2), + "max_streak": max_streak, + } + ) + return result + + +def _build_yesterday_performance( + previous_limits: list[dict[str, Any]], + daily: list[dict[str, Any]], + current_limits: list[dict[str, Any]], + current_broken: list[dict[str, Any]], + current_down: list[dict[str, Any]], +) -> list[dict[str, Any]]: + daily_map = {str(row.get("ts_code", "")).split(".")[0]: row for row in daily} + limit_map = {row["code"]: row for row in current_limits} + broken_codes = {row["code"] for row in current_broken} + down_codes = {row["code"] for row in current_down} + result = [] + for previous in previous_limits: + code = previous["code"] + daily_row = daily_map.get(code, {}) + current = limit_map.get(code) + if current: + outcome = "晋级" + elif code in broken_codes: + outcome = "炸板" + elif code in down_codes: + outcome = "跌停" + else: + outcome = "断板" + result.append( + { + "code": code, + "name": previous["name"], + "prior_streak": previous.get("streak", 1), + "current_streak": current.get("streak", 0) if current else 0, + "current_change": _number(daily_row.get("pct_chg")), + "current_price": _number(daily_row.get("close")), + "sector": previous.get("sector", "其他"), + "reason": previous.get("reason", "待补充"), + "outcome": outcome, + } + ) + return result + + +def _build_limit_performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + result = [] + for level in sorted({int(row.get("prior_streak") or 1) for row in rows}, reverse=True): + group = [row for row in rows if int(row.get("prior_streak") or 1) == level] + advanced = sum(row.get("outcome") == "晋级" for row in group) + positive = sum(_number(row.get("current_change")) > 0 for row in group) + result.append( + { + "level": level, + "label": "昨日首板" if level == 1 else f"昨日{level}板", + "count": len(group), + "advanced": advanced, + "advance_rate": round(advanced / len(group) * 100, 1), + "positive_rate": round(positive / len(group) * 100, 1), + "average_change": round(sum(_number(row.get("current_change")) for row in group) / len(group), 2), + } + ) + return result + + +def _build_sector_rotation( + current: list[dict[str, Any]], previous: list[dict[str, Any]] +) -> list[dict[str, Any]]: + previous_map = {row["name"]: row for row in previous} + result = [] + for index, sector in enumerate(current, start=1): + previous_count = int(previous_map.get(sector["name"], {}).get("count", 0)) + delta = int(sector["count"]) - previous_count + result.append( + { + **sector, + "rank": index, + "previous_count": previous_count, + "delta": delta, + "trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平", + } + ) + return result diff --git a/app/backend/data/realtime.py b/app/backend/data/realtime.py new file mode 100644 index 0000000..d566df2 --- /dev/null +++ b/app/backend/data/realtime.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import copy +import http.client +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import datetime +from threading import Lock +from typing import Any, ClassVar + + +class RealtimeAggregateError(RuntimeError): + pass + + +EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get" +EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get" +TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006" +THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool" +XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail" +BROWSER_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/138.0.0.0 Safari/537.36" +) + + +@dataclass +class WebRealtimeAggregator: + timeout: int = 8 + retry_attempts: int = 3 + retry_delay_seconds: float = 0.2 + response_cache_ttl_seconds: int = 90 + _sector_cache: ClassVar[dict[str, Any]] = {} + _sector_cache_lock: ClassVar[Lock] = Lock() + _response_cache: ClassVar[dict[str, dict[str, Any]]] = {} + _response_cache_lock: ClassVar[Lock] = Lock() + + def health_snapshot(self, sector: str = "") -> dict[str, Any]: + started = time.perf_counter() + sources: dict[str, dict[str, Any]] = {} + indices: list[dict[str, Any]] = [] + sector_payload: dict[str, Any] | None = None + + indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices) + if sector.strip(): + sector_payload, sources["eastmoney_sector"] = self._capture( + lambda: self.eastmoney_sector(sector) + ) + ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool) + xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool) + + index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []] + now = datetime.now().astimezone() + max_skew = 120 if now.hour >= 15 else 15 + index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew + ready = ( + bool(indices) + and len(indices) == 3 + and index_consistent + and (not sector.strip() or bool(sector_payload)) + ) + return { + "ready": ready, + "isolated": True, + "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "elapsed_ms": round((time.perf_counter() - started) * 1000), + "indices": indices or [], + "index_consistent": index_consistent, + "sector": sector_payload, + "sources": sources, + "observations": { + "ths_limit_pool": ths_observation, + "xgb_limit_pool": xgb_observation, + }, + "policy": { + "integration": "heaven_realtime_fallback", + "max_index_time_skew_seconds": max_skew, + "notice": "聚合源仅作为盘中观势的实时指数与板块外显,主行情快照仍由Tushare维护。", + }, + } + + def eastmoney_indices(self) -> list[dict[str, Any]]: + try: + payload = self._get_json( + EASTMONEY_INDEX_URL, + { + "secids": "1.000001,0.399001,0.399006", + "fltt": "2", + "invt": "2", + "fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124", + }, + referer="https://quote.eastmoney.com/", + ) + except RealtimeAggregateError: + return self.tencent_indices() + cache_meta = payload.get("_aggregate_cache") or {} + rows = list((payload.get("data") or {}).get("diff") or []) + result = [] + for row in rows: + code = str(row.get("f12") or "") + if code not in {"000001", "399001", "399006"}: + continue + epoch = int(_number(row.get("f124"))) + result.append( + { + "code": code, + "name": row.get("f14") or code, + "price": _number(row.get("f2")), + "change": _number(row.get("f3")), + "change_amount": _number(row.get("f4")), + "open": _number(row.get("f17")), + "high": _number(row.get("f15")), + "low": _number(row.get("f16")), + "previous_close": _number(row.get("f18")), + "amount_billion": round(_number(row.get("f6")) / 100000000, 2), + "quote_time_epoch": epoch, + "quote_time": ( + datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds") + if epoch else "" + ), + "source": ( + "eastmoney_push2_cache" if cache_meta else "eastmoney_push2" + ), + "cache_age_seconds": cache_meta.get("age_seconds", 0), + } + ) + if len(result) != 3: + raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices") + return result + + def tencent_indices(self) -> list[dict[str, Any]]: + raw, cache_age = self._get_text( + TENCENT_INDEX_URL, + referer="https://gu.qq.com/", + encoding="gb18030", + ) + result = [] + for line in raw.splitlines(): + if '="' not in line: + continue + fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~") + if len(fields) < 38: + continue + code = fields[2] + if code not in {"000001", "399001", "399006"}: + continue + try: + quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone() + except ValueError as exc: + raise RealtimeAggregateError( + f"Tencent returned invalid quote time for {code}" + ) from exc + result.append( + { + "code": code, + "name": fields[1] or code, + "price": _number(fields[3]), + "change": _number(fields[32]), + "change_amount": _number(fields[31]), + "open": _number(fields[5]), + "high": _number(fields[33]), + "low": _number(fields[34]), + "previous_close": _number(fields[4]), + "amount_billion": round(_number(fields[37]) / 10000, 2), + "quote_time_epoch": int(quote_time.timestamp()), + "quote_time": quote_time.isoformat(timespec="seconds"), + "source": "tencent_qt_cache" if cache_age else "tencent_qt", + "cache_age_seconds": cache_age, + } + ) + if len(result) != 3: + raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices") + return result + + def eastmoney_sector(self, query: str) -> dict[str, Any]: + target = _normalize_sector(query) + candidates = self._eastmoney_sector_catalog() + matched = _match_sector(candidates, target) + if not matched: + raise RealtimeAggregateError(f"Eastmoney sector not found: {query}") + epoch = int(_number(matched.get("f124"))) + return { + "code": matched.get("f12") or "", + "name": matched.get("f14") or query, + "price": _number(matched.get("f2")), + "change": _number(matched.get("f3")), + "change_amount": _number(matched.get("f4")), + "turnover_rate": _number(matched.get("f8")), + "up_count": int(_number(matched.get("f104"))), + "down_count": int(_number(matched.get("f105"))), + "leader": matched.get("f128") or "--", + "leader_code": matched.get("f140") or "", + "leading_pct": _number(matched.get("f136")), + "quote_time_epoch": epoch, + "quote_time": ( + datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds") + if epoch else "" + ), + "source": "eastmoney_push2", + "match_query": query, + } + + def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]: + now = time.time() + with self._sector_cache_lock: + cached = self._sector_cache.get("eastmoney") + if cached and now - float(cached.get("created_at") or 0) < 600: + return list(cached.get("rows") or []) + + def load_page(page: int) -> list[dict[str, Any]]: + payload = self._get_json( + EASTMONEY_SECTOR_URL, + { + "pn": str(page), + "pz": "100", + "po": "1", + "np": "1", + "fltt": "2", + "invt": "2", + "fid": "f3", + "fs": "m:90+t:2", + "fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124", + }, + referer="https://quote.eastmoney.com/center/boardlist.html", + ) + return list((payload.get("data") or {}).get("diff") or []) + + with ThreadPoolExecutor(max_workers=5) as executor: + pages = list(executor.map(load_page, range(1, 6))) + rows = [row for page in pages for row in page] + if not rows: + raise RealtimeAggregateError("Eastmoney sector catalog is empty") + with self._sector_cache_lock: + self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows} + return rows + + def ths_limit_pool(self) -> dict[str, Any]: + payload = self._get_json( + THS_LIMIT_URL, + {"page": "1", "limit": "3", "field": "199112"}, + referer="https://data.10jqka.com.cn/limit_up/", + ) + data = payload.get("data") or payload + return { + "available": True, + "keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [], + "source": "ths_web_dataapi", + } + + def xgb_limit_pool(self) -> dict[str, Any]: + payload = self._get_json( + XGB_POOL_URL, + {"pool_name": "limit_up"}, + referer="https://xuangubao.cn/", + ) + data = payload.get("data") or {} + rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or [] + return { + "available": True, + "count": len(rows) if isinstance(rows, list) else 0, + "source": "xuangubao_web_api", + } + + def _capture(self, operation): + started = time.perf_counter() + try: + value = operation() + return value, { + "ok": True, + "elapsed_ms": round((time.perf_counter() - started) * 1000), + "error": "", + } + except Exception as exc: + return None, { + "ok": False, + "elapsed_ms": round((time.perf_counter() - started) * 1000), + "error": str(exc)[:500], + } + + def _get_json( + self, + url: str, + params: dict[str, str], + referer: str, + ) -> dict[str, Any]: + request_url = f"{url}?{urllib.parse.urlencode(params)}" + last_error: Exception | None = None + attempts = max(1, int(self.retry_attempts)) + for attempt in range(attempts): + request = urllib.request.Request( + request_url, + headers={ + "Accept": "application/json,text/plain,*/*", + "Connection": "close", + "Referer": referer, + "User-Agent": BROWSER_USER_AGENT, + }, + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + content_type = response.headers.get("Content-Type", "") + raw = response.read().decode("utf-8", errors="replace") + if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")): + raise RealtimeAggregateError( + f"non-JSON response: {raw[:120].strip()}" + ) + payload = json.loads(raw) + if not isinstance(payload, dict): + raise RealtimeAggregateError("unexpected response shape") + if payload.get("rc") not in (None, 0): + raise RealtimeAggregateError(f"provider rc={payload.get('rc')}") + with self._response_cache_lock: + self._response_cache[request_url] = { + "created_at": time.time(), + "payload": copy.deepcopy(payload), + } + return payload + except ( + urllib.error.URLError, + TimeoutError, + ConnectionError, + OSError, + http.client.HTTPException, + json.JSONDecodeError, + RealtimeAggregateError, + ) as exc: + last_error = exc + if attempt + 1 < attempts and self.retry_delay_seconds > 0: + time.sleep(self.retry_delay_seconds * (attempt + 1)) + + now = time.time() + with self._response_cache_lock: + cached = self._response_cache.get(request_url) + cache_age = now - float((cached or {}).get("created_at") or 0) + if cached and cache_age <= self.response_cache_ttl_seconds: + payload = copy.deepcopy(cached.get("payload") or {}) + payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)} + return payload + raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error + + def _get_text( + self, + request_url: str, + referer: str, + encoding: str = "utf-8", + ) -> tuple[str, float]: + cache_key = f"text:{request_url}" + last_error: Exception | None = None + attempts = max(1, int(self.retry_attempts)) + for attempt in range(attempts): + request = urllib.request.Request( + request_url, + headers={ + "Accept": "text/plain,*/*", + "Connection": "close", + "Referer": referer, + "User-Agent": BROWSER_USER_AGENT, + }, + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = response.read().decode(encoding, errors="replace") + if not raw.strip(): + raise RealtimeAggregateError("empty text response") + with self._response_cache_lock: + self._response_cache[cache_key] = { + "created_at": time.time(), + "payload": raw, + } + return raw, 0 + except ( + urllib.error.URLError, + TimeoutError, + ConnectionError, + OSError, + http.client.HTTPException, + RealtimeAggregateError, + ) as exc: + last_error = exc + if attempt + 1 < attempts and self.retry_delay_seconds > 0: + time.sleep(self.retry_delay_seconds * (attempt + 1)) + + now = time.time() + with self._response_cache_lock: + cached = self._response_cache.get(cache_key) + cache_age = now - float((cached or {}).get("created_at") or 0) + if cached and cache_age <= self.response_cache_ttl_seconds: + return str(cached.get("payload") or ""), round(cache_age, 1) + raise RealtimeAggregateError( + f"text request failed after {attempts} attempts: {last_error}" + ) from last_error + + +def _normalize_sector(value: Any) -> str: + text = str(value or "").strip().replace(" ", "") + for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"): + text = text.replace(suffix, "") + aliases = {"元器件": "元件", "电子元器件": "元件"} + return aliases.get(text, text) + + +def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None: + exact = [row for row in rows if _normalize_sector(row.get("f14")) == target] + if exact: + return min(exact, key=lambda row: len(str(row.get("f14") or ""))) + fuzzy = [ + row for row in rows + if target and ( + target in _normalize_sector(row.get("f14")) + or _normalize_sector(row.get("f14")) in target + ) + ] + return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None + + +def _number(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/app/backend/features/market/__init__.py b/app/backend/features/market/__init__.py new file mode 100644 index 0000000..0cc0632 --- /dev/null +++ b/app/backend/features/market/__init__.py @@ -0,0 +1,13 @@ +"""Public market data, search, detail and chart feature.""" + +from .charts import ChartDataError, EastmoneyChartClient, MarketChartClient +from .repository import MarketRepositoryMixin +from .service import MarketServiceMixin + +__all__ = [ + "ChartDataError", + "EastmoneyChartClient", + "MarketChartClient", + "MarketRepositoryMixin", + "MarketServiceMixin", +] diff --git a/app/backend/features/market/charts.py b/app/backend/features/market/charts.py new file mode 100644 index 0000000..97cd8b7 --- /dev/null +++ b/app/backend/features/market/charts.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import http.client +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, time as dt_time, timedelta +from threading import Lock +from typing import Any, ClassVar + +from backend.data.providers.ifind_client import IfindError, IfindHttpClient + + +class ChartDataError(RuntimeError): + pass + + +TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get" +BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get" +BROWSER_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/138.0.0.0 Safari/537.36" +) +INDEX_SECIDS = { + "000001.SH": "1.000001", + "399001.SZ": "0.399001", + "399006.SZ": "0.399006", +} + + +class MarketChartClient: + """Prefer iFinD for display charts and retain Eastmoney as a last resort.""" + + def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None: + self.ifind = ifind + self.fallback = fallback + + def stock_intraday(self, code: str) -> dict[str, Any]: + normalized = str(code or "").strip() + if not re.fullmatch(r"\d{6}", normalized): + raise ChartDataError("Invalid stock code") + ifind_code = _stock_market_code(normalized) + try: + return self._ifind_intraday(ifind_code, "stock", normalized) + except (IfindError, ChartDataError): + return self.fallback.stock_intraday(normalized) + + def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: + normalized = str(code or "").strip() + if not re.fullmatch(r"\d{6}", normalized): + raise ChartDataError("Invalid stock code") + return self._ifind_daily(_stock_market_code(normalized), end_date, limit) + + def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: + normalized = str(identifier or "").strip().upper() + if normalized not in INDEX_SECIDS: + raise ChartDataError("Unsupported index") + return self._ifind_daily(normalized, end_date, limit) + + def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: + normalized = str(identifier or "").strip().upper() + if not normalized: + raise ChartDataError("Invalid board code") + return self._ifind_daily(normalized, end_date, limit) + + def index_intraday(self, identifier: str) -> dict[str, Any]: + normalized = str(identifier or "").strip().upper() + if normalized not in INDEX_SECIDS: + raise ChartDataError("Unsupported index") + try: + return self._ifind_intraday(normalized, "index", normalized) + except (IfindError, ChartDataError): + return self.fallback.index_intraday(normalized) + + def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]: + normalized = str(identifier or "").strip().upper() + try: + return self._ifind_intraday(normalized, "board", normalized, name) + except (IfindError, ChartDataError): + return self.fallback.board_intraday(normalized, name) + + def _ifind_intraday( + self, + ifind_code: str, + entity_type: str, + identifier: str, + name: str = "", + ) -> dict[str, Any]: + if not self.ifind.configured: + raise ChartDataError("iFinD is not configured") + now = datetime.now().astimezone() + rows: list[dict[str, Any]] = [] + for offset in range(0, 8): + candidate = now.date() - timedelta(days=offset) + if candidate.weekday() >= 5: + continue + display_date = candidate.isoformat() + rows = self.ifind.intraday( + ifind_code, + f"{display_date} 09:30:00", + f"{display_date} 15:00:00", + cache_ttl=20 if offset == 0 else 6 * 60 * 60, + ) + if rows: + break + points = [point for row in rows if (point := _ifind_point(row))] + if not points: + raise ChartDataError("No iFinD intraday chart data returned") + latest_date = points[-1]["date"] + points = [point for point in points if point["date"] == latest_date] + previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"]) + return { + "entity_type": entity_type, + "identifier": identifier, + "name": name, + "code": identifier, + "trade_date": latest_date, + "previous_close": previous_close, + "points": points, + "source": "ifind", + } + + def _ifind_daily( + self, ifind_code: str, end_date: str, limit: int + ) -> list[dict[str, Any]]: + if not self.ifind.configured: + raise ChartDataError("iFinD is not configured") + compact_end = str(end_date or "").replace("-", "") + if not re.fullmatch(r"\d{8}", compact_end): + raise ChartDataError("Invalid chart end date") + end = datetime.strptime(compact_end, "%Y%m%d") + start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d") + try: + rows = self.ifind.history( + ifind_code, + ["open", "high", "low", "close", "volume", "amount"], + start, + compact_end, + cache_ttl=300, + ) + except IfindError as exc: + raise ChartDataError("No iFinD daily chart data returned") from exc + normalized = [] + for row in rows: + stamp = str(row.get("time") or "").strip() + trade_date = stamp[:10] + close = _number(row.get("close")) + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0: + continue + normalized.append( + { + "trade_date": trade_date, + "open": _number(row.get("open")), + "high": _number(row.get("high")), + "low": _number(row.get("low")), + "close": close, + "volume": _number(row.get("volume")), + "amount_billion": _number(row.get("amount")) / 100_000_000, + } + ) + normalized.sort(key=lambda row: row["trade_date"]) + for index, row in enumerate(normalized): + previous = normalized[index - 1]["close"] if index > 0 else 0 + row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0 + + market_now = datetime.now().astimezone() + today = market_now.strftime("%Y%m%d") + market_open = ( + market_now.weekday() < 5 + and market_now.time().replace(tzinfo=None) >= dt_time(9, 30) + ) + today_display = market_now.date().isoformat() + if normalized and normalized[-1]["trade_date"] == today_display: + current_bar = normalized[-1] + current_bar_is_valid = ( + current_bar["open"] > 0 + and current_bar["high"] >= max(current_bar["open"], current_bar["close"]) + and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"]) + and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0) + ) + if not market_open or not current_bar_is_valid: + normalized.pop() + if compact_end == today and market_open: + try: + quote_rows = self.ifind.real_time( + ifind_code, + ["open", "high", "low", "latest", "preClose", "volume", "amount"], + cache_ttl=10, + ) + quote = quote_rows[0] if quote_rows else {} + latest = _number(quote.get("latest")) + previous = _number(quote.get("preClose")) + open_price = _number(quote.get("open")) + high = _number(quote.get("high")) + low = _number(quote.get("low")) + volume = _number(quote.get("volume")) + amount = _number(quote.get("amount")) + quote_date = str(quote.get("time") or "")[:10].replace("-", "") + quote_is_current = not quote_date or quote_date == today + has_market_activity = volume > 0 or amount > 0 + if ( + latest > 0 + and open_price > 0 + and high >= max(open_price, latest) + and 0 < low <= min(open_price, latest) + and has_market_activity + and quote_is_current + ): + realtime = { + "trade_date": end.strftime("%Y-%m-%d"), + "open": open_price, + "high": high, + "low": low, + "close": latest, + "change": round((latest / previous - 1) * 100, 4) if previous else 0.0, + "volume": volume, + "amount_billion": amount / 100_000_000, + "realtime": True, + } + if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]: + normalized[-1] = realtime + else: + normalized.append(realtime) + except IfindError: + pass + if not normalized: + raise ChartDataError("No iFinD daily chart data returned") + return normalized[-max(20, min(180, int(limit))):] + + def _previous_close(self, code: str, trade_date: str, fallback: float) -> float: + today = datetime.now().astimezone().date().isoformat() + if trade_date == today: + try: + quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20) + value = _number((quote[0] if quote else {}).get("preClose")) + if value > 0: + return value + except IfindError: + pass + end = datetime.strptime(trade_date, "%Y-%m-%d") + try: + rows = self.ifind.history( + code, + ["close"], + (end - timedelta(days=12)).strftime("%Y%m%d"), + end.strftime("%Y%m%d"), + cache_ttl=6 * 60 * 60, + ) + closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0] + if len(closes) >= 2: + return closes[-2] + except IfindError: + pass + return fallback + + +@dataclass +class EastmoneyChartClient: + """Isolated display-only minute chart source. + + The returned data must not be used by market snapshots, scoring, screening, + or divination. Its only consumer is a chart-rendering endpoint. + """ + + timeout: int = 6 + cache_ttl_seconds: int = 20 + retry_attempts: int = 2 + _cache: ClassVar[dict[str, dict[str, Any]]] = {} + _cache_lock: ClassVar[Lock] = Lock() + _board_catalog: ClassVar[dict[str, dict[str, str]]] = {} + _board_catalog_at: ClassVar[float] = 0.0 + _board_catalog_lock: ClassVar[Lock] = Lock() + + def stock_intraday(self, code: str) -> dict[str, Any]: + normalized = str(code or "").strip() + if not re.fullmatch(r"\d{6}", normalized): + raise ChartDataError("Invalid stock code") + market = "1" if normalized.startswith(("5", "6", "9")) else "0" + return self._intraday(f"{market}.{normalized}", "stock", normalized) + + def index_intraday(self, identifier: str) -> dict[str, Any]: + normalized = str(identifier or "").strip().upper() + secid = INDEX_SECIDS.get(normalized) + if not secid: + raise ChartDataError("Unsupported index") + return self._intraday(secid, "index", normalized) + + def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]: + normalized = str(identifier or "").strip().upper() + if re.fullmatch(r"BK\d{4}", normalized): + board_code = normalized + else: + board_code = self._resolve_board_code(name or identifier) + return self._intraday(f"90.{board_code}", "board", board_code) + + def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]: + cache_key = f"{entity_type}:{identifier}" + cached = self._get_cached(cache_key) + if cached is not None: + return cached + + payload = self._request_json( + TRENDS_URL, + { + "secid": secid, + "fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13", + "fields2": "f51,f52,f53,f54,f55,f56,f57,f58", + "iscr": "0", + "ndays": "1", + }, + "https://quote.eastmoney.com/", + ) + data = payload.get("data") or {} + points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))] + if not points: + raise ChartDataError("No intraday chart data returned") + + result = { + "entity_type": entity_type, + "identifier": identifier, + "name": str(data.get("name") or ""), + "code": str(data.get("code") or identifier), + "trade_date": points[-1]["date"], + "previous_close": _number(data.get("preClose")), + "points": points, + } + with self._cache_lock: + self._cache[cache_key] = {"created_at": time.time(), "payload": result} + return result + + def _get_cached(self, cache_key: str) -> dict[str, Any] | None: + with self._cache_lock: + cached = self._cache.get(cache_key) + if not cached: + return None + if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds: + with self._cache_lock: + self._cache.pop(cache_key, None) + return None + return dict(cached["payload"]) + + def _resolve_board_code(self, name: str) -> str: + normalized = _normalize_name(name) + if not normalized: + raise ChartDataError("Board name is required") + catalog = self._load_board_catalog() + item = catalog.get(normalized) + if not item: + raise ChartDataError("No matching chart board") + return item["code"] + + def _load_board_catalog(self) -> dict[str, dict[str, str]]: + now = time.time() + with self._board_catalog_lock: + if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60: + return dict(self._board_catalog) + + rows: list[dict[str, Any]] = [] + for board_type in ("1", "2", "3"): + for page in range(1, 6): + payload = self._request_json( + BOARD_LIST_URL, + { + "pn": str(page), + "pz": "100", + "po": "1", + "np": "1", + "fltt": "2", + "invt": "2", + "fid": "f3", + "fs": f"m:90+t:{board_type}", + "fields": "f12,f14", + }, + "https://quote.eastmoney.com/center/boardlist.html", + ) + page_rows = (payload.get("data") or {}).get("diff") or [] + rows.extend(page_rows) + if len(page_rows) < 100: + break + + catalog: dict[str, dict[str, str]] = {} + for row in rows: + code = str(row.get("f12") or "").strip().upper() + board_name = str(row.get("f14") or "").strip() + if re.fullmatch(r"BK\d{4}", code) and board_name: + catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name}) + if not catalog: + raise ChartDataError("Board chart directory is unavailable") + with self._board_catalog_lock: + type(self)._board_catalog = catalog + type(self)._board_catalog_at = now + return dict(catalog) + + def _request_json( + self, url: str, params: dict[str, str], referer: str + ) -> dict[str, Any]: + request_url = f"{url}?{urllib.parse.urlencode(params)}" + last_error: Exception | None = None + for attempt in range(max(1, int(self.retry_attempts))): + request = urllib.request.Request( + request_url, + headers={ + "Accept": "application/json,text/plain,*/*", + "Connection": "close", + "Referer": referer, + "User-Agent": BROWSER_USER_AGENT, + }, + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, dict): + raise ChartDataError("Invalid intraday chart response") + return payload + except ( + urllib.error.URLError, + TimeoutError, + ConnectionError, + OSError, + http.client.HTTPException, + json.JSONDecodeError, + ChartDataError, + ) as exc: + last_error = exc + if attempt + 1 < self.retry_attempts: + time.sleep(0.12) + raise ChartDataError("Intraday chart request failed") from last_error + + +def _parse_trend(raw: Any) -> dict[str, Any] | None: + fields = str(raw or "").split(",") + if len(fields) < 8 or " " not in fields[0]: + return None + stamp = fields[0].strip() + trade_date, trade_time = stamp.split(" ", 1) + close = _number(fields[2]) + if close <= 0: + return None + return { + "date": trade_date, + "time": trade_time[:5], + "open": _number(fields[1]), + "close": close, + "high": _number(fields[3]), + "low": _number(fields[4]), + "volume": _number(fields[5]), + "amount": _number(fields[6]), + "average": _number(fields[7]), + } + + +def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None: + stamp = str(row.get("time") or "").strip() + if " " not in stamp: + return None + trade_date, trade_time = stamp.split(" ", 1) + close = _number(row.get("close")) + if close <= 0: + return None + return { + "date": trade_date, + "time": trade_time[:5], + "open": _number(row.get("open")), + "close": close, + "high": _number(row.get("high")), + "low": _number(row.get("low")), + "volume": _number(row.get("volume")), + "amount": _number(row.get("amount")), + "average": _number(row.get("avgPrice")), + } + + +def _stock_market_code(code: str) -> str: + if code.startswith(("4", "8", "9")): + suffix = "BJ" + elif code.startswith("6"): + suffix = "SH" + else: + suffix = "SZ" + return f"{code}.{suffix}" + + +def _number(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def _normalize_name(value: Any) -> str: + normalized = re.sub(r"[\s·・()()\-_/]", "", str(value or "")).casefold() + return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized) diff --git a/app/backend/features/market/repository.py b/app/backend/features/market/repository.py new file mode 100644 index 0000000..b36e26d --- /dev/null +++ b/app/backend/features/market/repository.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + + +class MarketRepositoryMixin: + def get_snapshot(self, trade_date: str) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + "SELECT payload FROM dashboard_snapshots WHERE trade_date = ?", + (trade_date,), + ).fetchone() + if not row: + return None + try: + return json.loads(row["payload"]) + except json.JSONDecodeError: + return None + + def get_latest_real_snapshot( + self, trade_date: str, strictly_before: bool = False + ) -> dict[str, Any] | None: + operator = "<" if strictly_before else "<=" + with self.connect() as connection: + row = connection.execute( + f""" + SELECT payload FROM dashboard_snapshots + WHERE trade_date {operator} ? AND source != 'demo' + ORDER BY trade_date DESC LIMIT 1 + """, + (trade_date,), + ).fetchone() + if not row: + return None + try: + return json.loads(row["payload"]) + except json.JSONDecodeError: + return None + + def save_snapshot(self, trade_date: str, source: str, payload: dict[str, Any]) -> None: + updated_at = datetime.now().astimezone().isoformat(timespec="seconds") + record_count = sum( + len(payload.get(key) or []) + for key in ("limits", "broken", "down_limits", "yesterday_limits") + ) + content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + with self.connect() as connection: + connection.execute( + """ + INSERT INTO dashboard_snapshots + (trade_date, source, payload, record_count, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(trade_date) DO UPDATE SET + source = excluded.source, + payload = excluded.payload, + record_count = excluded.record_count, + updated_at = excluded.updated_at + """, + (trade_date, source, content, record_count, updated_at), + ) + + def get_data_snapshot(self, kind: str, cache_key: str) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + "SELECT payload FROM data_snapshots WHERE kind = ? AND cache_key = ?", + (kind, cache_key), + ).fetchone() + if not row: + return None + try: + return json.loads(row["payload"]) + except json.JSONDecodeError: + return None + + def get_latest_data_snapshot( + self, + kind: str, + cache_key_prefix: str, + maximum_cache_key: str, + exclude_source: str = "", + ) -> dict[str, Any] | None: + source_clause = " AND source != ?" if exclude_source else "" + parameters: list[Any] = [kind, f"{cache_key_prefix}%", maximum_cache_key] + if exclude_source: + parameters.append(exclude_source) + with self.connect() as connection: + row = connection.execute( + f""" + SELECT payload FROM data_snapshots + WHERE kind = ? AND cache_key LIKE ? AND cache_key <= ?{source_clause} + ORDER BY cache_key DESC LIMIT 1 + """, + parameters, + ).fetchone() + if not row: + return None + try: + return json.loads(row["payload"]) + except json.JSONDecodeError: + return None + + def save_data_snapshot( + self, kind: str, cache_key: str, source: str, payload: dict[str, Any] + ) -> None: + updated_at = datetime.now().astimezone().isoformat(timespec="seconds") + content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + with self.connect() as connection: + connection.execute( + """ + INSERT INTO data_snapshots (kind, cache_key, source, payload, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(kind, cache_key) DO UPDATE SET + source = excluded.source, + payload = excluded.payload, + updated_at = excluded.updated_at + """, + (kind, cache_key, source, content, updated_at), + ) + + def search_stock_master(self, query: str, limit: int = 12) -> list[dict[str, Any]]: + text = str(query or "").strip() + if not text: + return [] + escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + with self.connect() as connection: + rows = connection.execute( + """ + SELECT ts_code, code, name, industry, market, list_date + FROM stock_master + WHERE code = ? OR name = ? OR name LIKE ? ESCAPE '\\' + ORDER BY + CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 ELSE 2 END, + list_date DESC, + code + LIMIT ? + """, + (text, text, f"%{escaped}%", text, text, max(1, min(30, int(limit)))), + ).fetchall() + return [dict(row) for row in rows] + + def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT trade_date, payload FROM dashboard_snapshots + WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT ? + """, + (end_date, limit), + ).fetchall() + result: list[dict[str, Any]] = [] + for row in reversed(rows): + try: + payload = json.loads(row["payload"]) + except json.JSONDecodeError: + continue + payload["_snapshot_date"] = row["trade_date"] + result.append(payload) + return result + + def start_sync(self, trade_date: str, source: str) -> int: + started_at = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + INSERT INTO sync_runs (trade_date, source, status, started_at) + VALUES (?, ?, 'running', ?) + """, + (trade_date, source, started_at), + ) + return int(cursor.lastrowid) + + def finish_sync( + self, + sync_id: int, + status: str, + record_count: int = 0, + message: str = "", + source: str | None = None, + ) -> None: + finished_at = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + UPDATE sync_runs + SET status = ?, finished_at = ?, record_count = ?, message = ?, + source = COALESCE(?, source) + WHERE id = ? + """, + (status, finished_at, record_count, message[:1000], source, sync_id), + ) + + def status(self) -> dict[str, Any]: + with self.connect() as connection: + last_sync = connection.execute( + """ + SELECT id, trade_date, source, status, started_at, finished_at, + record_count, message + FROM sync_runs ORDER BY id DESC LIMIT 1 + """ + ).fetchone() + snapshot_stats = connection.execute( + """ + SELECT COUNT(*) AS dates, COALESCE(SUM(record_count), 0) AS records, + MAX(updated_at) AS updated_at + FROM dashboard_snapshots + """ + ).fetchone() + watchlist_count = connection.execute("SELECT COUNT(*) FROM watchlist").fetchone()[0] + note_count = connection.execute("SELECT COUNT(*) FROM review_notes").fetchone()[0] + + return { + "database": str(self.path.name), + "snapshot_dates": int(snapshot_stats["dates"]), + "snapshot_records": int(snapshot_stats["records"]), + "updated_at": snapshot_stats["updated_at"], + "last_sync": dict(last_sync) if last_sync else None, + "watchlist_count": int(watchlist_count), + "note_count": int(note_count), + } + diff --git a/app/backend/features/market/service.py b/app/backend/features/market/service.py new file mode 100644 index 0000000..3d5dccd --- /dev/null +++ b/app/backend/features/market/service.py @@ -0,0 +1,949 @@ +from __future__ import annotations + +import copy +import re +from datetime import date, datetime, time as dt_time, timedelta +from typing import Any + +from backend.bootstrap.config import ( + normalize_date, + tushare_code, + validate_stock_code, + validate_text, +) +from backend.data.providers.ifind_client import IfindError +from backend.data.providers.tushare_client import TushareClient, TushareError +from backend.features.market.charts import ChartDataError +from sentiment_engine import SENTIMENT_ENGINE_VERSION + + +SEARCH_INDEXES = ( + {"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"}, + {"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"}, + {"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"}, +) +SEARCH_TYPE_LABELS = { + "stock": "股票", + "sector": "板块", + "theme": "题材", + "index": "指数", +} +THS_SEARCH_TYPES = { + "I": ("sector", "行业板块"), + "R": ("sector", "地域板块"), + "N": ("theme", "概念题材"), +} + + +class MarketServiceMixin: + def _tushare_client(self) -> TushareClient: + gateway = getattr(self, "data_gateway", None) + if gateway is not None: + return gateway.tushare() + # Compatibility for isolated legacy unit-test service stubs. + return TushareClient(self.token) + + def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + now = datetime.now().astimezone() + if ( + normalized_date == now.strftime("%Y%m%d") + and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time() + ): + previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True) + if previous: + carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情") + return self._apply_reason_overrides(self._with_storage(carried, cached=True)) + if not force: + snapshot = self.database.get_snapshot(normalized_date) + if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo": + snapshot = copy.deepcopy(snapshot) + if normalized_date != now.strftime("%Y%m%d"): + snapshot.setdefault("meta", {}).update( + {"realtime": False, "market_status": "closed"} + ) + if not self._dashboard_sentiment_ready(snapshot): + snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) + self.database.save_snapshot( + normalized_date, + str((snapshot.get("meta") or {}).get("source") or "tushare"), + snapshot, + ) + snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date) + return self._apply_reason_overrides(self._with_storage(snapshot, cached=True)) + resolved = self.database.get_data_snapshot( + "dashboard_request_v1", normalized_date + ) + if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo": + resolved = copy.deepcopy(resolved) + resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date( + normalized_date + ) + return self._apply_reason_overrides( + self._with_storage(resolved, cached=True) + ) + if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5: + previous = self.database.get_latest_real_snapshot(normalized_date) + if previous: + carried = self._carry_dashboard( + previous, + normalized_date, + "非交易日沿用最近交易日收盘行情", + ) + self.database.save_data_snapshot( + "dashboard_request_v1", normalized_date, "sqlite", carried + ) + return self._apply_reason_overrides( + self._with_storage(carried, cached=True) + ) + return self.sync_dashboard(normalized_date) + + @staticmethod + def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool: + overview = dashboard.get("overview") or {} + return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all( + key in overview + for key in ( + "sentiment_score", + "sentiment_label", + "sentiment_phase", + "sentiment_direction", + "sentiment_components", + ) + ) + + @staticmethod + def _display_compact_date(compact: str) -> str: + return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}" + + def _carry_dashboard( + self, snapshot: dict[str, Any], requested_date: str, reason: str + ) -> dict[str, Any]: + carried = copy.deepcopy(snapshot) + meta = carried.setdefault("meta", {}) + meta.update( + { + "requested_date": self._display_compact_date(requested_date), + "carried_forward": True, + "realtime": False, + "market_status": "closed", + "notice": reason, + } + ) + return carried + + def _realtime_snapshot_due( + self, + normalized_date: str, + snapshot: dict[str, Any], + ) -> bool: + if not self.configured or normalized_date != date.today().strftime("%Y%m%d"): + return False + now = datetime.now().astimezone() + local_time = now.time().replace(tzinfo=None) + realtime_start = datetime.strptime("09:15", "%H:%M").time() + morning_end = datetime.strptime("11:35", "%H:%M").time() + afternoon_start = datetime.strptime("12:55", "%H:%M").time() + realtime_end = datetime.strptime("15:05", "%H:%M").time() + in_session = ( + realtime_start <= local_time < morning_end + or afternoon_start <= local_time < realtime_end + ) + if not in_session: + return False + meta = snapshot.get("meta") or {} + snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "") + if snapshot_trade_date and snapshot_trade_date != normalized_date: + return False + if not meta.get("realtime"): + return True + try: + updated_at = datetime.fromisoformat(str(meta.get("updated_at") or "")) + if updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=now.tzinfo) + except ValueError: + return True + age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds() + return age_seconds >= 8 + + def sync_dashboard(self, trade_date: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + source = "tushare" + with self.sync_lock: + sync_id = self.database.start_sync(normalized_date, source) + try: + if not self.configured: + raise TushareError("公共行情尚未配置") + dashboard = self._tushare_client().dashboard(normalized_date) + + dashboard["meta"]["source"] = source + dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date) + dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) + record_count = self._record_count(dashboard) + actual_date = normalize_date( + str(dashboard.get("meta", {}).get("trade_date") or normalized_date) + ) + self.database.save_snapshot(actual_date, source, dashboard) + if actual_date != normalized_date: + dashboard.setdefault("meta", {}).update( + { + "carried_forward": True, + "realtime": False, + "market_status": "closed", + } + ) + self.database.save_data_snapshot( + "dashboard_request_v1", normalized_date, source, dashboard + ) + self.database.finish_sync( + sync_id, + "success", + record_count, + dashboard.get("meta", {}).get("notice", ""), + source, + ) + return self._apply_reason_overrides(self._with_storage(dashboard, cached=False)) + except TushareError as exc: + fallback = self.database.get_latest_real_snapshot(normalized_date) + if fallback: + carried = self._carry_dashboard( + fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}" + ) + self.database.finish_sync( + sync_id, "fallback", self._record_count(carried), str(exc), "tushare" + ) + return self._apply_reason_overrides(self._with_storage(carried, cached=True)) + self.database.finish_sync(sync_id, "failed", message=str(exc)) + raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc + except Exception as exc: + self.database.finish_sync(sync_id, "failed", message=str(exc)) + raise + + def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]: + sector = validate_text(sector, "板块名称", 50) + return self.realtime_aggregator.health_snapshot(sector) + + def _search_market_directory(self) -> list[dict[str, Any]]: + cached = self.database.get_data_snapshot("search_directory", "ths") or {} + cached_items = list(cached.get("items") or []) + if cached_items and int(cached.get("schema_version") or 0) >= 2: + return cached_items + if not self.configured: + return cached_items + + try: + rows = self._tushare_client().query( + "ths_index", + {}, + "ts_code,name,count,exchange,list_date,type", + ) + except TushareError: + return cached_items + + items = [] + for row in rows: + mapping = THS_SEARCH_TYPES.get(str(row.get("type") or "").upper()) + code = str(row.get("ts_code") or "").strip().upper() + name = str(row.get("name") or "").strip() + if not mapping or not code or not name or str(row.get("exchange") or "").upper() != "A": + continue + entity_type, subtitle = mapping + items.append( + { + "id": code, + "code": code, + "name": name, + "type": entity_type, + "subtitle": subtitle, + "member_count": int(float(row.get("count") or 0)), + } + ) + if items: + self.database.save_data_snapshot( + "search_directory", "ths", "tushare", {"schema_version": 2, "items": items} + ) + return items + + @staticmethod + def _search_match_score(item: dict[str, Any], query: str) -> tuple[int, int, str]: + name = str(item.get("name") or "").casefold() + code = str(item.get("code") or item.get("id") or "").casefold() + needle = query.casefold() + if code == needle: + rank = 0 + elif name == needle: + rank = 1 + elif code.startswith(needle): + rank = 2 + elif name.startswith(needle): + rank = 3 + else: + rank = 4 + return rank, len(name), code + + def search_entities(self, query: str, trade_date: str) -> dict[str, Any]: + needle = str(query or "").strip() + normalized_date = normalize_date(trade_date) + groups: dict[str, list[dict[str, Any]]] = { + "stocks": [], + "sectors": [], + "themes": [], + "indices": [], + } + if not needle: + return {"query": "", "trade_date": normalized_date, "groups": groups} + + stocks = [] + for row in self.database.search_stock_master(needle, 12): + stocks.append( + { + "id": str(row.get("code") or ""), + "code": str(row.get("code") or ""), + "name": str(row.get("name") or "--"), + "type": "stock", + "type_label": SEARCH_TYPE_LABELS["stock"], + "industry": str(row.get("industry") or "其他"), + "market": str(row.get("market") or ""), + "subtitle": " · ".join( + part for part in (str(row.get("industry") or ""), str(row.get("market") or "")) if part + ) or "A股", + } + ) + groups["stocks"] = stocks[:8] + + market_items = list(self._search_market_directory()) + [dict(item) for item in SEARCH_INDEXES] + matched = [ + item for item in market_items + if needle.casefold() in str(item.get("name") or "").casefold() + or needle.casefold() in str(item.get("code") or "").casefold() + ] + matched.sort(key=lambda item: self._search_match_score(item, needle)) + group_keys = {"sector": "sectors", "theme": "themes", "index": "indices"} + for item in matched: + group_key = group_keys.get(str(item.get("type") or "")) + if not group_key or len(groups[group_key]) >= 8: + continue + groups[group_key].append( + { + **item, + "type_label": SEARCH_TYPE_LABELS[str(item["type"])], + } + ) + return {"query": needle, "trade_date": normalized_date, "groups": groups} + + def get_search_detail( + self, entity_type: str, identifier: str, trade_date: str + ) -> dict[str, Any]: + entity_type = str(entity_type or "").strip().lower() + identifier = str(identifier or "").strip().upper() + normalized_date = normalize_date(trade_date) + if entity_type not in {"sector", "theme", "index"}: + raise ValueError("搜索详情类型不支持。") + if not re.fullmatch(r"[A-Z0-9.]{3,24}", identifier): + raise ValueError("搜索详情标识无效。") + if not self.configured: + raise ValueError("行情数据源尚未配置。") + + if entity_type == "index": + index_basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None) + if not index_basic: + raise ValueError("暂不支持该指数详情。") + return self._index_search_detail(index_basic, normalized_date) + + directory = self._search_market_directory() + basic = next( + ( + item for item in directory + if item.get("id") == identifier and item.get("type") == entity_type + ), + None, + ) + if not basic: + raise ValueError("未找到对应的板块或题材。") + return self._ths_search_detail(basic, normalized_date) + + def get_intraday_chart( + self, entity_type: str, identifier: str + ) -> dict[str, Any]: + entity_type = str(entity_type or "").strip().lower() + identifier = str(identifier or "").strip().upper() + if entity_type == "stock": + code = validate_stock_code(identifier) + chart = self.chart_data.stock_intraday(code) + type_label = SEARCH_TYPE_LABELS["stock"] + elif entity_type == "index": + basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None) + if not basic: + raise ValueError("暂不支持该指数分时行情。") + chart = self.chart_data.index_intraday(identifier) + type_label = SEARCH_TYPE_LABELS["index"] + elif entity_type in {"sector", "theme"}: + basic = next( + ( + item for item in self._search_market_directory() + if item.get("id") == identifier and item.get("type") == entity_type + ), + None, + ) + if not basic: + raise ValueError("未找到对应的板块或题材。") + chart = self.chart_data.board_intraday(identifier, str(basic.get("name") or "")) + type_label = SEARCH_TYPE_LABELS[entity_type] + else: + raise ValueError("分时行情类型不支持。") + + return { + "meta": { + "trade_date": str(chart.get("trade_date") or ""), + "previous_close": float(chart.get("previous_close") or 0), + }, + "entity": { + "id": identifier, + "code": str(chart.get("code") or identifier), + "name": str(chart.get("name") or ""), + "type": entity_type, + "type_label": type_label, + }, + "points": list(chart.get("points") or []), + } + + def _ths_search_detail( + self, basic: dict[str, Any], trade_date: str + ) -> dict[str, Any]: + client = self._tushare_client() + resolved_date, _ = client.resolve_trade_context(trade_date) + end = datetime.strptime(resolved_date, "%Y%m%d") + start_date = (end - timedelta(days=190)).strftime("%Y%m%d") + identifier = str(basic["id"]) + snapshot = client.sector_snapshot(identifier, resolved_date) + rows = client.query( + "ths_daily", + {"ts_code": identifier, "start_date": start_date, "end_date": resolved_date}, + "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate,total_mv,float_mv", + ) + rows.sort(key=lambda item: str(item.get("trade_date") or "")) + series = [ + { + "trade_date": self._display_compact_date(str(row.get("trade_date") or "")), + "open": float(row.get("open") or 0), + "high": float(row.get("high") or 0), + "low": float(row.get("low") or 0), + "close": float(row.get("close") or 0), + "change": float(row.get("pct_change") or 0), + "volume": float(row.get("vol") or 0), + "turnover_rate": float(row.get("turnover_rate") or 0), + } + for row in rows[-90:] + ] + try: + chart_series = self.chart_data.board_daily(identifier, resolved_date, 90) + if chart_series: + series = chart_series + except (AttributeError, ChartDataError): + pass + latest = series[-1] if series else {} + snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date + change = float( + snapshot.get("change") + if snapshot_is_current and snapshot.get("change") is not None + else latest.get("change") or 0 + ) + if latest.get("realtime"): + change = float(latest.get("change") or 0) + turnover_rate = float( + snapshot.get("turnover_rate") + if snapshot_is_current and snapshot.get("turnover_rate") is not None + else latest.get("turnover_rate") or 0 + ) + metrics = [ + {"label": "涨跌幅", "value": round(change, 2), "unit": "%", "tone": "change"}, + {"label": "换手率", "value": round(turnover_rate, 2), "unit": "%"}, + {"label": "成份数量", "value": int(float(basic.get("member_count") or 0)), "unit": "只"}, + ] + up_count = int(float(snapshot.get("up_count") or 0)) + down_count = int(float(snapshot.get("down_count") or 0)) + if up_count or down_count: + metrics.extend( + [ + {"label": "上涨家数", "value": up_count, "unit": "家"}, + {"label": "下跌家数", "value": down_count, "unit": "家"}, + ] + ) + leader = str(snapshot.get("leader") or "").strip() + if leader and leader != "--": + metrics.extend( + [ + {"label": "领涨标的", "value": leader, "unit": ""}, + {"label": "领涨幅", "value": round(float(snapshot.get("leading_pct") or 0), 2), "unit": "%", "tone": "change"}, + ] + ) + return { + "meta": { + "trade_date": self._display_compact_date(resolved_date), + "realtime": bool(snapshot.get("realtime")), + }, + "entity": { + "id": identifier, + "code": identifier, + "name": str(snapshot.get("name") or basic.get("name") or "--"), + "type": str(basic.get("type") or "sector"), + "type_label": SEARCH_TYPE_LABELS[str(basic.get("type") or "sector")], + "subtitle": str(basic.get("subtitle") or ""), + "value": float(latest.get("close") or 0), + "change": change, + }, + "series": series, + "metrics": metrics, + } + + def _index_search_detail( + self, basic: dict[str, Any], trade_date: str + ) -> dict[str, Any]: + client = self._tushare_client() + resolved_date, _ = client.resolve_trade_context(trade_date) + payload = ( + client.realtime_market_indices(resolved_date) + if client.should_use_realtime(trade_date, resolved_date) + else client.market_indices(resolved_date, 90) + ) + current = next( + (item for item in payload.get("indices") or [] if item.get("ts_code") == basic["id"]), + None, + ) + if not current: + raise ValueError("该指数暂无可用行情。") + end = datetime.strptime(resolved_date, "%Y%m%d") + rows = client.query( + "index_daily", + { + "ts_code": basic["id"], + "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"), + "end_date": resolved_date, + }, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ) + rows.sort(key=lambda item: str(item.get("trade_date") or "")) + series = [ + { + "trade_date": self._display_compact_date(str(row.get("trade_date") or "")), + "open": float(row.get("open") or 0), + "high": float(row.get("high") or 0), + "low": float(row.get("low") or 0), + "close": float(row.get("close") or 0), + "change": float(row.get("pct_chg") or 0), + "volume": float(row.get("vol") or 0), + } + for row in rows[-90:] + ] + try: + chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90) + if chart_series: + series = chart_series + except (AttributeError, ChartDataError): + pass + latest = series[-1] if series else {} + latest_close = float(latest.get("close") or current.get("close") or 0) + latest_change = float(latest.get("change") or current.get("pct_chg") or 0) + + def series_return(days: int) -> float: + if len(series) <= days: + return 0.0 + previous = float(series[-days - 1].get("close") or 0) + return (latest_close / previous - 1) * 100 if previous > 0 else 0.0 + return { + "meta": { + "trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)), + "realtime": bool(payload.get("realtime")), + }, + "entity": { + **basic, + "type_label": SEARCH_TYPE_LABELS["index"], + "value": latest_close, + "change": latest_change, + }, + "series": series, + "metrics": [ + {"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"}, + {"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"}, + {"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"}, + {"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"}, + ], + } + + def get_stock_detail( + self, code: str, trade_date: str, force: bool = False + ) -> dict[str, Any]: + code = validate_stock_code(code) + normalized_date = normalize_date(trade_date) + cache_key = f"{code}:{normalized_date}" + if not force: + cached = self.database.get_data_snapshot("stock_detail", cache_key) + if cached and str((cached.get("meta") or {}).get("source") or "") != "demo": + if not self._stock_detail_cache_needs_refresh(cached, normalized_date): + cached["meta"] = {**cached.get("meta", {}), "cached": True} + return self._prepare_stock_detail(cached, code, normalized_date) + + name, sector = self._stock_identity(code, normalized_date) + source = "tushare" + if self.configured: + try: + payload = self._tushare_client().stock_detail( + tushare_code(code), normalized_date + ) + if not payload.get("prices"): + raise TushareError("No price history returned") + except TushareError as exc: + payload = self.database.get_latest_data_snapshot( + "stock_detail", f"{code}:", cache_key, exclude_source="demo" + ) + if not payload: + raise ValueError(f"暂无 {code} 的真实行情数据:{exc}") from exc + payload = copy.deepcopy(payload) + payload["meta"] = { + **payload.get("meta", {}), + "cached": True, + "notice": "最新行情暂不可用,已沿用最近真实收盘数据。", + } + return self._prepare_stock_detail(payload, code, normalized_date) + else: + payload = self.database.get_latest_data_snapshot( + "stock_detail", f"{code}:", cache_key, exclude_source="demo" + ) + if not payload: + raise ValueError(f"暂无 {code} 的真实行情数据,请等待后台完成首次同步。") + payload = copy.deepcopy(payload) + payload["meta"] = { + **payload.get("meta", {}), + "cached": True, + "notice": "公共行情尚未配置,已沿用最近真实收盘数据。", + } + return self._prepare_stock_detail(payload, code, normalized_date) + payload["meta"]["source"] = source + payload["meta"]["cached"] = False + self.database.save_data_snapshot("stock_detail", cache_key, source, payload) + return self._prepare_stock_detail(payload, code, normalized_date) + + @staticmethod + def _stock_detail_bar_date(payload: dict[str, Any]) -> str: + prices = list(payload.get("prices") or []) + return str((prices[-1] if prices else {}).get("trade_date") or "").replace("-", "") + + def _stock_detail_cache_needs_refresh( + self, payload: dict[str, Any], requested_date: str + ) -> bool: + now = datetime.now().astimezone() + return ( + requested_date == now.strftime("%Y%m%d") + and now.time().replace(tzinfo=None) >= dt_time(15, 0) + and self._stock_detail_bar_date(payload) < requested_date + ) + + def _prepare_stock_detail( + self, payload: dict[str, Any], code: str, requested_date: str + ) -> dict[str, Any]: + result = copy.deepcopy(payload) + now = datetime.now().astimezone() + try: + result["prices"] = self.chart_data.stock_daily(code, requested_date, 90) + result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"} + except (AttributeError, ChartDataError): + pass + result = self._sanitize_stock_detail_prices(result, now) + actual_date = self._stock_detail_bar_date(result) + if actual_date: + result["meta"] = { + **(result.get("meta") or {}), + "trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}", + } + today = now.strftime("%Y%m%d") + should_merge = ( + requested_date == today + and actual_date <= today + and now.weekday() < 5 + and now.time().replace(tzinfo=None) >= dt_time(9, 30) + ) + if should_merge: + quote = self._ifind_realtime_stock_quote(code) + if quote and self._valid_realtime_stock_quote(quote, today): + self._merge_realtime_stock_detail(result, quote, requested_date) + elif self.configured and actual_date < today: + client = self._tushare_client() + try: + resolved_date, _ = client.resolve_trade_context(requested_date) + if resolved_date == today: + quote = client.realtime_stock_quote(tushare_code(code), requested_date) + if self._valid_realtime_stock_quote(quote, today): + self._merge_realtime_stock_detail(result, quote, requested_date) + except TushareError: + pass + return self._enrich_stock_detail(result) + + @staticmethod + def _sanitize_stock_detail_prices( + payload: dict[str, Any], market_now: datetime + ) -> dict[str, Any]: + result = copy.deepcopy(payload) + raw_prices = list(result.get("prices") or []) + raw_latest_date = str( + (raw_prices[-1] if raw_prices else {}).get("trade_date") or "" + ).replace("-", "") + prices = [] + for bar in raw_prices: + open_price = float(bar.get("open") or 0) + high = float(bar.get("high") or 0) + low = float(bar.get("low") or 0) + close = float(bar.get("close") or 0) + if ( + open_price > 0 + and high >= max(open_price, close) + and 0 < low <= min(open_price, close) + and close > 0 + ): + prices.append(bar) + + today = market_now.strftime("%Y%m%d") + market_open = ( + market_now.weekday() < 5 + and market_now.time().replace(tzinfo=None) >= dt_time(9, 30) + ) + if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today: + current = prices[-1] + has_market_activity = ( + float(current.get("volume") or 0) > 0 + or float(current.get("amount_billion") or 0) > 0 + ) + if not market_open or not has_market_activity: + prices.pop() + + if raw_latest_date == today and ( + not prices + or str(prices[-1].get("trade_date") or "").replace("-", "") != today + ): + result["meta"] = {**(result.get("meta") or {}), "realtime": False} + + result["prices"] = prices + if prices: + latest = prices[-1] + stock = dict(result.get("stock") or {}) + stock.update( + { + "price": float(latest.get("close") or 0), + "change": float(latest.get("change") or 0), + "amount_billion": float(latest.get("amount_billion") or 0), + } + ) + result["stock"] = stock + return result + + @staticmethod + def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool: + price = float(quote.get("price") or 0) + open_price = float(quote.get("open") or 0) + high = float(quote.get("high") or 0) + low = float(quote.get("low") or 0) + volume = float(quote.get("volume") or 0) + amount = float(quote.get("amount_billion") or 0) + quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "") + return ( + price > 0 + and open_price > 0 + and high >= max(open_price, price) + and 0 < low <= min(open_price, price) + and (volume > 0 or amount > 0) + and (not quote_date or quote_date == trade_date) + ) + + def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None: + ifind = getattr(self, "ifind", None) + if not ifind or not ifind.configured: + return None + try: + rows = ifind.real_time( + tushare_code(code), + [ + "open", "high", "low", "latest", "preClose", + "volume", "amount", "turnoverRatio", + ], + cache_ttl=10, + ) + except IfindError: + return None + row = rows[0] if rows else {} + price = float(row.get("latest") or 0) + previous_close = float(row.get("preClose") or 0) + if price <= 0: + return None + change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0 + stock = self._stock_identity(code, date.today().strftime("%Y%m%d")) + return { + "name": stock[0], + "sector": stock[1], + "price": price, + "open": float(row.get("open") or price), + "high": float(row.get("high") or price), + "low": float(row.get("low") or price), + "change": round(change, 4), + "volume": float(row.get("volume") or 0), + "volume_unit": "lots", + "amount_billion": float(row.get("amount") or 0) / 100_000_000, + "turnover_rate": float(row.get("turnoverRatio") or 0), + "quote_time": str(row.get("time") or ""), + } + + @staticmethod + def _merge_realtime_stock_detail( + payload: dict[str, Any], quote: dict[str, Any], trade_date: str + ) -> None: + display_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}" + realtime_bar = { + "trade_date": display_date, + "open": quote["open"], + "high": quote["high"], + "low": quote["low"], + "close": quote["price"], + "change": quote["change"], + "volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100, + "amount_billion": quote["amount_billion"], + "realtime": True, + } + prices = list(payload.get("prices") or []) + if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == trade_date: + prices[-1] = realtime_bar + else: + prices.append(realtime_bar) + payload["prices"] = prices[-90:] + stock = dict(payload.get("stock") or {}) + stock.update( + { + "name": quote["name"], + "industry": quote["sector"], + "price": quote["price"], + "change": quote["change"], + "amount_billion": quote["amount_billion"], + "turnover_rate": quote["turnover_rate"], + } + ) + payload["stock"] = stock + payload["meta"] = { + **(payload.get("meta") or {}), + "trade_date": display_date, + "realtime": True, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + + def get_stock_preview( + self, code: str, trade_date: str, force: bool = False + ) -> dict[str, Any]: + code = validate_stock_code(code) + # Hover previews deliberately follow the latest market day, independent + # from the review date selected by the page. + detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force) + detail_meta = detail.get("meta") or {} + resolved_date = str(detail_meta.get("trade_date") or trade_date) + intraday_points: list[dict[str, Any]] = [] + intraday_status = "unavailable" + intraday_notice = "分时行情暂不可用。" + + intraday_trade_date = "" + intraday_previous_close = 0.0 + try: + intraday = self.chart_data.stock_intraday(code) + intraday_points = list(intraday.get("points") or []) + intraday_trade_date = str(intraday.get("trade_date") or "") + intraday_previous_close = float(intraday.get("previous_close") or 0) + if intraday_points: + intraday_status = "available" + intraday_notice = "" + else: + intraday_status = "empty" + intraday_notice = "最近交易日暂无分时数据。" + except ChartDataError: + intraday_status = "unavailable" + intraday_notice = "分时行情暂不可用,请稍后重试。" + + prices = list(detail.get("prices") or [])[-60:] + stock = dict(detail.get("stock") or {"code": code}) + realtime = bool(detail_meta.get("realtime")) + return { + "meta": { + "trade_date": resolved_date, + "source": detail_meta.get("source") or "unavailable", + "notice": detail_meta.get("notice") or "", + "intraday_status": intraday_status, + "intraday_notice": intraday_notice, + "intraday_trade_date": intraday_trade_date, + "intraday_previous_close": intraday_previous_close, + "realtime": realtime, + "refresh_interval_seconds": 10 if realtime else 0, + }, + "stock": stock, + "prices": prices, + "intraday": intraday_points, + } + + def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]: + start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date() + end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date() + if start > end: + raise ValueError("开始日期不能晚于结束日期。") + weekdays = [] + current = start + while current <= end: + if current.weekday() < 5: + weekdays.append(current) + current += timedelta(days=1) + if len(weekdays) > 15: + raise ValueError("单次最多回补 15 个工作日。") + results = [] + for day in weekdays: + dashboard = self.sync_dashboard(day.strftime("%Y%m%d")) + results.append( + { + "requested_date": day.isoformat(), + "trade_date": dashboard["meta"]["trade_date"], + "source": dashboard["meta"]["source"], + "records": self._record_count(dashboard), + } + ) + return results + + def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]: + snapshot = self.database.get_snapshot(trade_date) or {} + for key in ("limits", "broken", "down_limits"): + for row in snapshot.get(key) or []: + if str(row.get("code")) == code: + return row.get("name") or "--", row.get("sector") or "其他" + for item in self.database.list_watchlist(self.current_user_id): + if item["code"] == code: + return item["name"], item["sector"] or "其他" + return "--", "其他" + + def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]: + result = dict(payload) + stock = dict(payload.get("stock") or {}) + code = str(stock.get("code") or "") + watched = { + item["code"]: item + for item in self.database.list_watchlist(self.current_user_id) + } + stock["watchlist"] = watched.get(code) + result["stock"] = stock + result["notes"] = self.database.list_notes(self.current_user_id, code=code) + return result + + def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]: + result = dict(dashboard) + result["meta"] = { + **dashboard.get("meta", {}), + "storage": "sqlite", + "cached": cached, + } + return result + + @staticmethod + def _record_count(dashboard: dict[str, Any]) -> int: + return sum( + len(dashboard.get(key) or []) + for key in ("limits", "broken", "down_limits", "yesterday_limits") + ) + diff --git a/app/chart_data_provider.py b/app/chart_data_provider.py index 1259cc5..f07b035 100644 --- a/app/chart_data_provider.py +++ b/app/chart_data_provider.py @@ -1,497 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical market chart clients.""" -import http.client -import json -import re -import time -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from datetime import datetime, time as dt_time, timedelta -from threading import Lock -from typing import Any, ClassVar +import sys -from ifind_client import IfindError, IfindHttpClient +from backend.features.market import charts as _implementation - -class ChartDataError(RuntimeError): - pass - - -TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get" -BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get" -BROWSER_USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/138.0.0.0 Safari/537.36" -) -INDEX_SECIDS = { - "000001.SH": "1.000001", - "399001.SZ": "0.399001", - "399006.SZ": "0.399006", -} - - -class MarketChartClient: - """Prefer iFinD for display charts and retain Eastmoney as a last resort.""" - - def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None: - self.ifind = ifind - self.fallback = fallback - - def stock_intraday(self, code: str) -> dict[str, Any]: - normalized = str(code or "").strip() - if not re.fullmatch(r"\d{6}", normalized): - raise ChartDataError("Invalid stock code") - ifind_code = _stock_market_code(normalized) - try: - return self._ifind_intraday(ifind_code, "stock", normalized) - except (IfindError, ChartDataError): - return self.fallback.stock_intraday(normalized) - - def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: - normalized = str(code or "").strip() - if not re.fullmatch(r"\d{6}", normalized): - raise ChartDataError("Invalid stock code") - return self._ifind_daily(_stock_market_code(normalized), end_date, limit) - - def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: - normalized = str(identifier or "").strip().upper() - if normalized not in INDEX_SECIDS: - raise ChartDataError("Unsupported index") - return self._ifind_daily(normalized, end_date, limit) - - def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]: - normalized = str(identifier or "").strip().upper() - if not normalized: - raise ChartDataError("Invalid board code") - return self._ifind_daily(normalized, end_date, limit) - - def index_intraday(self, identifier: str) -> dict[str, Any]: - normalized = str(identifier or "").strip().upper() - if normalized not in INDEX_SECIDS: - raise ChartDataError("Unsupported index") - try: - return self._ifind_intraday(normalized, "index", normalized) - except (IfindError, ChartDataError): - return self.fallback.index_intraday(normalized) - - def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]: - normalized = str(identifier or "").strip().upper() - try: - return self._ifind_intraday(normalized, "board", normalized, name) - except (IfindError, ChartDataError): - return self.fallback.board_intraday(normalized, name) - - def _ifind_intraday( - self, - ifind_code: str, - entity_type: str, - identifier: str, - name: str = "", - ) -> dict[str, Any]: - if not self.ifind.configured: - raise ChartDataError("iFinD is not configured") - now = datetime.now().astimezone() - rows: list[dict[str, Any]] = [] - for offset in range(0, 8): - candidate = now.date() - timedelta(days=offset) - if candidate.weekday() >= 5: - continue - display_date = candidate.isoformat() - rows = self.ifind.intraday( - ifind_code, - f"{display_date} 09:30:00", - f"{display_date} 15:00:00", - cache_ttl=20 if offset == 0 else 6 * 60 * 60, - ) - if rows: - break - points = [point for row in rows if (point := _ifind_point(row))] - if not points: - raise ChartDataError("No iFinD intraday chart data returned") - latest_date = points[-1]["date"] - points = [point for point in points if point["date"] == latest_date] - previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"]) - return { - "entity_type": entity_type, - "identifier": identifier, - "name": name, - "code": identifier, - "trade_date": latest_date, - "previous_close": previous_close, - "points": points, - "source": "ifind", - } - - def _ifind_daily( - self, ifind_code: str, end_date: str, limit: int - ) -> list[dict[str, Any]]: - if not self.ifind.configured: - raise ChartDataError("iFinD is not configured") - compact_end = str(end_date or "").replace("-", "") - if not re.fullmatch(r"\d{8}", compact_end): - raise ChartDataError("Invalid chart end date") - end = datetime.strptime(compact_end, "%Y%m%d") - start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d") - try: - rows = self.ifind.history( - ifind_code, - ["open", "high", "low", "close", "volume", "amount"], - start, - compact_end, - cache_ttl=300, - ) - except IfindError as exc: - raise ChartDataError("No iFinD daily chart data returned") from exc - normalized = [] - for row in rows: - stamp = str(row.get("time") or "").strip() - trade_date = stamp[:10] - close = _number(row.get("close")) - if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0: - continue - normalized.append( - { - "trade_date": trade_date, - "open": _number(row.get("open")), - "high": _number(row.get("high")), - "low": _number(row.get("low")), - "close": close, - "volume": _number(row.get("volume")), - "amount_billion": _number(row.get("amount")) / 100_000_000, - } - ) - normalized.sort(key=lambda row: row["trade_date"]) - for index, row in enumerate(normalized): - previous = normalized[index - 1]["close"] if index > 0 else 0 - row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0 - - market_now = datetime.now().astimezone() - today = market_now.strftime("%Y%m%d") - market_open = ( - market_now.weekday() < 5 - and market_now.time().replace(tzinfo=None) >= dt_time(9, 30) - ) - today_display = market_now.date().isoformat() - if normalized and normalized[-1]["trade_date"] == today_display: - current_bar = normalized[-1] - current_bar_is_valid = ( - current_bar["open"] > 0 - and current_bar["high"] >= max(current_bar["open"], current_bar["close"]) - and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"]) - and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0) - ) - if not market_open or not current_bar_is_valid: - normalized.pop() - if compact_end == today and market_open: - try: - quote_rows = self.ifind.real_time( - ifind_code, - ["open", "high", "low", "latest", "preClose", "volume", "amount"], - cache_ttl=10, - ) - quote = quote_rows[0] if quote_rows else {} - latest = _number(quote.get("latest")) - previous = _number(quote.get("preClose")) - open_price = _number(quote.get("open")) - high = _number(quote.get("high")) - low = _number(quote.get("low")) - volume = _number(quote.get("volume")) - amount = _number(quote.get("amount")) - quote_date = str(quote.get("time") or "")[:10].replace("-", "") - quote_is_current = not quote_date or quote_date == today - has_market_activity = volume > 0 or amount > 0 - if ( - latest > 0 - and open_price > 0 - and high >= max(open_price, latest) - and 0 < low <= min(open_price, latest) - and has_market_activity - and quote_is_current - ): - realtime = { - "trade_date": end.strftime("%Y-%m-%d"), - "open": open_price, - "high": high, - "low": low, - "close": latest, - "change": round((latest / previous - 1) * 100, 4) if previous else 0.0, - "volume": volume, - "amount_billion": amount / 100_000_000, - "realtime": True, - } - if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]: - normalized[-1] = realtime - else: - normalized.append(realtime) - except IfindError: - pass - if not normalized: - raise ChartDataError("No iFinD daily chart data returned") - return normalized[-max(20, min(180, int(limit))):] - - def _previous_close(self, code: str, trade_date: str, fallback: float) -> float: - today = datetime.now().astimezone().date().isoformat() - if trade_date == today: - try: - quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20) - value = _number((quote[0] if quote else {}).get("preClose")) - if value > 0: - return value - except IfindError: - pass - end = datetime.strptime(trade_date, "%Y-%m-%d") - try: - rows = self.ifind.history( - code, - ["close"], - (end - timedelta(days=12)).strftime("%Y%m%d"), - end.strftime("%Y%m%d"), - cache_ttl=6 * 60 * 60, - ) - closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0] - if len(closes) >= 2: - return closes[-2] - except IfindError: - pass - return fallback - - -@dataclass -class EastmoneyChartClient: - """Isolated display-only minute chart source. - - The returned data must not be used by market snapshots, scoring, screening, - or divination. Its only consumer is a chart-rendering endpoint. - """ - - timeout: int = 6 - cache_ttl_seconds: int = 20 - retry_attempts: int = 2 - _cache: ClassVar[dict[str, dict[str, Any]]] = {} - _cache_lock: ClassVar[Lock] = Lock() - _board_catalog: ClassVar[dict[str, dict[str, str]]] = {} - _board_catalog_at: ClassVar[float] = 0.0 - _board_catalog_lock: ClassVar[Lock] = Lock() - - def stock_intraday(self, code: str) -> dict[str, Any]: - normalized = str(code or "").strip() - if not re.fullmatch(r"\d{6}", normalized): - raise ChartDataError("Invalid stock code") - market = "1" if normalized.startswith(("5", "6", "9")) else "0" - return self._intraday(f"{market}.{normalized}", "stock", normalized) - - def index_intraday(self, identifier: str) -> dict[str, Any]: - normalized = str(identifier or "").strip().upper() - secid = INDEX_SECIDS.get(normalized) - if not secid: - raise ChartDataError("Unsupported index") - return self._intraday(secid, "index", normalized) - - def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]: - normalized = str(identifier or "").strip().upper() - if re.fullmatch(r"BK\d{4}", normalized): - board_code = normalized - else: - board_code = self._resolve_board_code(name or identifier) - return self._intraday(f"90.{board_code}", "board", board_code) - - def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]: - cache_key = f"{entity_type}:{identifier}" - cached = self._get_cached(cache_key) - if cached is not None: - return cached - - payload = self._request_json( - TRENDS_URL, - { - "secid": secid, - "fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13", - "fields2": "f51,f52,f53,f54,f55,f56,f57,f58", - "iscr": "0", - "ndays": "1", - }, - "https://quote.eastmoney.com/", - ) - data = payload.get("data") or {} - points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))] - if not points: - raise ChartDataError("No intraday chart data returned") - - result = { - "entity_type": entity_type, - "identifier": identifier, - "name": str(data.get("name") or ""), - "code": str(data.get("code") or identifier), - "trade_date": points[-1]["date"], - "previous_close": _number(data.get("preClose")), - "points": points, - } - with self._cache_lock: - self._cache[cache_key] = {"created_at": time.time(), "payload": result} - return result - - def _get_cached(self, cache_key: str) -> dict[str, Any] | None: - with self._cache_lock: - cached = self._cache.get(cache_key) - if not cached: - return None - if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds: - with self._cache_lock: - self._cache.pop(cache_key, None) - return None - return dict(cached["payload"]) - - def _resolve_board_code(self, name: str) -> str: - normalized = _normalize_name(name) - if not normalized: - raise ChartDataError("Board name is required") - catalog = self._load_board_catalog() - item = catalog.get(normalized) - if not item: - raise ChartDataError("No matching chart board") - return item["code"] - - def _load_board_catalog(self) -> dict[str, dict[str, str]]: - now = time.time() - with self._board_catalog_lock: - if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60: - return dict(self._board_catalog) - - rows: list[dict[str, Any]] = [] - for board_type in ("1", "2", "3"): - for page in range(1, 6): - payload = self._request_json( - BOARD_LIST_URL, - { - "pn": str(page), - "pz": "100", - "po": "1", - "np": "1", - "fltt": "2", - "invt": "2", - "fid": "f3", - "fs": f"m:90+t:{board_type}", - "fields": "f12,f14", - }, - "https://quote.eastmoney.com/center/boardlist.html", - ) - page_rows = (payload.get("data") or {}).get("diff") or [] - rows.extend(page_rows) - if len(page_rows) < 100: - break - - catalog: dict[str, dict[str, str]] = {} - for row in rows: - code = str(row.get("f12") or "").strip().upper() - board_name = str(row.get("f14") or "").strip() - if re.fullmatch(r"BK\d{4}", code) and board_name: - catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name}) - if not catalog: - raise ChartDataError("Board chart directory is unavailable") - with self._board_catalog_lock: - type(self)._board_catalog = catalog - type(self)._board_catalog_at = now - return dict(catalog) - - def _request_json( - self, url: str, params: dict[str, str], referer: str - ) -> dict[str, Any]: - request_url = f"{url}?{urllib.parse.urlencode(params)}" - last_error: Exception | None = None - for attempt in range(max(1, int(self.retry_attempts))): - request = urllib.request.Request( - request_url, - headers={ - "Accept": "application/json,text/plain,*/*", - "Connection": "close", - "Referer": referer, - "User-Agent": BROWSER_USER_AGENT, - }, - ) - try: - with urllib.request.urlopen(request, timeout=self.timeout) as response: - payload = json.loads(response.read().decode("utf-8")) - if not isinstance(payload, dict): - raise ChartDataError("Invalid intraday chart response") - return payload - except ( - urllib.error.URLError, - TimeoutError, - ConnectionError, - OSError, - http.client.HTTPException, - json.JSONDecodeError, - ChartDataError, - ) as exc: - last_error = exc - if attempt + 1 < self.retry_attempts: - time.sleep(0.12) - raise ChartDataError("Intraday chart request failed") from last_error - - -def _parse_trend(raw: Any) -> dict[str, Any] | None: - fields = str(raw or "").split(",") - if len(fields) < 8 or " " not in fields[0]: - return None - stamp = fields[0].strip() - trade_date, trade_time = stamp.split(" ", 1) - close = _number(fields[2]) - if close <= 0: - return None - return { - "date": trade_date, - "time": trade_time[:5], - "open": _number(fields[1]), - "close": close, - "high": _number(fields[3]), - "low": _number(fields[4]), - "volume": _number(fields[5]), - "amount": _number(fields[6]), - "average": _number(fields[7]), - } - - -def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None: - stamp = str(row.get("time") or "").strip() - if " " not in stamp: - return None - trade_date, trade_time = stamp.split(" ", 1) - close = _number(row.get("close")) - if close <= 0: - return None - return { - "date": trade_date, - "time": trade_time[:5], - "open": _number(row.get("open")), - "close": close, - "high": _number(row.get("high")), - "low": _number(row.get("low")), - "volume": _number(row.get("volume")), - "amount": _number(row.get("amount")), - "average": _number(row.get("avgPrice")), - } - - -def _stock_market_code(code: str) -> str: - if code.startswith(("4", "8", "9")): - suffix = "BJ" - elif code.startswith("6"): - suffix = "SH" - else: - suffix = "SZ" - return f"{code}.{suffix}" - - -def _number(value: Any) -> float: - try: - return float(value or 0) - except (TypeError, ValueError): - return 0.0 - - -def _normalize_name(value: Any) -> str: - normalized = re.sub(r"[\s·・()()\-_/]", "", str(value or "")).casefold() - return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized) +sys.modules[__name__] = _implementation diff --git a/app/database.py b/app/database.py index 9f23746..9a3b60b 100644 --- a/app/database.py +++ b/app/database.py @@ -8,6 +8,7 @@ from typing import Any from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory from backend.features.accounts.repository import AccountRepositoryMixin +from backend.features.market.repository import MarketRepositoryMixin from backend.features.system.repository import SystemSettingsRepositoryMixin @@ -20,7 +21,11 @@ def _optional_float(value: Any) -> float | None: return None -class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin): +class ReviewDatabase( + AccountRepositoryMixin, + MarketRepositoryMixin, + SystemSettingsRepositoryMixin, +): def __init__(self, path: Path) -> None: self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) @@ -690,118 +695,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin): ).fetchone() return int(row["total"] if row else 0) - def get_snapshot(self, trade_date: str) -> dict[str, Any] | None: - with self.connect() as connection: - row = connection.execute( - "SELECT payload FROM dashboard_snapshots WHERE trade_date = ?", - (trade_date,), - ).fetchone() - if not row: - return None - try: - return json.loads(row["payload"]) - except json.JSONDecodeError: - return None - - def get_latest_real_snapshot( - self, trade_date: str, strictly_before: bool = False - ) -> dict[str, Any] | None: - operator = "<" if strictly_before else "<=" - with self.connect() as connection: - row = connection.execute( - f""" - SELECT payload FROM dashboard_snapshots - WHERE trade_date {operator} ? AND source != 'demo' - ORDER BY trade_date DESC LIMIT 1 - """, - (trade_date,), - ).fetchone() - if not row: - return None - try: - return json.loads(row["payload"]) - except json.JSONDecodeError: - return None - - def save_snapshot(self, trade_date: str, source: str, payload: dict[str, Any]) -> None: - updated_at = datetime.now().astimezone().isoformat(timespec="seconds") - record_count = sum( - len(payload.get(key) or []) - for key in ("limits", "broken", "down_limits", "yesterday_limits") - ) - content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - with self.connect() as connection: - connection.execute( - """ - INSERT INTO dashboard_snapshots - (trade_date, source, payload, record_count, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(trade_date) DO UPDATE SET - source = excluded.source, - payload = excluded.payload, - record_count = excluded.record_count, - updated_at = excluded.updated_at - """, - (trade_date, source, content, record_count, updated_at), - ) - - def get_data_snapshot(self, kind: str, cache_key: str) -> dict[str, Any] | None: - with self.connect() as connection: - row = connection.execute( - "SELECT payload FROM data_snapshots WHERE kind = ? AND cache_key = ?", - (kind, cache_key), - ).fetchone() - if not row: - return None - try: - return json.loads(row["payload"]) - except json.JSONDecodeError: - return None - - def get_latest_data_snapshot( - self, - kind: str, - cache_key_prefix: str, - maximum_cache_key: str, - exclude_source: str = "", - ) -> dict[str, Any] | None: - source_clause = " AND source != ?" if exclude_source else "" - parameters: list[Any] = [kind, f"{cache_key_prefix}%", maximum_cache_key] - if exclude_source: - parameters.append(exclude_source) - with self.connect() as connection: - row = connection.execute( - f""" - SELECT payload FROM data_snapshots - WHERE kind = ? AND cache_key LIKE ? AND cache_key <= ?{source_clause} - ORDER BY cache_key DESC LIMIT 1 - """, - parameters, - ).fetchone() - if not row: - return None - try: - return json.loads(row["payload"]) - except json.JSONDecodeError: - return None - - def save_data_snapshot( - self, kind: str, cache_key: str, source: str, payload: dict[str, Any] - ) -> None: - updated_at = datetime.now().astimezone().isoformat(timespec="seconds") - content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - with self.connect() as connection: - connection.execute( - """ - INSERT INTO data_snapshots (kind, cache_key, source, payload, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(kind, cache_key) DO UPDATE SET - source = excluded.source, - payload = excluded.payload, - updated_at = excluded.updated_at - """, - (kind, cache_key, source, content, updated_at), - ) def list_watchlist(self, user_id: int) -> list[dict[str, Any]]: with self.connect() as connection: @@ -869,7 +762,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin): (int(user_id), code), ) return cursor.rowcount > 0 - def list_notes( self, user_id: int, @@ -1042,26 +934,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin): ) return len(values) - def search_stock_master(self, query: str, limit: int = 12) -> list[dict[str, Any]]: - text = str(query or "").strip() - if not text: - return [] - escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - with self.connect() as connection: - rows = connection.execute( - """ - SELECT ts_code, code, name, industry, market, list_date - FROM stock_master - WHERE code = ? OR name = ? OR name LIKE ? ESCAPE '\\' - ORDER BY - CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 ELSE 2 END, - list_date DESC, - code - LIMIT ? - """, - (text, text, f"%{escaped}%", text, text, max(1, min(30, int(limit)))), - ).fetchall() - return [dict(row) for row in rows] def list_stock_master(self) -> list[dict[str, Any]]: with self.connect() as connection: @@ -1666,24 +1538,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin): for row in series[-limit:] ] - def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - """ - SELECT trade_date, payload FROM dashboard_snapshots - WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT ? - """, - (end_date, limit), - ).fetchall() - result: list[dict[str, Any]] = [] - for row in reversed(rows): - try: - payload = json.loads(row["payload"]) - except json.JSONDecodeError: - continue - payload["_snapshot_date"] = row["trade_date"] - result.append(payload) - return result def save_screener_strategy( self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any], @@ -2529,64 +2383,3 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin): (int(reading_id), int(user_id)), ) return cursor.rowcount > 0 - - def start_sync(self, trade_date: str, source: str) -> int: - started_at = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - cursor = connection.execute( - """ - INSERT INTO sync_runs (trade_date, source, status, started_at) - VALUES (?, ?, 'running', ?) - """, - (trade_date, source, started_at), - ) - return int(cursor.lastrowid) - - def finish_sync( - self, - sync_id: int, - status: str, - record_count: int = 0, - message: str = "", - source: str | None = None, - ) -> None: - finished_at = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute( - """ - UPDATE sync_runs - SET status = ?, finished_at = ?, record_count = ?, message = ?, - source = COALESCE(?, source) - WHERE id = ? - """, - (status, finished_at, record_count, message[:1000], source, sync_id), - ) - - def status(self) -> dict[str, Any]: - with self.connect() as connection: - last_sync = connection.execute( - """ - SELECT id, trade_date, source, status, started_at, finished_at, - record_count, message - FROM sync_runs ORDER BY id DESC LIMIT 1 - """ - ).fetchone() - snapshot_stats = connection.execute( - """ - SELECT COUNT(*) AS dates, COALESCE(SUM(record_count), 0) AS records, - MAX(updated_at) AS updated_at - FROM dashboard_snapshots - """ - ).fetchone() - watchlist_count = connection.execute("SELECT COUNT(*) FROM watchlist").fetchone()[0] - note_count = connection.execute("SELECT COUNT(*) FROM review_notes").fetchone()[0] - - return { - "database": str(self.path.name), - "snapshot_dates": int(snapshot_stats["dates"]), - "snapshot_records": int(snapshot_stats["records"]), - "updated_at": snapshot_stats["updated_at"], - "last_sync": dict(last_sync) if last_sync else None, - "watchlist_count": int(watchlist_count), - "note_count": int(note_count), - } diff --git a/app/ifind_client.py b/app/ifind_client.py index 2b30fd9..e6cb99e 100644 --- a/app/ifind_client.py +++ b/app/ifind_client.py @@ -1,385 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical iFinD provider implementation.""" -import copy -import json -import threading -import time -import urllib.error -import urllib.request -from datetime import datetime, timedelta -from typing import Any +import sys +from backend.data.providers import ifind_client as _implementation -class IfindError(RuntimeError): - pass - - -class IfindHttpClient: - BASE_URL = "https://quantapi.51ifind.com/api/v1" - AUTH_ENDPOINT = "get_access_token" - AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303} - - def __init__( - self, - refresh_token: str = "", - access_token: str = "", - timeout: int = 15, - ) -> None: - self.timeout = max(3, int(timeout)) - self._refresh_token = str(refresh_token or "").strip() - self._access_token = str(access_token or "").strip() - self._access_expires_at: datetime | None = None - self._token_lock = threading.Lock() - self._cache_lock = threading.Lock() - self._cache: dict[str, dict[str, Any]] = {} - - @property - def configured(self) -> bool: - return bool(self._refresh_token or self._access_token) - - def set_credentials(self, refresh_token: str, access_token: str = "") -> None: - refresh_token = str(refresh_token or "").strip() - access_token = str(access_token or "").strip() - with self._token_lock: - refresh_changed = refresh_token != self._refresh_token - self._refresh_token = refresh_token - if access_token or refresh_changed: - self._access_token = access_token - self._access_expires_at = None - if refresh_changed: - with self._cache_lock: - self._cache.clear() - - def status(self) -> dict[str, Any]: - return { - "configured": self.configured, - "access_ready": bool(self._access_token), - "access_expires_at": ( - self._access_expires_at.isoformat(timespec="seconds") - if self._access_expires_at - else "" - ), - } - - def test_connection(self) -> dict[str, Any]: - payload = self.real_time( - "000001.SH", - ["open", "high", "low", "latest", "preClose"], - cache_ttl=0, - ) - return { - "ok": bool(payload), - "sample_time": str(payload[0].get("time") or "") if payload else "", - } - - def real_time( - self, - codes: str | list[str], - indicators: list[str], - cache_ttl: int = 10, - ) -> list[dict[str, Any]]: - code_text = self._codes(codes) - payload = self._request( - "real_time_quotation", - {"codes": code_text, "indicators": ",".join(indicators)}, - cache_key=f"rq:{code_text}:{','.join(indicators)}", - cache_ttl=cache_ttl, - ) - return self._table_rows(payload) - - def history( - self, - codes: str | list[str], - indicators: list[str], - start_date: str, - end_date: str, - cache_ttl: int = 300, - ) -> list[dict[str, Any]]: - code_text = self._codes(codes) - payload = self._request( - "cmd_history_quotation", - { - "codes": code_text, - "indicators": ",".join(indicators), - "startdate": self._display_date(start_date), - "enddate": self._display_date(end_date), - "functionpara": {"CPS": "forward1", "Fill": "Omit"}, - }, - cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}", - cache_ttl=cache_ttl, - ) - return self._table_rows(payload) - - def intraday( - self, - code: str, - start_time: str, - end_time: str, - cache_ttl: int = 20, - ) -> list[dict[str, Any]]: - indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"] - payload = self._request( - "high_frequency", - { - "codes": self._codes(code), - "indicators": ",".join(indicators), - "starttime": start_time, - "endtime": end_time, - "functionpara": { - "CPS": "forward1", - "Fill": "Previous", - "Timeformat": "LocalTime", - "Interval": "1", - "Limitstart": "09:30:00", - "Limitend": "15:00:00", - }, - }, - cache_key=f"hf:{code}:{start_time}:{end_time}", - cache_ttl=cache_ttl, - ) - return self._table_rows(payload) - - def snapshots( - self, - codes: str | list[str], - indicators: list[str], - start_time: str, - end_time: str, - cache_ttl: int = 8, - ) -> list[dict[str, Any]]: - code_text = self._codes(codes) - payload = self._request( - "snap_shot", - { - "codes": code_text, - "indicators": ",".join(indicators), - "starttime": start_time, - "endtime": end_time, - }, - cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}", - cache_ttl=cache_ttl, - ) - return self._table_rows(payload) - - def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]: - normalized = " ".join(str(query or "").split()) - if not normalized: - raise IfindError("问财查询不能为空。") - payload = self._request( - "smart_stock_picking", - {"searchstring": normalized, "searchtype": search_type}, - cache_key=f"wc:{search_type}:{normalized}", - cache_ttl=cache_ttl, - ) - return self._table_rows(payload) - - def report_query( - self, - codes: str | list[str], - begin_date: str, - end_date: str, - cache_ttl: int = 300, - ) -> list[dict[str, Any]]: - code_text = self._codes(codes) - payload = self._request( - "report_query", - { - "codes": code_text, - "beginrDate": self._display_date(begin_date), - "endrDate": self._display_date(end_date), - "outputpara": ( - "reportDate:Y,thscode:Y,secName:Y,ctime:Y," - "reportTitle:Y,pdfURL:Y,seq:Y" - ), - }, - cache_key=f"report:{code_text}:{begin_date}:{end_date}", - cache_ttl=cache_ttl, - ) - return self._table_rows(payload) - - def _request( - self, - endpoint: str, - body: dict[str, Any], - cache_key: str = "", - cache_ttl: int = 0, - ) -> dict[str, Any]: - if not self.configured: - raise IfindError("iFinD 尚未配置。") - if cache_key and cache_ttl > 0: - cached = self._cached(cache_key, cache_ttl) - if cached is not None: - return cached - - payload = self._post(endpoint, body, self._ensure_access_token()) - if self._is_auth_error(payload) and self._refresh_token: - self._invalidate_access_token() - payload = self._post(endpoint, body, self._ensure_access_token(force=True)) - self._validate_payload(payload) - if cache_key and cache_ttl > 0: - with self._cache_lock: - self._cache[cache_key] = { - "created_at": time.time(), - "payload": copy.deepcopy(payload), - } - return payload - - def _ensure_access_token(self, force: bool = False) -> str: - with self._token_lock: - now = datetime.now().astimezone().replace(tzinfo=None) - token_valid = bool(self._access_token) and ( - self._access_expires_at is None - or self._access_expires_at > now + timedelta(minutes=2) - ) - if token_valid and not force: - return self._access_token - if not self._refresh_token: - if self._access_token: - return self._access_token - raise IfindError("iFinD Refresh Token 尚未配置。") - payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token) - self._validate_payload(payload) - data = payload.get("data") or {} - token = str(data.get("access_token") or "").strip() - if not token: - raise IfindError("iFinD 未返回 Access Token。") - expires_at = self._parse_datetime(data.get("expired_time")) - self._access_token = token - self._access_expires_at = expires_at - return token - - def _post( - self, - endpoint: str, - body: dict[str, Any], - access_token: str, - refresh_token: str = "", - ) -> dict[str, Any]: - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": "XiaobaiReviewWeb/1.0", - "ifindlang": "cn", - } - if access_token: - headers["access_token"] = access_token - if refresh_token: - headers["refresh_token"] = refresh_token - request = urllib.request.Request( - f"{self.BASE_URL}/{endpoint}", - data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), - headers=headers, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=self.timeout) as response: - payload = json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as exc: - detail = "" - try: - detail_payload = json.loads(exc.read().decode("utf-8", errors="replace")) - detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "") - except (json.JSONDecodeError, OSError): - pass - raise IfindError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc - except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: - raise IfindError("iFinD 数据请求失败。") from exc - if not isinstance(payload, dict): - raise IfindError("iFinD 返回格式不正确。") - return payload - - def _cached(self, key: str, ttl: int) -> dict[str, Any] | None: - with self._cache_lock: - cached = self._cache.get(key) - if not cached: - return None - if time.time() - float(cached.get("created_at") or 0) > ttl: - self._cache.pop(key, None) - return None - return copy.deepcopy(cached["payload"]) - - def _invalidate_access_token(self) -> None: - with self._token_lock: - self._access_token = "" - self._access_expires_at = None - - @classmethod - def _validate_payload(cls, payload: dict[str, Any]) -> None: - try: - error_code = int(payload.get("errorcode") or 0) - except (TypeError, ValueError): - error_code = -1 - if error_code != 0: - message = str(payload.get("errmsg") or "未知错误") - raise IfindError(f"iFinD 返回错误:{message[:200]}") - - @classmethod - def _is_auth_error(cls, payload: dict[str, Any]) -> bool: - try: - error_code = int(payload.get("errorcode") or 0) - except (TypeError, ValueError): - error_code = 0 - message = str(payload.get("errmsg") or "").casefold() - return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message - - @staticmethod - def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: - tables = payload.get("tables") or [] - if isinstance(tables, dict): - tables = [tables] - rows: list[dict[str, Any]] = [] - for block in tables if isinstance(tables, list) else []: - if not isinstance(block, dict): - continue - table = block.get("table") or {} - if not isinstance(table, dict): - continue - times = block.get("time") or [] - codes = block.get("thscode") or block.get("thscodes") or [] - if isinstance(codes, str): - codes = [codes] - lengths = [len(value) for value in table.values() if isinstance(value, list)] - row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0]) - for index in range(row_count): - row: dict[str, Any] = {} - if isinstance(times, list) and index < len(times): - row["time"] = times[index] - if codes: - row["thscode"] = codes[index] if index < len(codes) else codes[0] - for field, values in table.items(): - if isinstance(values, list): - row[field] = values[index] if index < len(values) else None - elif index == 0: - row[field] = values - rows.append(row) - return rows - - @staticmethod - def _codes(codes: str | list[str]) -> str: - if isinstance(codes, list): - values = [str(code or "").strip().upper() for code in codes] - else: - values = [part.strip().upper() for part in str(codes or "").split(",")] - values = [value for value in values if value] - if not values: - raise IfindError("iFinD 证券代码不能为空。") - if len(values) > 100: - raise IfindError("iFinD 单次证券代码过多。") - return ",".join(values) - - @staticmethod - def _display_date(value: str) -> str: - compact = str(value or "").replace("-", "") - if len(compact) != 8 or not compact.isdigit(): - raise IfindError("iFinD 日期格式不正确。") - return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}" - - @staticmethod - def _parse_datetime(value: Any) -> datetime | None: - text = str(value or "").strip() - if not text: - return None - try: - return datetime.fromisoformat(text) - except ValueError: - return None +sys.modules[__name__] = _implementation diff --git a/app/realtime_aggregator.py b/app/realtime_aggregator.py index d566df2..a0d7ba6 100644 --- a/app/realtime_aggregator.py +++ b/app/realtime_aggregator.py @@ -1,426 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical display-only realtime observer.""" -import copy -import http.client -import json -import time -import urllib.error -import urllib.parse -import urllib.request -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from datetime import datetime -from threading import Lock -from typing import Any, ClassVar +import sys +from backend.data import realtime as _implementation -class RealtimeAggregateError(RuntimeError): - pass - - -EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get" -EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get" -TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006" -THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool" -XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail" -BROWSER_USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/138.0.0.0 Safari/537.36" -) - - -@dataclass -class WebRealtimeAggregator: - timeout: int = 8 - retry_attempts: int = 3 - retry_delay_seconds: float = 0.2 - response_cache_ttl_seconds: int = 90 - _sector_cache: ClassVar[dict[str, Any]] = {} - _sector_cache_lock: ClassVar[Lock] = Lock() - _response_cache: ClassVar[dict[str, dict[str, Any]]] = {} - _response_cache_lock: ClassVar[Lock] = Lock() - - def health_snapshot(self, sector: str = "") -> dict[str, Any]: - started = time.perf_counter() - sources: dict[str, dict[str, Any]] = {} - indices: list[dict[str, Any]] = [] - sector_payload: dict[str, Any] | None = None - - indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices) - if sector.strip(): - sector_payload, sources["eastmoney_sector"] = self._capture( - lambda: self.eastmoney_sector(sector) - ) - ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool) - xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool) - - index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []] - now = datetime.now().astimezone() - max_skew = 120 if now.hour >= 15 else 15 - index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew - ready = ( - bool(indices) - and len(indices) == 3 - and index_consistent - and (not sector.strip() or bool(sector_payload)) - ) - return { - "ready": ready, - "isolated": True, - "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "elapsed_ms": round((time.perf_counter() - started) * 1000), - "indices": indices or [], - "index_consistent": index_consistent, - "sector": sector_payload, - "sources": sources, - "observations": { - "ths_limit_pool": ths_observation, - "xgb_limit_pool": xgb_observation, - }, - "policy": { - "integration": "heaven_realtime_fallback", - "max_index_time_skew_seconds": max_skew, - "notice": "聚合源仅作为盘中观势的实时指数与板块外显,主行情快照仍由Tushare维护。", - }, - } - - def eastmoney_indices(self) -> list[dict[str, Any]]: - try: - payload = self._get_json( - EASTMONEY_INDEX_URL, - { - "secids": "1.000001,0.399001,0.399006", - "fltt": "2", - "invt": "2", - "fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124", - }, - referer="https://quote.eastmoney.com/", - ) - except RealtimeAggregateError: - return self.tencent_indices() - cache_meta = payload.get("_aggregate_cache") or {} - rows = list((payload.get("data") or {}).get("diff") or []) - result = [] - for row in rows: - code = str(row.get("f12") or "") - if code not in {"000001", "399001", "399006"}: - continue - epoch = int(_number(row.get("f124"))) - result.append( - { - "code": code, - "name": row.get("f14") or code, - "price": _number(row.get("f2")), - "change": _number(row.get("f3")), - "change_amount": _number(row.get("f4")), - "open": _number(row.get("f17")), - "high": _number(row.get("f15")), - "low": _number(row.get("f16")), - "previous_close": _number(row.get("f18")), - "amount_billion": round(_number(row.get("f6")) / 100000000, 2), - "quote_time_epoch": epoch, - "quote_time": ( - datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds") - if epoch else "" - ), - "source": ( - "eastmoney_push2_cache" if cache_meta else "eastmoney_push2" - ), - "cache_age_seconds": cache_meta.get("age_seconds", 0), - } - ) - if len(result) != 3: - raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices") - return result - - def tencent_indices(self) -> list[dict[str, Any]]: - raw, cache_age = self._get_text( - TENCENT_INDEX_URL, - referer="https://gu.qq.com/", - encoding="gb18030", - ) - result = [] - for line in raw.splitlines(): - if '="' not in line: - continue - fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~") - if len(fields) < 38: - continue - code = fields[2] - if code not in {"000001", "399001", "399006"}: - continue - try: - quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone() - except ValueError as exc: - raise RealtimeAggregateError( - f"Tencent returned invalid quote time for {code}" - ) from exc - result.append( - { - "code": code, - "name": fields[1] or code, - "price": _number(fields[3]), - "change": _number(fields[32]), - "change_amount": _number(fields[31]), - "open": _number(fields[5]), - "high": _number(fields[33]), - "low": _number(fields[34]), - "previous_close": _number(fields[4]), - "amount_billion": round(_number(fields[37]) / 10000, 2), - "quote_time_epoch": int(quote_time.timestamp()), - "quote_time": quote_time.isoformat(timespec="seconds"), - "source": "tencent_qt_cache" if cache_age else "tencent_qt", - "cache_age_seconds": cache_age, - } - ) - if len(result) != 3: - raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices") - return result - - def eastmoney_sector(self, query: str) -> dict[str, Any]: - target = _normalize_sector(query) - candidates = self._eastmoney_sector_catalog() - matched = _match_sector(candidates, target) - if not matched: - raise RealtimeAggregateError(f"Eastmoney sector not found: {query}") - epoch = int(_number(matched.get("f124"))) - return { - "code": matched.get("f12") or "", - "name": matched.get("f14") or query, - "price": _number(matched.get("f2")), - "change": _number(matched.get("f3")), - "change_amount": _number(matched.get("f4")), - "turnover_rate": _number(matched.get("f8")), - "up_count": int(_number(matched.get("f104"))), - "down_count": int(_number(matched.get("f105"))), - "leader": matched.get("f128") or "--", - "leader_code": matched.get("f140") or "", - "leading_pct": _number(matched.get("f136")), - "quote_time_epoch": epoch, - "quote_time": ( - datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds") - if epoch else "" - ), - "source": "eastmoney_push2", - "match_query": query, - } - - def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]: - now = time.time() - with self._sector_cache_lock: - cached = self._sector_cache.get("eastmoney") - if cached and now - float(cached.get("created_at") or 0) < 600: - return list(cached.get("rows") or []) - - def load_page(page: int) -> list[dict[str, Any]]: - payload = self._get_json( - EASTMONEY_SECTOR_URL, - { - "pn": str(page), - "pz": "100", - "po": "1", - "np": "1", - "fltt": "2", - "invt": "2", - "fid": "f3", - "fs": "m:90+t:2", - "fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124", - }, - referer="https://quote.eastmoney.com/center/boardlist.html", - ) - return list((payload.get("data") or {}).get("diff") or []) - - with ThreadPoolExecutor(max_workers=5) as executor: - pages = list(executor.map(load_page, range(1, 6))) - rows = [row for page in pages for row in page] - if not rows: - raise RealtimeAggregateError("Eastmoney sector catalog is empty") - with self._sector_cache_lock: - self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows} - return rows - - def ths_limit_pool(self) -> dict[str, Any]: - payload = self._get_json( - THS_LIMIT_URL, - {"page": "1", "limit": "3", "field": "199112"}, - referer="https://data.10jqka.com.cn/limit_up/", - ) - data = payload.get("data") or payload - return { - "available": True, - "keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [], - "source": "ths_web_dataapi", - } - - def xgb_limit_pool(self) -> dict[str, Any]: - payload = self._get_json( - XGB_POOL_URL, - {"pool_name": "limit_up"}, - referer="https://xuangubao.cn/", - ) - data = payload.get("data") or {} - rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or [] - return { - "available": True, - "count": len(rows) if isinstance(rows, list) else 0, - "source": "xuangubao_web_api", - } - - def _capture(self, operation): - started = time.perf_counter() - try: - value = operation() - return value, { - "ok": True, - "elapsed_ms": round((time.perf_counter() - started) * 1000), - "error": "", - } - except Exception as exc: - return None, { - "ok": False, - "elapsed_ms": round((time.perf_counter() - started) * 1000), - "error": str(exc)[:500], - } - - def _get_json( - self, - url: str, - params: dict[str, str], - referer: str, - ) -> dict[str, Any]: - request_url = f"{url}?{urllib.parse.urlencode(params)}" - last_error: Exception | None = None - attempts = max(1, int(self.retry_attempts)) - for attempt in range(attempts): - request = urllib.request.Request( - request_url, - headers={ - "Accept": "application/json,text/plain,*/*", - "Connection": "close", - "Referer": referer, - "User-Agent": BROWSER_USER_AGENT, - }, - ) - try: - with urllib.request.urlopen(request, timeout=self.timeout) as response: - content_type = response.headers.get("Content-Type", "") - raw = response.read().decode("utf-8", errors="replace") - if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")): - raise RealtimeAggregateError( - f"non-JSON response: {raw[:120].strip()}" - ) - payload = json.loads(raw) - if not isinstance(payload, dict): - raise RealtimeAggregateError("unexpected response shape") - if payload.get("rc") not in (None, 0): - raise RealtimeAggregateError(f"provider rc={payload.get('rc')}") - with self._response_cache_lock: - self._response_cache[request_url] = { - "created_at": time.time(), - "payload": copy.deepcopy(payload), - } - return payload - except ( - urllib.error.URLError, - TimeoutError, - ConnectionError, - OSError, - http.client.HTTPException, - json.JSONDecodeError, - RealtimeAggregateError, - ) as exc: - last_error = exc - if attempt + 1 < attempts and self.retry_delay_seconds > 0: - time.sleep(self.retry_delay_seconds * (attempt + 1)) - - now = time.time() - with self._response_cache_lock: - cached = self._response_cache.get(request_url) - cache_age = now - float((cached or {}).get("created_at") or 0) - if cached and cache_age <= self.response_cache_ttl_seconds: - payload = copy.deepcopy(cached.get("payload") or {}) - payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)} - return payload - raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error - - def _get_text( - self, - request_url: str, - referer: str, - encoding: str = "utf-8", - ) -> tuple[str, float]: - cache_key = f"text:{request_url}" - last_error: Exception | None = None - attempts = max(1, int(self.retry_attempts)) - for attempt in range(attempts): - request = urllib.request.Request( - request_url, - headers={ - "Accept": "text/plain,*/*", - "Connection": "close", - "Referer": referer, - "User-Agent": BROWSER_USER_AGENT, - }, - ) - try: - with urllib.request.urlopen(request, timeout=self.timeout) as response: - raw = response.read().decode(encoding, errors="replace") - if not raw.strip(): - raise RealtimeAggregateError("empty text response") - with self._response_cache_lock: - self._response_cache[cache_key] = { - "created_at": time.time(), - "payload": raw, - } - return raw, 0 - except ( - urllib.error.URLError, - TimeoutError, - ConnectionError, - OSError, - http.client.HTTPException, - RealtimeAggregateError, - ) as exc: - last_error = exc - if attempt + 1 < attempts and self.retry_delay_seconds > 0: - time.sleep(self.retry_delay_seconds * (attempt + 1)) - - now = time.time() - with self._response_cache_lock: - cached = self._response_cache.get(cache_key) - cache_age = now - float((cached or {}).get("created_at") or 0) - if cached and cache_age <= self.response_cache_ttl_seconds: - return str(cached.get("payload") or ""), round(cache_age, 1) - raise RealtimeAggregateError( - f"text request failed after {attempts} attempts: {last_error}" - ) from last_error - - -def _normalize_sector(value: Any) -> str: - text = str(value or "").strip().replace(" ", "") - for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"): - text = text.replace(suffix, "") - aliases = {"元器件": "元件", "电子元器件": "元件"} - return aliases.get(text, text) - - -def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None: - exact = [row for row in rows if _normalize_sector(row.get("f14")) == target] - if exact: - return min(exact, key=lambda row: len(str(row.get("f14") or ""))) - fuzzy = [ - row for row in rows - if target and ( - target in _normalize_sector(row.get("f14")) - or _normalize_sector(row.get("f14")) in target - ) - ] - return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None - - -def _number(value: Any, default: float = 0.0) -> float: - try: - return float(value) - except (TypeError, ValueError): - return default +sys.modules[__name__] = _implementation diff --git a/app/tests/test_data_gateway.py b/app/tests/test_data_gateway.py index a3058f4..32cfe04 100644 --- a/app/tests/test_data_gateway.py +++ b/app/tests/test_data_gateway.py @@ -48,7 +48,11 @@ class DataGatewayTests(unittest.TestCase): from pathlib import Path source = ( - Path(__file__).resolve().parents[1] / "backend" / "application.py" + Path(__file__).resolve().parents[1] + / "backend" + / "features" + / "market" + / "service.py" ).read_text(encoding="utf-8") self.assertEqual(source.count("TushareClient(self.token)"), 1) self.assertIn("return gateway.tushare()", source) diff --git a/app/tests/test_preservation_slice_market.py b/app/tests/test_preservation_slice_market.py new file mode 100644 index 0000000..e0bbb29 --- /dev/null +++ b/app/tests/test_preservation_slice_market.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import ast +import hashlib +import unittest +from pathlib import Path + +import chart_data_provider +import ifind_client +import realtime_aggregator +import tushare_client +from backend.data import realtime +from backend.data.providers import ifind_client as canonical_ifind +from backend.data.providers import tushare_client as canonical_tushare +from backend.features.market import charts + + +APP_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_ROOT = APP_ROOT.parent + +MARKET_METHODS = { + "_tushare_client", + "get_dashboard", + "_dashboard_sentiment_ready", + "_display_compact_date", + "_carry_dashboard", + "_realtime_snapshot_due", + "sync_dashboard", + "realtime_aggregate_health", + "_search_market_directory", + "_search_match_score", + "search_entities", + "get_search_detail", + "get_intraday_chart", + "_ths_search_detail", + "_index_search_detail", + "get_stock_detail", + "_stock_detail_bar_date", + "_stock_detail_cache_needs_refresh", + "_prepare_stock_detail", + "_sanitize_stock_detail_prices", + "_valid_realtime_stock_quote", + "_ifind_realtime_stock_quote", + "_merge_realtime_stock_detail", + "get_stock_preview", + "backfill", + "_stock_identity", + "_enrich_stock_detail", + "_with_storage", + "_record_count", +} + +MARKET_REPOSITORY_METHODS = { + "get_snapshot", + "get_latest_real_snapshot", + "save_snapshot", + "get_data_snapshot", + "get_latest_data_snapshot", + "save_data_snapshot", + "search_stock_master", + "list_snapshot_payloads", + "start_sync", + "finish_sync", + "status", +} + + +def class_methods(path: Path, class_name: str) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + owner = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == class_name + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in owner.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def top_level_definitions(path: Path) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + return { + node.name: ast.dump(node, include_attributes=False) + for node in tree.body + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +class MarketSliceSourceEquivalenceTests(unittest.TestCase): + def test_market_service_methods_are_exact_original_ast(self) -> None: + original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService") + migrated = class_methods( + APP_ROOT / "backend" / "features" / "market" / "service.py", + "MarketServiceMixin", + ) + self.assertEqual(set(migrated), MARKET_METHODS) + for name in sorted(MARKET_METHODS): + self.assertEqual(migrated[name], original[name], name) + + def test_market_repository_methods_are_exact_original_ast(self) -> None: + original = class_methods(ORIGINAL_ROOT / "database.py", "ReviewDatabase") + migrated = class_methods( + APP_ROOT / "backend" / "features" / "market" / "repository.py", + "MarketRepositoryMixin", + ) + self.assertEqual(set(migrated), MARKET_REPOSITORY_METHODS) + for name in sorted(MARKET_REPOSITORY_METHODS): + self.assertEqual(migrated[name], original[name], name) + + def test_original_classes_no_longer_duplicate_moved_methods(self) -> None: + remaining_service = class_methods(APP_ROOT / "backend" / "application.py", "DashboardService") + remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase") + self.assertTrue(MARKET_METHODS.isdisjoint(remaining_service)) + self.assertTrue(MARKET_REPOSITORY_METHODS.isdisjoint(remaining_database)) + + def test_provider_compatibility_modules_are_canonical_aliases(self) -> None: + self.assertIs(tushare_client.TushareClient, canonical_tushare.TushareClient) + self.assertIs(ifind_client.IfindHttpClient, canonical_ifind.IfindHttpClient) + self.assertIs(realtime_aggregator.WebRealtimeAggregator, realtime.WebRealtimeAggregator) + self.assertIs(chart_data_provider.MarketChartClient, charts.MarketChartClient) + + def test_provider_logic_is_the_original_implementation(self) -> None: + exact_moves = ( + ("tushare_client.py", "backend/data/providers/tushare_client.py"), + ("ifind_client.py", "backend/data/providers/ifind_client.py"), + ("realtime_aggregator.py", "backend/data/realtime.py"), + ) + for original, migrated in exact_moves: + self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated)) + self.assertEqual( + top_level_definitions(ORIGINAL_ROOT / "chart_data_provider.py"), + top_level_definitions(APP_ROOT / "backend/features/market/charts.py"), + ) + + def test_unchanged_frontend_assets_match_the_original(self) -> None: + for relative in ( + "index.html", + "app.js", + "styles.css", + "renovation.css", + "redesign-v2.css", + "theme.css", + "wentian-v2.css", + ): + self.assertEqual( + sha256(APP_ROOT / "static" / relative), + sha256(ORIGINAL_ROOT / "static" / relative), + relative, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_stock_detail_realtime.py b/app/tests/test_stock_detail_realtime.py index c6760b6..b33743a 100644 --- a/app/tests/test_stock_detail_realtime.py +++ b/app/tests/test_stock_detail_realtime.py @@ -90,8 +90,8 @@ class StockDetailRealtimeTests(unittest.TestCase): "moneyflow": {}, } - with patch("backend.application.datetime", FixedMarketDatetime), patch( - "backend.application.TushareClient", RealtimeClientStub + with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( + "backend.features.market.service.TushareClient", RealtimeClientStub ): result = self.service._prepare_stock_detail(cached, "002141", today) @@ -112,8 +112,8 @@ class StockDetailRealtimeTests(unittest.TestCase): "stock": {"code": "002141", "price": 10, "change": 1.2}, "prices": [{"trade_date": historical, "close": 10, "change": 1.2}], } - with patch("backend.application.datetime", FixedMarketDatetime), patch( - "backend.application.TushareClient", RealtimeClientStub + with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( + "backend.features.market.service.TushareClient", RealtimeClientStub ): result = self.service._prepare_stock_detail(payload, "002141", historical) @@ -151,8 +151,8 @@ class StockDetailRealtimeTests(unittest.TestCase): }, ], } - with patch("backend.application.datetime", FixedPreopenDatetime), patch( - "backend.application.TushareClient", RealtimeClientStub + with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch( + "backend.features.market.service.TushareClient", RealtimeClientStub ): result = self.service._prepare_stock_detail(payload, "002141", today) diff --git a/app/tools/move_class_methods.py b/app/tools/move_class_methods.py new file mode 100644 index 0000000..12e50bb --- /dev/null +++ b/app/tools/move_class_methods.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import argparse +import ast +from pathlib import Path + + +MARKER = " # PRESERVATION_METHODS\n" + + +def method_span(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[int, int]: + start = min((decorator.lineno for decorator in node.decorator_list), default=node.lineno) + if node.end_lineno is None: + raise ValueError(f"Missing end position for {node.name}") + return start - 1, node.end_lineno + + +def move_methods( + source_path: Path, + class_name: str, + target_path: Path, + method_names: list[str], +) -> None: + source = source_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(source_path)) + owner = next( + ( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == class_name + ), + None, + ) + if owner is None: + raise ValueError(f"Class not found: {class_name}") + + methods = { + node.name: node + for node in owner.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + missing = [name for name in method_names if name not in methods] + if missing: + raise ValueError(f"Methods not found in {class_name}: {', '.join(missing)}") + + lines = source.splitlines(keepends=True) + ordered = sorted((methods[name] for name in method_names), key=lambda node: node.lineno) + blocks = ["".join(lines[start:end]).rstrip() for start, end in map(method_span, ordered)] + + for start, end in sorted(map(method_span, ordered), reverse=True): + del lines[start:end] + while start < len(lines) - 1 and lines[start] == "\n" and lines[start + 1] == "\n": + del lines[start] + + target = target_path.read_text(encoding="utf-8") + if target.count(MARKER) != 1: + raise ValueError(f"Target must contain exactly one method marker: {target_path}") + target = target.replace(MARKER, "\n\n".join(blocks) + "\n") + + source_path.write_text("".join(lines), encoding="utf-8") + target_path.write_text(target, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Mechanically move class methods between modules") + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--class-name", required=True) + parser.add_argument("--target", type=Path, required=True) + parser.add_argument("methods", nargs="+") + args = parser.parse_args() + move_methods(args.source, args.class_name, args.target, args.methods) + + +if __name__ == "__main__": + main() + diff --git a/app/tushare_client.py b/app/tushare_client.py index a2f65b0..793a84c 100644 --- a/app/tushare_client.py +++ b/app/tushare_client.py @@ -1,2175 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical Tushare provider implementation.""" -import json -import math -import re -import urllib.error -import urllib.request -from collections import Counter -from dataclasses import dataclass -from datetime import datetime, time as dt_time, timedelta -from threading import Lock -from typing import Any, ClassVar +import sys -from sentiment_engine import apply_sentiment_to_dashboard +from backend.data.providers import tushare_client as _implementation - -TUSHARE_URL = "http://api.tushare.pro" - - -class TushareError(RuntimeError): - pass - - -@dataclass -class TushareClient: - token: str - timeout: int = 30 - _realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {} - _realtime_reference_lock: ClassVar[Lock] = Lock() - _capital_cache: ClassVar[dict[str, dict[str, Any]]] = {} - _latest_realtime_market: ClassVar[dict[str, dict[str, Any]]] = {} - _stock_activity_cache: ClassVar[dict[str, dict[str, Any]]] = {} - _stock_listing_cache: ClassVar[dict[str, Any]] = {} - _stock_listing_lock: ClassVar[Lock] = Lock() - _suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {} - _suspension_lock: ClassVar[Lock] = Lock() - - def query( - self, - api_name: str, - params: dict[str, Any] | None = None, - fields: str = "", - ) -> list[dict[str, Any]]: - payload = json.dumps( - { - "api_name": api_name, - "token": self.token, - "params": params or {}, - "fields": fields, - } - ).encode("utf-8") - request = urllib.request.Request( - TUSHARE_URL, - data=payload, - headers={"Content-Type": "application/json", "User-Agent": "XiaobaiReviewWeb/0.2"}, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=self.timeout) as response: - result = json.loads(response.read().decode("utf-8")) - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: - raise TushareError(f"Tushare request failed: {exc}") from exc - - if result.get("code") != 0: - raise TushareError(result.get("msg") or "Tushare returned an unknown error") - - data = result.get("data") or {} - columns = data.get("fields") or [] - return [dict(zip(columns, item)) for item in data.get("items") or []] - - def dashboard(self, requested_date: str) -> dict[str, Any]: - trade_date, previous_trade_date = self.resolve_trade_context(requested_date) - if self.should_use_realtime(requested_date, trade_date): - return self._realtime_dashboard( - requested_date, - trade_date, - previous_trade_date, - ) - - daily = self._load_daily(trade_date) - if ( - not daily - and requested_date == datetime.now().astimezone().strftime("%Y%m%d") - and trade_date == requested_date - and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15) - ): - return self._realtime_dashboard( - requested_date, - trade_date, - previous_trade_date, - ) - if not daily: - raise TushareError(f"No daily data returned for {trade_date}") - - notices: list[str] = [] - try: - limit_rows = self._load_limit_lists(trade_date) - previous_limit_rows = self._load_limit_type(previous_trade_date, "U") - if not limit_rows: - notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。") - limit_rows = self._derive_limits(trade_date, daily) - except TushareError as exc: - notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}") - limit_rows = self._derive_limits(trade_date, daily) - previous_daily = self._load_daily(previous_trade_date) - previous_limit_rows = [ - row for row in self._derive_limits(previous_trade_date, previous_daily) - if row.get("limit_type") == "U" - ] - - up_rows = [row for row in limit_rows if row.get("limit_type") == "U"] - down_rows = [row for row in limit_rows if row.get("limit_type") == "D"] - broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"] - limits = [self._normalize_limit(row, "涨停") for row in up_rows] - broken = [self._normalize_limit(row, "炸板") for row in broken_rows] - down_limits = [self._normalize_limit(row, "跌停") for row in down_rows] - previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows] - yesterday_limits = _build_yesterday_performance( - previous_limits, - daily, - limits, - broken, - down_limits, - ) - sectors = _build_sectors(limits) - previous_sectors = _build_sectors(previous_limits) - - dashboard = { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(trade_date), - "previous_trade_date": _display_date(previous_trade_date), - "source": "tushare", - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": ";".join(notices), - }, - "overview": _build_overview(daily, up_rows, down_rows, broken_rows), - "limits": limits, - "broken": broken, - "down_limits": down_limits, - "yesterday_limits": yesterday_limits, - "limit_performance": _build_limit_performance(yesterday_limits), - "ladders": _build_ladders(limits), - "sectors": sectors, - "sector_rotation": _build_sector_rotation(sectors, previous_sectors), - } - return apply_sentiment_to_dashboard(dashboard) - - @staticmethod - def should_use_realtime(requested_date: str, trade_date: str) -> bool: - """Use rt_k for today's open market until end-of-day datasets settle.""" - now = datetime.now().astimezone() - today = now.strftime("%Y%m%d") - return ( - requested_date == today - and trade_date == today - and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30) - ) - - def _realtime_dashboard( - self, - requested_date: str, - trade_date: str, - previous_trade_date: str, - ) -> dict[str, Any]: - reference = self._load_realtime_reference(trade_date, previous_trade_date) - basic_rows = list(reference["basic_rows"]) - codes = ",".join( - str(row.get("ts_code") or "") for row in basic_rows if row.get("ts_code") - ) - if not codes: - raise TushareError("No active stock codes available for rt_k") - quotes = self.query("rt_k", {"ts_code": codes}) - if not quotes: - raise TushareError(f"No realtime data returned for {trade_date}") - - basic_map = {str(row.get("ts_code") or ""): row for row in basic_rows} - daily: list[dict[str, Any]] = [] - for quote in quotes: - close = _number(quote.get("close")) - previous_close = _number(quote.get("pre_close")) - if close <= 0 or previous_close <= 0: - continue - basic = basic_map.get(str(quote.get("ts_code") or ""), {}) - daily.append( - { - **quote, - "trade_date": trade_date, - "name": str(quote.get("name") or basic.get("name") or "--").strip(), - "industry": basic.get("industry") or "其他", - "pct_chg": round((close / previous_close - 1) * 100, 4), - "amount_unit": "yuan", - } - ) - with self._realtime_reference_lock: - self._latest_realtime_market[trade_date] = { - "rows": daily, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - } - if len(self._latest_realtime_market) > 3: - oldest = next(iter(self._latest_realtime_market)) - self._latest_realtime_market.pop(oldest, None) - - limit_rows = self._derive_limits( - trade_date, - daily, - price_limits=list(reference["price_limits"]), - basic_rows=basic_rows, - previous_limit_rows=list(reference["previous_limit_rows"]), - capital_rows=list(reference["capital_rows"]), - ) - previous_limit_rows = list(reference["previous_limit_rows"]) - up_rows = [row for row in limit_rows if row.get("limit_type") == "U"] - down_rows = [row for row in limit_rows if row.get("limit_type") == "D"] - broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"] - limits = [self._normalize_limit(row, "涨停") for row in up_rows] - broken = [self._normalize_limit(row, "炸板") for row in broken_rows] - down_limits = [self._normalize_limit(row, "跌停") for row in down_rows] - previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows] - yesterday_limits = _build_yesterday_performance( - previous_limits, - daily, - limits, - broken, - down_limits, - ) - sectors = _build_sectors(limits) - previous_sectors = _build_sectors(previous_limits) - now = datetime.now().astimezone() - market_status = _realtime_market_status(now.time().replace(tzinfo=None)) - dashboard = { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(trade_date), - "previous_trade_date": _display_date(previous_trade_date), - "source": "tushare", - "mode": "realtime", - "realtime": True, - "market_status": market_status, - "refresh_mode": "manual", - "auto_refresh": False, - "quote_count": len(daily), - "updated_at": now.isoformat(timespec="seconds"), - "notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。", - }, - "overview": _build_overview(daily, up_rows, down_rows, broken_rows), - "limits": limits, - "broken": broken, - "down_limits": down_limits, - "yesterday_limits": yesterday_limits, - "limit_performance": _build_limit_performance(yesterday_limits), - "ladders": _build_ladders(limits), - "sectors": sectors, - "sector_rotation": _build_sector_rotation(sectors, previous_sectors), - } - return apply_sentiment_to_dashboard(dashboard) - - def _load_realtime_reference( - self, - trade_date: str, - previous_trade_date: str, - ) -> dict[str, Any]: - cache_key = f"{trade_date}:{previous_trade_date}" - with self._realtime_reference_lock: - cached = self._realtime_reference_cache.get(cache_key) - if cached: - return cached - - basic_rows = self.query( - "stock_basic", - {"exchange": "", "list_status": "L"}, - "ts_code,name,industry,market,list_date", - ) - price_limits = self.query( - "stk_limit", - {"trade_date": trade_date}, - "ts_code,trade_date,up_limit,down_limit", - ) - previous_limit_rows = self._load_limit_type(previous_trade_date, "U") - capital_rows = self.query( - "daily_basic", - {"trade_date": previous_trade_date}, - "ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv", - ) - if not basic_rows or not price_limits: - raise TushareError(f"Realtime reference data is incomplete for {trade_date}") - result = { - "basic_rows": basic_rows, - "price_limits": price_limits, - "previous_limit_rows": previous_limit_rows, - "capital_rows": capital_rows, - } - with self._realtime_reference_lock: - self._realtime_reference_cache[cache_key] = result - if len(self._realtime_reference_cache) > 3: - oldest = next(iter(self._realtime_reference_cache)) - self._realtime_reference_cache.pop(oldest, None) - return result - - def realtime_stock_quote( - self, - ts_code: str, - reference_date: str = "", - ) -> dict[str, Any]: - rows = self.query("rt_k", {"ts_code": ts_code}) - if not rows: - raise TushareError(f"No realtime quote returned for {ts_code}") - row = rows[0] - close = _number(row.get("close")) - previous_close = _number(row.get("pre_close")) - if close <= 0 or previous_close <= 0: - raise TushareError(f"Realtime quote is unavailable for {ts_code}") - - basic: dict[str, Any] = {} - with self._realtime_reference_lock: - references = list(self._realtime_reference_cache.values()) - for reference in reversed(references): - basic = next( - ( - item for item in reference.get("basic_rows") or [] - if str(item.get("ts_code") or "") == ts_code - ), - {}, - ) - if basic: - break - if not basic: - basics = self.query( - "stock_basic", - {"ts_code": ts_code}, - "ts_code,name,industry,market,list_date", - ) - basic = basics[0] if basics else {} - capital = self._latest_capital(ts_code, reference_date) - float_share = _number(capital.get("float_share")) - # rt_k volume is shares; daily_basic float_share is reported in 10k shares. - turnover_rate = _number(row.get("vol")) / float_share / 100 if float_share else 0 - market_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d") - self._ensure_realtime_market_cache(market_date) - with self._realtime_reference_lock: - market_rows = list((self._latest_realtime_market.get(market_date) or {}).get("rows") or []) - references = list(self._realtime_reference_cache.values()) - capital_map: dict[str, dict[str, Any]] = {} - for reference in reversed(references): - capital_map = { - str(item.get("ts_code") or ""): item - for item in reference.get("capital_rows") or [] - } - if capital_map: - break - market_amounts = [_number(item.get("amount")) for item in market_rows if _number(item.get("amount")) > 0] - amount_percentile = _value_percentile(_number(row.get("amount")), market_amounts) - market_turnovers = [] - for item in market_rows: - item_capital = capital_map.get(str(item.get("ts_code") or ""), {}) - item_float_share = _number(item_capital.get("float_share")) - if item_float_share: - market_turnovers.append(_number(item.get("vol")) / item_float_share / 100) - market_turnover = ( - sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0 - ) - turnover_relative = turnover_rate / market_turnover if market_turnover else 0 - activity = self._stock_activity_metrics( - ts_code, - market_date, - _number(row.get("vol")) / 100, - ) - return { - "code": ts_code.split(".")[0], - "ts_code": ts_code, - "name": str(row.get("name") or basic.get("name") or "--").strip(), - "sector": basic.get("industry") or "其他", - "price": round(close, 3), - "change": round((close / previous_close - 1) * 100, 4), - "open": round(_number(row.get("open")), 3), - "high": round(_number(row.get("high")), 3), - "low": round(_number(row.get("low")), 3), - "previous_close": round(previous_close, 3), - "amount_billion": round(_number(row.get("amount")) / 100000000, 3), - "volume": _number(row.get("vol")), - "trade_count": int(_number(row.get("num"))), - "turnover_rate": round(turnover_rate, 4), - "market_turnover_rate": round(market_turnover, 4), - "turnover_relative": round(turnover_relative, 4), - "amount_percentile": round(amount_percentile * 100, 2), - "volume_activity_ratio": activity.get("volume_activity_ratio", 0), - "activity_history_date": activity.get("history_trade_date", ""), - "activity_source": activity.get("source", "unavailable"), - "float_share_10k": float_share, - "capital_trade_date": str(capital.get("trade_date") or ""), - "turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable", - "data_source": "tushare", - "realtime": True, - } - - def _stock_activity_metrics( - self, - ts_code: str, - reference_date: str, - current_volume_lots: float, - ) -> dict[str, Any]: - cache_key = f"{ts_code}:{reference_date}" - with self._realtime_reference_lock: - history = self._stock_activity_cache.get(cache_key) - if history is None: - try: - end = datetime.strptime(reference_date, "%Y%m%d") - except ValueError: - end = datetime.now().astimezone().replace(tzinfo=None) - rows = self.query( - "daily", - { - "ts_code": ts_code, - "start_date": (end - timedelta(days=30)).strftime("%Y%m%d"), - "end_date": reference_date, - }, - "ts_code,trade_date,vol,amount", - ) - completed = [ - item for item in rows - if str(item.get("trade_date") or "") < reference_date and _number(item.get("vol")) > 0 - ] - completed.sort(key=lambda item: str(item.get("trade_date") or "")) - recent = completed[-5:] - history = { - "average_volume_lots": ( - sum(_number(item.get("vol")) for item in recent) / len(recent) - if recent else 0 - ), - "history_trade_date": str(recent[-1].get("trade_date") or "") if recent else "", - } - with self._realtime_reference_lock: - self._stock_activity_cache[cache_key] = history - if len(self._stock_activity_cache) > 256: - oldest = next(iter(self._stock_activity_cache)) - self._stock_activity_cache.pop(oldest, None) - average_volume = _number(history.get("average_volume_lots")) - progress = _trading_session_progress(datetime.now().astimezone().time().replace(tzinfo=None)) - expected_volume = average_volume * progress - ratio = current_volume_lots / expected_volume if expected_volume else 0 - return { - **history, - "volume_activity_ratio": round(ratio, 4), - "session_progress": round(progress, 4), - "source": "rt_volume/5d_average_at_same_progress" if expected_volume else "unavailable", - } - - def realtime_factor_snapshot(self, requested_date: str) -> dict[str, Any]: - trade_date, previous_trade_date = self.resolve_trade_context(requested_date) - reference = self._load_realtime_reference(trade_date, previous_trade_date) - codes = [ - str(row.get("ts_code") or "") - for row in reference.get("basic_rows") or [] - if row.get("ts_code") - ] - quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") - capital_map = { - str(row.get("ts_code") or ""): row - for row in reference.get("capital_rows") or [] - } - rows = [] - for quote in quotes: - ts_code = str(quote.get("ts_code") or "") - close = _number(quote.get("close")) - previous_close = _number(quote.get("pre_close")) - if not ts_code or close <= 0 or previous_close <= 0: - continue - capital = capital_map.get(ts_code, {}) - float_share = _number(capital.get("float_share")) - rows.append( - { - "ts_code": ts_code, - "trade_date": trade_date, - "open": _number(quote.get("open")), - "high": _number(quote.get("high")), - "low": _number(quote.get("low")), - "close": close, - "pct_chg": (close / previous_close - 1) * 100, - "vol": _number(quote.get("vol")) / 100, - "amount": _number(quote.get("amount")), - "turnover_rate": ( - _number(quote.get("vol")) / float_share / 100 if float_share else 0 - ), - "capital_trade_date": str(capital.get("trade_date") or ""), - } - ) - if not rows: - raise TushareError(f"No realtime factor snapshot returned for {trade_date}") - return { - "trade_date": trade_date, - "previous_trade_date": previous_trade_date, - "source": "tushare_rt_k", - "realtime": True, - "rows": rows, - } - - def _ensure_realtime_market_cache(self, requested_date: str) -> list[dict[str, Any]]: - with self._realtime_reference_lock: - cached = list( - (self._latest_realtime_market.get(requested_date) or {}).get("rows") or [] - ) - if cached: - return cached - trade_date, previous_trade_date = self.resolve_trade_context(requested_date) - if trade_date != requested_date: - return [] - reference = self._load_realtime_reference(trade_date, previous_trade_date) - codes = [ - str(row.get("ts_code") or "") - for row in reference.get("basic_rows") or [] - if row.get("ts_code") - ] - quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") - rows = [ - row for row in quotes - if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0 - ] - with self._realtime_reference_lock: - self._latest_realtime_market[trade_date] = { - "rows": rows, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - } - return rows - - def _latest_capital(self, ts_code: str, reference_date: str = "") -> dict[str, Any]: - end_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d") - cache_key = f"{ts_code}:{end_date}" - with self._realtime_reference_lock: - cached = self._capital_cache.get(cache_key) - if cached: - return cached - try: - end = datetime.strptime(end_date, "%Y%m%d") - except ValueError: - end = datetime.now().astimezone().replace(tzinfo=None) - end_date = end.strftime("%Y%m%d") - start_date = (end - timedelta(days=20)).strftime("%Y%m%d") - rows = self.query( - "daily_basic", - {"ts_code": ts_code, "start_date": start_date, "end_date": end_date}, - "ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share," - "free_share,total_mv,circ_mv", - ) - rows.sort(key=lambda item: str(item.get("trade_date") or "")) - result = rows[-1] if rows else {} - with self._realtime_reference_lock: - self._capital_cache[cache_key] = result - if len(self._capital_cache) > 256: - oldest = next(iter(self._capital_cache)) - self._capital_cache.pop(oldest, None) - return result - - def market_indices(self, requested_date: str, lookback_days: int = 45) -> dict[str, Any]: - trade_date, _ = self.resolve_trade_context(requested_date) - end = datetime.strptime(trade_date, "%Y%m%d") - start_date = (end - timedelta(days=max(30, lookback_days * 2))).strftime("%Y%m%d") - index_names = { - "000001.SH": "上证指数", - "399001.SZ": "深证成指", - "399006.SZ": "创业板指", - } - indices = [] - for ts_code, name in index_names.items(): - rows = self.query( - "index_daily", - {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, - "ts_code,trade_date,close,pct_chg,vol,amount", - ) - rows.sort(key=lambda item: str(item.get("trade_date") or "")) - if not rows: - continue - latest = rows[-1] - close = _number(latest.get("close")) - close_5d = _number(rows[-6].get("close")) if len(rows) >= 6 else _number(rows[0].get("close")) - close_20d = _number(rows[-21].get("close")) if len(rows) >= 21 else _number(rows[0].get("close")) - indices.append( - { - "ts_code": ts_code, - "name": name, - "trade_date": str(latest.get("trade_date") or trade_date), - "close": close, - "pct_chg": round(_number(latest.get("pct_chg")), 3), - "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0, - "return_20d": round((close / close_20d - 1) * 100, 3) if close_20d else 0, - "amount_billion": round(_number(latest.get("amount")) / 100000, 2), - } - ) - if not indices: - raise TushareError(f"No index data returned for {trade_date}") - return { - "trade_date": trade_date, - "source": "tushare", - "realtime": False, - "precise": all(item["trade_date"] == trade_date for item in indices), - "indices": indices, - "aggregate": { - "average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3), - "average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3), - "average_return_20d": round(sum(item["return_20d"] for item in indices) / len(indices), 3), - }, - } - - def realtime_market_indices(self, requested_date: str) -> dict[str, Any]: - trade_date, _ = self.resolve_trade_context(requested_date) - index_names = { - "000001.SH": "上证指数", - "399001.SZ": "深证成指", - "399006.SZ": "创业板指", - } - rows = self.query("rt_idx_k", {"ts_code": ",".join(index_names)}, "") - row_map = {str(row.get("ts_code") or ""): row for row in rows} - indices = [] - for ts_code, name in index_names.items(): - row = row_map.get(ts_code) - if not row: - continue - close = _number(row.get("close")) - previous_close = _number(row.get("pre_close")) - if close <= 0 or previous_close <= 0: - continue - history = self.query( - "index_daily", - { - "ts_code": ts_code, - "start_date": (datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)).strftime("%Y%m%d"), - "end_date": trade_date, - }, - "ts_code,trade_date,close,pct_chg", - ) - history.sort(key=lambda item: str(item.get("trade_date") or "")) - previous_closes = [ - _number(item.get("close")) for item in history - if str(item.get("trade_date") or "") < trade_date and _number(item.get("close")) > 0 - ] - close_5d = previous_closes[-5] if len(previous_closes) >= 5 else previous_closes[0] if previous_closes else previous_close - indices.append( - { - "ts_code": ts_code, - "name": str(row.get("name") or name).strip(), - "trade_date": trade_date, - "close": close, - "pct_chg": round((close / previous_close - 1) * 100, 3), - "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0, - "amount_billion": round(_number(row.get("amount")) / 100000000, 2), - } - ) - if len(indices) != len(index_names): - raise TushareError("Realtime index quotes are incomplete") - return { - "trade_date": trade_date, - "source": "tushare_rt_idx_k", - "realtime": True, - "precise": True, - "indices": indices, - "aggregate": { - "average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3), - "average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3), - "average_return_20d": 0, - }, - } - - def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]: - """Return the Shenwan industry active for a stock on trade_date.""" - rows = [] - for is_new in ("Y", "N"): - rows.extend( - self.query( - "index_member_all", - {"ts_code": ts_code, "is_new": is_new}, - "l1_code,l1_name,l2_code,l2_name,l3_code,l3_name," - "ts_code,name,in_date,out_date,is_new", - ) - ) - rows = _reconcile_membership_rows(rows) - matched = [row for row in rows if _membership_active_on(row, trade_date)] - if not matched: - matched = [ - row for row in rows - if row.get("is_new") == "Y" - and str(row.get("in_date") or "") <= trade_date - ] - if not matched: - raise TushareError(f"No Shenwan industry returned for {ts_code}") - row = max( - matched, - key=lambda item: ( - str(item.get("in_date") or ""), - 1 if item.get("is_new") == "Y" else 0, - str(item.get("l3_code") or item.get("l2_code") or ""), - ), - ) - return { - "l1_code": str(row.get("l1_code") or ""), - "l1_name": str(row.get("l1_name") or ""), - "l2_code": str(row.get("l2_code") or ""), - "l2_name": str(row.get("l2_name") or ""), - "l3_code": str(row.get("l3_code") or ""), - "l3_name": str(row.get("l3_name") or ""), - "in_date": str(row.get("in_date") or ""), - "out_date": str(row.get("out_date") or ""), - "is_new": str(row.get("is_new") or ""), - } - - def sw_sector_snapshot( - self, - ts_code: str, - requested_date: str, - realtime_expected: bool = False, - allow_realtime_close: bool = False, - ) -> dict[str, Any]: - """Build the single Shenwan L2 sector context used by heaven trend.""" - trade_date, previous_trade_date = self.resolve_trade_context(requested_date) - industry = self.sw_stock_industry(ts_code, trade_date) - sector_code = str(industry.get("l2_code") or "") - if not sector_code: - raise TushareError(f"Shenwan L2 code is unavailable for {ts_code}") - members = self._sw_sector_members(sector_code, trade_date) - if not members: - raise TushareError(f"No Shenwan members returned for {sector_code}") - raw_member_count = len(members) - members, excluded_members = _filter_members_by_listing( - members, - self._stock_listing_reference(), - trade_date, - ) - if not members: - raise TushareError(f"No listed Shenwan members returned for {sector_code}") - - if realtime_expected: - snapshot = self._sw_realtime_sector_snapshot( - industry, - members, - trade_date, - previous_trade_date, - finalized=False, - ) - snapshot.update({ - "raw_member_count": raw_member_count, - "excluded_member_count": len(excluded_members), - "excluded_members": excluded_members, - }) - return snapshot - - member_set = {str(item.get("ts_code") or "") for item in members} - member_names = { - str(item.get("ts_code") or ""): str(item.get("name") or "") - for item in members - } - member_rows = [ - row for row in self._load_daily(trade_date) - if str(row.get("ts_code") or "") in member_set - ] - quoted_codes = {str(row.get("ts_code") or "") for row in member_rows} - suspended_members = self._confirmed_suspended_members( - members, quoted_codes, trade_date - ) - up_count = sum(_number(row.get("pct_chg")) > 0 for row in member_rows) - down_count = sum(_number(row.get("pct_chg")) < 0 for row in member_rows) - leader = max(member_rows, key=lambda row: _number(row.get("pct_chg")), default={}) - leader_code = str(leader.get("ts_code") or "") - equal_change = ( - sum(_number(row.get("pct_chg")) for row in member_rows) / len(member_rows) - if member_rows else 0 - ) - coverage = len(member_rows) / max(len(members), 1) * 100 - explained_count = len(member_rows) + len(suspended_members) - explained_coverage = explained_count / max(len(members), 1) * 100 - coverage_issue = _sector_coverage_issue( - len(members), - len(member_rows), - explained_coverage, - explained_count, - ) - inner_precise = not coverage_issue - inner_error = coverage_issue - amount_billion = sum(_number(row.get("amount")) for row in member_rows) / 100000 - rows = self.query( - "sw_daily", - {"ts_code": sector_code, "trade_date": trade_date}, - "ts_code,trade_date,name,close,pct_change,vol,amount,pe,pb,float_mv,total_mv", - ) - daily = rows[0] if rows else {} - actual_trade_date = str(daily.get("trade_date") or "") - outer_precise = actual_trade_date == trade_date - outer_error = "" if outer_precise else ( - f"No Shenwan daily returned for {sector_code} on {trade_date}" - ) - if not outer_precise and allow_realtime_close: - try: - return self._sw_realtime_sector_snapshot( - industry, - members, - trade_date, - previous_trade_date, - finalized=True, - ) - except TushareError as exc: - outer_error = f"{outer_error}; realtime close fallback failed: {exc}" - - official_change = _number(daily.get("pct_change")) if outer_precise else None - return { - "code": sector_code, - "name": industry.get("l2_name") or daily.get("name") or sector_code, - "leader": str(leader.get("name") or member_names.get(leader_code) or "--"), - "leader_code": leader_code, - "leading_pct": round(_number(leader.get("pct_chg")), 3), - "change": round(official_change, 3) if official_change is not None else None, - "member_equal_change": round(equal_change, 3), - "turnover_rate": 0, - "up_count": up_count, - "down_count": down_count, - "flat_count": len(member_rows) - up_count - down_count, - "member_count": len(members), - "raw_member_count": raw_member_count, - "excluded_member_count": len(excluded_members), - "excluded_members": excluded_members, - "quote_count": len(member_rows), - "coverage": round(coverage, 1), - "explained_count": explained_count, - "explained_coverage": round(explained_coverage, 1), - "suspended_count": len(suspended_members), - "suspended_members": suspended_members, - "strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1), - "amount_billion": round(amount_billion, 2), - "count": 0, - "max_streak": 0, - "source": "tushare_sw_daily+member_daily" if outer_precise else "tushare_member_daily", - "inner_source": "tushare_member_daily", - "outer_source": "tushare_sw_daily" if outer_precise else "unavailable", - "taxonomy": "sw_l2", - "industry": industry, - "trade_date": trade_date, - "inner_trade_date": trade_date if member_rows else "", - "outer_trade_date": actual_trade_date, - "realtime": False, - "finalized": True, - "inner_precise": inner_precise, - "outer_precise": outer_precise, - "precise": inner_precise and outer_precise, - "inner_error": inner_error, - "outer_error": outer_error, - "schema_version": 6, - "methodology": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合", - } - - def _sw_sector_members( - self, - sector_code: str, - trade_date: str, - ) -> list[dict[str, Any]]: - rows = [] - for is_new in ("Y", "N"): - rows.extend( - self.query( - "index_member_all", - {"l2_code": sector_code, "is_new": is_new}, - "l2_code,l2_name,ts_code,name,in_date,out_date,is_new", - ) - ) - deduped: dict[str, dict[str, Any]] = {} - for row in _reconcile_membership_rows(rows): - code = str(row.get("ts_code") or "") - if code and _membership_active_on(row, trade_date): - current = deduped.get(code) - if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""): - deduped[code] = row - return list(deduped.values()) - - def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]: - """Return constituents active in a Shenwan L2 industry on the target date.""" - return self._sw_sector_members(sector_code, trade_date) - - def _stock_listing_reference(self) -> dict[str, dict[str, Any]]: - now = datetime.now().astimezone() - with self._stock_listing_lock: - loaded_at = self._stock_listing_cache.get("loaded_at") - cached = self._stock_listing_cache.get("rows") - if ( - isinstance(loaded_at, datetime) - and isinstance(cached, dict) - and now - loaded_at < timedelta(hours=6) - ): - return cached - - rows: list[dict[str, Any]] = [] - try: - for status in ("L", "D", "P"): - rows.extend(self.query( - "stock_basic", - {"list_status": status}, - "ts_code,name,list_status,list_date,delist_date", - )) - except TushareError: - # Unknown status must remain in the denominator so a reference-data - # failure cannot silently improve coverage. - return {} - reference = { - str(row.get("ts_code") or ""): dict(row) - for row in rows - if row.get("ts_code") - } - with self._stock_listing_lock: - type(self)._stock_listing_cache = {"loaded_at": now, "rows": reference} - return reference - - def _confirmed_suspended_members( - self, - members: list[dict[str, Any]], - quoted_codes: set[str], - trade_date: str, - ) -> list[dict[str, str]]: - suspended: list[dict[str, str]] = [] - for member in members: - code = str(member.get("ts_code") or "") - if not code or code in quoted_codes: - continue - cache_key = f"{trade_date}:{code}" - with self._suspension_lock: - cached = self._suspension_cache.get(cache_key, "missing") - if cached == "missing": - try: - rows = self.query( - "suspend_d", - {"ts_code": code}, - "ts_code,suspend_date,resume_date,ann_date,suspend_reason,reason_type", - ) - except TushareError: - rows = [] - active = [ - row for row in rows - if str(row.get("suspend_date") or "") - and str(row.get("suspend_date") or "") <= trade_date - and ( - not str(row.get("resume_date") or "") - or trade_date < str(row.get("resume_date") or "") - ) - ] - row = max( - active, - key=lambda item: str(item.get("suspend_date") or ""), - default=None, - ) - cached = ({ - "ts_code": code, - "name": str(member.get("name") or code), - "suspend_date": str(row.get("suspend_date") or ""), - "resume_date": str(row.get("resume_date") or ""), - "reason": str(row.get("suspend_reason") or row.get("reason_type") or "已确认停牌"), - } if row else None) - with self._suspension_lock: - type(self)._suspension_cache[cache_key] = cached - if isinstance(cached, dict): - suspended.append(cached) - return suspended - - def _sw_realtime_sector_snapshot( - self, - industry: dict[str, Any], - members: list[dict[str, Any]], - trade_date: str, - previous_trade_date: str, - finalized: bool = False, - ) -> dict[str, Any]: - sector_code = str(industry.get("l2_code") or "") - sw_rows = self.query( - "rt_sw_k", - {"ts_code": sector_code}, - "ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change", - ) - sw_row = sw_rows[0] if sw_rows else {} - trade_time = str(sw_row.get("trade_time") or "") - quote_date = trade_time[:10].replace("-", "") - quote_clock = trade_time[11:19] if len(trade_time) >= 19 else "" - outer_precise = bool(sw_row and quote_date == trade_date) - if finalized and (not quote_clock or quote_clock < "15:00:00"): - outer_precise = False - official_change = _number(sw_row.get("pct_change")) - if not official_change: - close = _number(sw_row.get("close")) - pre_close = _number(sw_row.get("pre_close")) - official_change = (close / pre_close - 1) * 100 if close and pre_close else 0 - if not outer_precise: - official_change = None - outer_error = "" - if not sw_row: - outer_error = f"No Shenwan realtime index returned for {sector_code}" - elif quote_date != trade_date: - outer_error = f"Shenwan realtime index date is {quote_date or 'unknown'}, expected {trade_date}" - elif finalized and (not quote_clock or quote_clock < "15:00:00"): - outer_error = f"Shenwan realtime index is not a close snapshot ({trade_time})" - - valid: list[dict[str, Any]] = [] - codes: list[str] = [] - reference: dict[str, Any] = {} - inner_error = "" - try: - reference = self._load_realtime_reference(trade_date, previous_trade_date) - active_codes = { - str(row.get("ts_code") or "") - for row in reference.get("basic_rows") or [] - if row.get("ts_code") - } - codes = [ - str(row.get("ts_code") or "") - for row in members - if str(row.get("ts_code") or "") in active_codes - ] - if codes: - quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") - for row in quotes: - close = _number(row.get("close")) - previous_close = _number(row.get("pre_close")) - if close <= 0 or previous_close <= 0: - continue - valid.append({**row, "change": (close / previous_close - 1) * 100}) - else: - inner_error = f"No active Shenwan members returned for {sector_code}" - except TushareError as exc: - inner_error = str(exc) - - coverage = len(valid) / max(len(codes), 1) * 100 - valid_codes = {str(item.get("ts_code") or "") for item in valid} - suspended_members = self._confirmed_suspended_members( - members, valid_codes, trade_date - ) - explained_count = len(valid) + len(suspended_members) - explained_coverage = explained_count / max(len(codes), 1) * 100 - coverage_issue = _sector_coverage_issue( - len(codes), len(valid), explained_coverage, explained_count - ) - inner_precise = bool(codes) and not coverage_issue - if not inner_precise and not inner_error: - inner_error = coverage_issue or "申万实时有效成分为空" - up_count = sum(item["change"] > 0 for item in valid) - down_count = sum(item["change"] < 0 for item in valid) - leader = max(valid, key=lambda item: item["change"], default={}) - leader_code = str(leader.get("ts_code") or "") - member_names = { - str(item.get("ts_code") or ""): str(item.get("name") or "") - for item in members - } - equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0 - amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000 - try: - self._ensure_realtime_market_cache(trade_date) - with self._realtime_reference_lock: - market_rows = list( - (self._latest_realtime_market.get(trade_date) or {}).get("rows") or [] - ) - except TushareError as exc: - market_rows = [] - inner_precise = False - inner_error = inner_error or str(exc) - capital_map = { - str(item.get("ts_code") or ""): item - for item in reference.get("capital_rows") or [] - } - sector_turnovers = [] - for item in valid: - capital = capital_map.get(str(item.get("ts_code") or ""), {}) - float_share = _number(capital.get("float_share")) - if float_share: - sector_turnovers.append(_number(item.get("vol")) / float_share / 100) - market_turnovers = [] - for item in market_rows: - capital = capital_map.get(str(item.get("ts_code") or ""), {}) - float_share = _number(capital.get("float_share")) - if float_share: - market_turnovers.append(_number(item.get("vol")) / float_share / 100) - average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0 - market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0 - relative_turnover = average_turnover / market_turnover if market_turnover else 0 - if not relative_turnover: - inner_precise = False - inner_error = inner_error or "Shenwan member relative turnover is unavailable" - return { - "code": sector_code, - "name": str(industry.get("l2_name") or sw_row.get("name") or ""), - "leader": str(leader.get("name") or member_names.get(leader_code) or "--").strip(), - "leader_code": leader_code, - "leading_pct": round(_number(leader.get("change")), 3), - "change": round(official_change, 3) if official_change is not None else None, - "member_equal_change": round(equal_change, 3), - "turnover_rate": round(average_turnover, 4), - "market_turnover_rate": round(market_turnover, 4), - "relative_turnover": round(relative_turnover, 4), - "up_count": up_count, - "down_count": down_count, - "flat_count": len(valid) - up_count - down_count, - "member_count": len(codes), - "quote_count": len(valid), - "coverage": round(coverage, 1), - "explained_count": explained_count, - "explained_coverage": round(explained_coverage, 1), - "suspended_count": len(suspended_members), - "suspended_members": suspended_members, - "strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1), - "amount_billion": round(amount_billion, 2), - "count": sum(item["change"] >= 9.5 for item in valid), - "max_streak": 0, - "source": "tushare_rt_sw_k+sw_members_rt_k", - "inner_source": "tushare_sw_members+rt_k", - "outer_source": "tushare_rt_sw_k", - "taxonomy": "sw_l2", - "industry": industry, - "trade_date": trade_date, - "inner_trade_date": trade_date if valid else "", - "outer_trade_date": quote_date, - "trade_time": trade_time, - "realtime": True, - "finalized": finalized, - "inner_precise": inner_precise, - "outer_precise": outer_precise, - "precise": inner_precise and outer_precise, - "inner_error": inner_error, - "outer_error": outer_error, - "schema_version": 6, - "methodology": "外显使用申万官方 rt_sw_k;内核独立使用申万成分 rt_k 宽度与相对换手聚合", - } - - def sector_snapshot( - self, - identifier: str, - requested_date: str, - realtime_expected: bool | None = None, - ) -> dict[str, Any]: - trade_date, _ = self.resolve_trade_context(requested_date) - raw_identifier = identifier.strip() - if not raw_identifier: - raise TushareError("Sector identifier is empty") - errors = [] - now = datetime.now().astimezone() - if realtime_expected is None: - realtime_expected = ( - trade_date == now.strftime("%Y%m%d") - and dt_time(9, 15) <= now.time().replace(tzinfo=None) <= dt_time(15, 5) - ) - try: - dc_params = {"trade_date": trade_date} - if re.fullmatch(r"[A-Z0-9.]+", raw_identifier.upper()) and "." in raw_identifier: - dc_params["ts_code"] = raw_identifier.upper() - else: - dc_params["name"] = raw_identifier - dc_rows = self.query( - "dc_index", - dc_params, - "ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct," - "total_mv,turnover_rate,up_num,down_num", - ) - if not dc_rows and "name" in dc_params: - dc_rows = self.query( - "dc_index", - {"trade_date": trade_date}, - "ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct," - "total_mv,turnover_rate,up_num,down_num", - ) - dc_row = _match_sector_row(dc_rows, raw_identifier) - if dc_row and not realtime_expected: - change = _number(dc_row.get("pct_change")) - actual_trade_date = str(dc_row.get("trade_date") or "") - return { - "code": dc_row.get("ts_code") or "", - "name": dc_row.get("name") or raw_identifier, - "leader": dc_row.get("leading") or "--", - "leader_code": dc_row.get("leading_code") or "", - "leading_pct": _number(dc_row.get("leading_pct")), - "change": change, - "turnover_rate": _number(dc_row.get("turnover_rate")), - "up_count": int(_number(dc_row.get("up_num"))), - "down_count": int(_number(dc_row.get("down_num"))), - "total_mv": _number(dc_row.get("total_mv")), - "strength": round(max(0, min(100, 50 + change * 5)), 1), - "amount_billion": 0, - "count": 0, - "max_streak": 0, - "source": "tushare_dc", - "trade_date": actual_trade_date, - "realtime": False, - "precise": actual_trade_date == trade_date, - } - except TushareError as exc: - errors.append(f"DC: {exc}") - - ts_code = raw_identifier.upper() - if re.fullmatch(r"\d{6}", ts_code): - ts_code = f"{ts_code}.TI" - try: - if re.fullmatch(r"\d{6}\.TI", ts_code): - index_rows = self.query( - "ths_index", - {"ts_code": ts_code}, - "ts_code,name,count,exchange,list_date,type", - ) - else: - index_rows = self.query( - "ths_index", - {}, - "ts_code,name,count,exchange,list_date,type", - ) - basic = _match_sector_row(index_rows, raw_identifier) - if not basic: - raise TushareError(f"No THS sector returned for {raw_identifier}") - except TushareError as exc: - errors.append(f"THS: {exc}") - raise TushareError("; ".join(errors)) from exc - actual_code = str(basic.get("ts_code") or ts_code) - if realtime_expected: - try: - realtime_sector = self._realtime_sector_snapshot(actual_code, basic, trade_date) - if realtime_sector: - return realtime_sector - except TushareError as exc: - errors.append(f"THS realtime members: {exc}") - daily_rows = self.query( - "ths_daily", - {"ts_code": actual_code, "trade_date": trade_date}, - "ts_code,trade_date,close,pct_change,vol,turnover_rate,total_mv,float_mv", - ) - daily = daily_rows[0] if daily_rows else {} - actual_trade_date = str(daily.get("trade_date") or "") - change = _number(daily.get("pct_change")) - return { - "code": actual_code, - "name": basic.get("name") or raw_identifier, - "leader": "--", - "change": change, - "leading_pct": change, - "turnover_rate": _number(daily.get("turnover_rate")), - "up_count": 0, - "down_count": 0, - "strength": round(max(0, min(100, 50 + change * 5)), 1), - "amount_billion": 0, - "count": 0, - "max_streak": 0, - "source": "tushare_ths", - "trade_date": actual_trade_date, - "realtime": False, - "precise": actual_trade_date == trade_date, - } - - def _realtime_sector_snapshot( - self, - sector_code: str, - basic: dict[str, Any], - trade_date: str, - ) -> dict[str, Any] | None: - members = self.query( - "ths_member", - {"ts_code": sector_code, "is_new": "Y"}, - "ts_code,con_code,con_name,is_new", - ) - codes = [str(row.get("con_code") or "") for row in members if row.get("con_code")] - if not codes: - return None - quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") - valid = [] - for row in quotes: - close = _number(row.get("close")) - previous_close = _number(row.get("pre_close")) - if close <= 0 or previous_close <= 0: - continue - valid.append( - { - **row, - "change": (close / previous_close - 1) * 100, - } - ) - minimum = max(1, math.ceil(len(codes) * 0.9)) - if len(valid) < minimum: - raise TushareError( - f"Realtime sector coverage is insufficient ({len(valid)}/{len(codes)})" - ) - up_count = sum(item["change"] > 0 for item in valid) - down_count = sum(item["change"] < 0 for item in valid) - flat_count = len(valid) - up_count - down_count - leader = max(valid, key=lambda item: item["change"]) - change = sum(item["change"] for item in valid) / len(valid) - amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000 - self._ensure_realtime_market_cache(trade_date) - with self._realtime_reference_lock: - references = list(self._realtime_reference_cache.values()) - market_rows = list((self._latest_realtime_market.get(trade_date) or {}).get("rows") or []) - capital_map: dict[str, dict[str, Any]] = {} - for reference in reversed(references): - capital_map = { - str(item.get("ts_code") or ""): item - for item in reference.get("capital_rows") or [] - } - if capital_map: - break - sector_turnovers = [] - for item in valid: - capital = capital_map.get(str(item.get("ts_code") or ""), {}) - float_share = _number(capital.get("float_share")) - if float_share: - sector_turnovers.append(_number(item.get("vol")) / float_share / 100) - market_turnovers = [] - for item in market_rows: - capital = capital_map.get(str(item.get("ts_code") or ""), {}) - float_share = _number(capital.get("float_share")) - if float_share: - market_turnovers.append(_number(item.get("vol")) / float_share / 100) - average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0 - market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0 - relative_turnover = average_turnover / market_turnover if market_turnover else 0 - return { - "code": sector_code, - "name": basic.get("name") or sector_code, - "leader": str(leader.get("name") or "--").strip(), - "leader_code": leader.get("ts_code") or "", - "leading_pct": round(leader["change"], 3), - "change": round(change, 3), - "turnover_rate": round(average_turnover, 4), - "market_turnover_rate": round(market_turnover, 4), - "relative_turnover": round(relative_turnover, 4), - "up_count": up_count, - "down_count": down_count, - "flat_count": flat_count, - "member_count": len(codes), - "quote_count": len(valid), - "coverage": round(len(valid) / len(codes) * 100, 1), - "strength": round(max(0, min(100, 50 + change * 5)), 1), - "amount_billion": round(amount_billion, 2), - "count": sum(item["change"] >= 9.5 for item in valid), - "max_streak": 0, - "source": "tushare_rt_ths_members", - "trade_date": trade_date, - "realtime": True, - "precise": True, - "methodology": "同花顺行业最新成分股的 rt_k 等权涨跌、宽度与成交额聚合", - } - - def hot_money_profiles(self) -> dict[str, Any]: - rows = self.query("hm_list", {}, "name,desc,orgs") - profiles: list[dict[str, Any]] = [] - seen_names: set[str] = set() - for row in rows: - name = str(row.get("name") or "").strip() - if not name or name in seen_names: - continue - seen_names.add(name) - description = _text(row.get("desc")) - organization_text = _text(row.get("orgs")) - parsed_organizations: Any = None - if organization_text.startswith("["): - try: - parsed_organizations = json.loads(organization_text) - except json.JSONDecodeError: - parsed_organizations = None - organization_parts = ( - parsed_organizations - if isinstance(parsed_organizations, list) - else re.split(r"[,,;;\n]+", organization_text) - ) - organizations = list(dict.fromkeys( - _text(part) - for part in organization_parts - if _text(part) - )) - profiles.append( - { - "id": f"hot-money-profile-{len(profiles) + 1}", - "name": name, - "description": description, - "organizations": organizations, - "organization_count": len(organizations), - } - ) - return { - "meta": { - "source": "tushare", - "status": "success" if profiles else "empty", - "schema_version": 1, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": "", - }, - "summary": { - "profile_count": len(profiles), - "described_count": sum(bool(item["description"]) for item in profiles), - "organization_count": sum(item["organization_count"] for item in profiles), - }, - "profiles": profiles, - } - - def dragon_tiger(self, requested_date: str) -> dict[str, Any]: - trade_date, _ = self.resolve_trade_context(requested_date) - detail_rows = self.query( - "hm_detail", - {"trade_date": trade_date}, - "trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount," - "hm_name,hm_orgs,tag", - ) - - notices: list[str] = [] - try: - directory_rows = self.query("hm_list", {}, "name,desc,orgs") - except TushareError as exc: - directory_rows = [] - notices.append(f"游资名录暂不可用:{exc}") - directory = { - str(row.get("name") or "").strip(): { - "description": _text(row.get("desc")), - "orgs": _text(row.get("orgs")), - } - for row in directory_rows - if str(row.get("name") or "").strip() - } - - # 个股龙虎榜仅用于补充涨幅和上榜原因,不参与游资身份识别。 - try: - top_rows = self.query( - "top_list", - {"trade_date": trade_date}, - "trade_date,ts_code,name,pct_change,reason", - ) - except TushareError as exc: - top_rows = [] - notices.append(f"个股龙虎榜辅助信息暂不可用:{exc}") - stock_context: dict[str, dict[str, Any]] = {} - for row in top_rows: - ts_code = str(row.get("ts_code") or "") - if ts_code and ts_code not in stock_context: - stock_context[ts_code] = row - - groups: dict[str, dict[str, Any]] = {} - for row in detail_rows: - trader_name = str(row.get("hm_name") or "未命名游资").strip() - ts_code = str(row.get("ts_code") or "").strip() - stock = stock_context.get(ts_code, {}) - directory_item = directory.get(trader_name, {}) - seat_name = _text(row.get("hm_orgs")) or directory_item.get("orgs") or "--" - buy = round(_number(row.get("buy_amount")) / 1000000, 2) - sell = round(_number(row.get("sell_amount")) / 1000000, 2) - net_buy = round(_number(row.get("net_amount")) / 1000000, 2) - group = groups.setdefault( - trader_name, - { - "name": trader_name, - "description": directory_item.get("description") or "", - "directory_orgs": directory_item.get("orgs") or "", - "identity_type": "trader", - "identity_source": "tushare_hm", - "recognized": True, - "buy_million": 0.0, - "sell_million": 0.0, - "net_buy_million": 0.0, - "seat_names": set(), - "stock_codes": set(), - "operations": [], - }, - ) - group["buy_million"] += buy - group["sell_million"] += sell - group["net_buy_million"] += net_buy - if seat_name != "--": - group["seat_names"].add(seat_name) - code = ts_code.split(".")[0] - if code: - group["stock_codes"].add(code) - group["operations"].append( - { - "code": code, - "ts_code": ts_code, - "name": row.get("ts_name") or stock.get("name") or "--", - "change": ( - _number(stock.get("pct_change")) - if stock.get("pct_change") is not None - else None - ), - "direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平", - "buy_million": buy, - "sell_million": sell, - "net_buy_million": net_buy, - "seat_name": seat_name, - "seat_alias": trader_name, - "tag": _text(row.get("tag")) or "--", - "reason": _text(stock.get("reason")) or "--", - } - ) - - traders = list(groups.values()) - traders.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True) - for index, group in enumerate(traders, start=1): - group["id"] = f"hot-money-{index}" - group["buy_million"] = round(group["buy_million"], 2) - group["sell_million"] = round(group["sell_million"], 2) - group["net_buy_million"] = round(group["net_buy_million"], 2) - group["seat_count"] = len(group.pop("seat_names")) - group["stock_count"] = len(group.pop("stock_codes")) - group["operation_count"] = len(group["operations"]) - group["operations"].sort( - key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True - ) - - operation_count = sum(item["operation_count"] for item in traders) - active_stocks = { - operation["code"] for item in traders for operation in item["operations"] - if operation["code"] - } - net_buy_total = round(sum(item["net_buy_million"] for item in traders), 2) - status = "success" if detail_rows else "partial" if top_rows else "empty" - if not detail_rows: - notices.insert( - 0, - f"当日有 {len(stock_context)} 只股票上榜,但未返回可识别的游资每日明细。" - if top_rows - else "该交易日未返回龙虎榜或游资每日明细。", - ) - return { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(trade_date), - "source": "tushare", - "status": status, - "schema_version": 3, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": ";".join(notices), - }, - "summary": { - "trader_count": len(traders), - "identity_count": len(traders), - "operation_count": operation_count, - "active_stock_count": len(active_stocks), - "seat_net_buy_million": net_buy_total, - "unclassified_count": 0, - "directory_count": len(directory), - "official_stock_count": len(stock_context), - }, - "traders": traders, - "unclassified_seats": [], - "rows": [], - } - - def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]: - trade_date, _ = self.resolve_trade_context(requested_date) - end = datetime.strptime(trade_date, "%Y%m%d") - start_date = (end - timedelta(days=190)).strftime("%Y%m%d") - daily = self.query( - "daily", - {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, - "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", - ) - factors = self.query( - "adj_factor", - {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, - "ts_code,trade_date,adj_factor", - ) - basics = self.query( - "stock_basic", - {"ts_code": ts_code}, - "ts_code,symbol,name,area,industry,market,list_date", - ) - daily_basics = self.query( - "daily_basic", - {"ts_code": ts_code, "trade_date": trade_date}, - "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv", - ) - moneyflow = self.query( - "moneyflow", - {"ts_code": ts_code, "trade_date": trade_date}, - "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," - "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", - ) - factor_map = {row["trade_date"]: _number(row.get("adj_factor"), 1) for row in factors} - latest_factor = max(factor_map.values(), default=1) or 1 - prices = [] - for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-90:]: - factor = factor_map.get(row.get("trade_date"), latest_factor) - ratio = factor / latest_factor - prices.append( - { - "trade_date": _display_date(str(row.get("trade_date", ""))), - "open": round(_number(row.get("open")) * ratio, 3), - "high": round(_number(row.get("high")) * ratio, 3), - "low": round(_number(row.get("low")) * ratio, 3), - "close": round(_number(row.get("close")) * ratio, 3), - "change": _number(row.get("pct_chg")), - "volume": _number(row.get("vol")), - "amount_billion": round(_number(row.get("amount")) / 100000, 2), - } - ) - flow = moneyflow[0] if moneyflow else {} - basic = basics[0] if basics else {} - daily_basic = daily_basics[0] if daily_basics else {} - latest = prices[-1] if prices else {} - actual_trade_date = max( - (str(row.get("trade_date") or "") for row in daily), - default=trade_date, - ) or trade_date - return { - "meta": { - "requested_date": _display_date(requested_date), - "trade_date": _display_date(actual_trade_date), - "source": "tushare", - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "notice": "", - }, - "stock": { - "code": ts_code.split(".")[0], - "ts_code": ts_code, - "name": basic.get("name") or "--", - "industry": basic.get("industry") or "其他", - "area": basic.get("area") or "--", - "market": basic.get("market") or "--", - "list_date": _display_date(str(basic.get("list_date") or "")), - "price": latest.get("close", 0), - "change": latest.get("change", 0), - "turnover_rate": _number(daily_basic.get("turnover_rate")), - "volume_ratio": _number(daily_basic.get("volume_ratio")), - "amount_billion": latest.get("amount_billion", 0), - }, - "prices": prices, - "moneyflow": { - "net_million": round(_number(flow.get("net_mf_amount")) / 100, 2), - "large_million": round( - (_number(flow.get("buy_lg_amount")) + _number(flow.get("buy_elg_amount")) - - _number(flow.get("sell_lg_amount")) - _number(flow.get("sell_elg_amount"))) / 100, - 2, - ), - "medium_million": round( - (_number(flow.get("buy_md_amount")) - _number(flow.get("sell_md_amount"))) / 100, - 2, - ), - "small_million": round( - (_number(flow.get("buy_sm_amount")) - _number(flow.get("sell_sm_amount"))) / 100, - 2, - ), - }, - } - - def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]: - trade_date, _ = self.resolve_trade_context(requested_date) - display_date = _display_date(trade_date) - rows = self.query( - "stk_mins", - { - "ts_code": ts_code, - "freq": "1min", - "start_date": f"{display_date} 09:00:00", - "end_date": f"{display_date} 15:30:00", - }, - "ts_code,trade_time,open,close,high,low,vol,amount", - ) - points = [] - for row in sorted(rows, key=lambda item: str(item.get("trade_time") or "")): - trade_time = str(row.get("trade_time") or "") - if not trade_time: - continue - points.append( - { - "time": trade_time[-8:-3] if len(trade_time) >= 8 else trade_time, - "open": round(_number(row.get("open")), 3), - "high": round(_number(row.get("high")), 3), - "low": round(_number(row.get("low")), 3), - "close": round(_number(row.get("close")), 3), - "volume": _number(row.get("vol")), - "amount": _number(row.get("amount")), - } - ) - return {"trade_date": display_date, "points": points} - - def resolve_trade_context(self, requested: str) -> tuple[str, str]: - requested_rows = self.query( - "trade_cal", - {"exchange": "SSE", "start_date": requested, "end_date": requested}, - "cal_date,is_open,pretrade_date", - ) - if not requested_rows: - trade_date = requested - else: - row = requested_rows[0] - trade_date = row["cal_date"] if row.get("is_open") == 1 else row.get("pretrade_date", requested) - - resolved_rows = self.query( - "trade_cal", - {"exchange": "SSE", "start_date": trade_date, "end_date": trade_date}, - "cal_date,is_open,pretrade_date", - ) - previous = resolved_rows[0].get("pretrade_date") if resolved_rows else "" - return trade_date, previous or trade_date - - def _load_daily(self, trade_date: str) -> list[dict[str, Any]]: - return self.query( - "daily", - {"trade_date": trade_date}, - "ts_code,trade_date,open,high,low,close,pct_chg,amount", - ) - - def _load_limit_type(self, trade_date: str, limit_type: str) -> list[dict[str, Any]]: - fields = ( - "trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount," - "float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time," - "open_times,up_stat,limit_times" - ) - rows = self.query( - "limit_list_d", - {"trade_date": trade_date, "limit_type": limit_type}, - fields, - ) - for row in rows: - row["limit_type"] = limit_type - row["amount_unit"] = "yuan" - return rows - - def _load_limit_lists(self, trade_date: str) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for limit_type in ("U", "D", "Z"): - rows.extend(self._load_limit_type(trade_date, limit_type)) - return rows - - def _derive_limits( - self, - trade_date: str, - daily: list[dict[str, Any]], - price_limits: list[dict[str, Any]] | None = None, - basic_rows: list[dict[str, Any]] | None = None, - previous_limit_rows: list[dict[str, Any]] | None = None, - capital_rows: list[dict[str, Any]] | None = None, - ) -> list[dict[str, Any]]: - if price_limits is None: - price_limits = self.query( - "stk_limit", - {"trade_date": trade_date}, - "ts_code,trade_date,up_limit,down_limit", - ) - limit_map = {row["ts_code"]: row for row in price_limits} - if basic_rows is None: - basic_rows = self.query( - "stock_basic", - {"list_status": "L"}, - "ts_code,name,industry", - ) - basic_map = {row["ts_code"]: row for row in basic_rows} - previous_limit_map = { - str(row.get("ts_code") or ""): row for row in (previous_limit_rows or []) - } - capital_map = { - str(row.get("ts_code") or ""): row for row in (capital_rows or []) - } - - result: list[dict[str, Any]] = [] - for row in daily: - bounds = limit_map.get(row.get("ts_code")) - if not bounds or row.get("close") is None: - continue - limit_type = "" - if _prices_equal(row["close"], bounds.get("up_limit")): - limit_type = "U" - elif _prices_equal(row["close"], bounds.get("down_limit")): - limit_type = "D" - elif _prices_equal(row.get("high"), bounds.get("up_limit")): - limit_type = "Z" - if not limit_type: - continue - basic = basic_map.get(row["ts_code"], {}) - previous_limit = previous_limit_map.get(str(row.get("ts_code") or ""), {}) - streak = ( - max(1, int(_number(previous_limit.get("limit_times"), 1)) + 1) - if limit_type == "U" and previous_limit - else 1 - ) - item = { - **row, - "name": basic.get("name", "--"), - "industry": basic.get("industry") or "其他", - "limit_type": limit_type, - "limit_times": streak, - "open_times": 1 if limit_type == "Z" else 0, - "amount_unit": row.get("amount_unit") or "thousand_yuan", - } - if row.get("amount_unit") == "yuan": - capital = capital_map.get(str(row.get("ts_code") or ""), {}) - if not capital and capital_rows is None: - capital = self._latest_capital(str(row.get("ts_code") or ""), trade_date) - float_share = _number(capital.get("float_share")) - item["turnover_ratio"] = ( - _number(row.get("vol")) / float_share / 100 if float_share else 0 - ) - item["turnover_source"] = ( - "rt_volume/latest_float_share" if float_share else "unavailable" - ) - item["capital_trade_date"] = str(capital.get("trade_date") or "") - result.append(item) - return result - - @staticmethod - def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]: - amount = _number(row.get("amount")) - if row.get("amount_unit") == "thousand_yuan": - amount_billion = amount / 100000 - else: - amount_billion = amount / 100000000 - return { - "code": str(row.get("ts_code", "")).split(".")[0], - "ts_code": row.get("ts_code", ""), - "name": row.get("name") or "--", - "price": _number(row.get("close")), - "change": _number(row.get("pct_chg")), - "sector": row.get("industry") or "其他", - "reason": row.get("industry") or "待补充", - "first_time": _display_time(row.get("first_time")), - "last_time": _display_time(row.get("last_time")), - "open_times": int(_number(row.get("open_times"))), - "streak": max(1, int(_number(row.get("limit_times"), 1))), - "turnover_rate": _number(row.get("turnover_ratio")), - "turnover_source": row.get("turnover_source") or "provider", - "capital_trade_date": row.get("capital_trade_date") or "", - "amount_billion": round(amount_billion, 2), - "seal_amount_million": round(_number(row.get("fd_amount")) / 10000, 0), - "float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1), - "status": status, - } - - -def _number(value: Any, default: float = 0.0) -> float: - try: - number = float(value) - return number if math.isfinite(number) else default - except (TypeError, ValueError): - return default - - -def _text(value: Any) -> str: - if isinstance(value, (list, tuple, set)): - return "、".join(str(item).strip() for item in value if str(item).strip()) - return str(value or "").strip() - - -def _filter_members_by_listing( - members: list[dict[str, Any]], - listing_reference: dict[str, dict[str, Any]], - trade_date: str, -) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: - eligible: list[dict[str, Any]] = [] - excluded: list[dict[str, str]] = [] - for member in members: - code = str(member.get("ts_code") or "") - listing = listing_reference.get(code) - if not listing: - eligible.append(member) - continue - list_date = str(listing.get("list_date") or "") - delist_date = str(listing.get("delist_date") or "") - reason = "" - effective_date = "" - if delist_date and delist_date <= trade_date: - reason = "目标日期前已退市" - effective_date = delist_date - elif list_date and list_date > trade_date: - reason = "目标日期尚未上市" - effective_date = list_date - if not reason: - eligible.append(member) - continue - excluded.append({ - "ts_code": code, - "name": str(member.get("name") or listing.get("name") or code), - "reason": reason, - "effective_date": effective_date, - }) - return eligible, excluded - - -def _sector_coverage_issue( - member_count: int, - quote_count: int, - coverage: float | None = None, - explained_count: int | None = None, -) -> str: - members = max(0, int(member_count or 0)) - quotes = max(0, min(int(quote_count or 0), members)) - if members <= 0: - if coverage is not None and float(coverage) >= 90: - return "" - if coverage is not None: - return "行业成分行情覆盖率低于90%" - return "申万有效成分为空" - explained = quotes if explained_count is None else max( - quotes, min(int(explained_count or 0), members) - ) - actual_coverage = ( - float(coverage) - if coverage is not None - else explained / members * 100 - ) - missing = members - explained - if members <= 7 and missing: - return f"小型行业有效成分状态仅确认 {explained}/{members},要求全部可解释" - if members <= 20 and (actual_coverage < 90 or missing > 1): - return f"中型行业有效成分状态仅确认 {explained}/{members},要求覆盖率至少90%且最多缺1只" - if members > 20 and actual_coverage < 90: - return f"行业有效成分状态仅确认 {explained}/{members},覆盖率低于90%" - return "" - - -def _membership_active_on(row: dict[str, Any], trade_date: str) -> bool: - start = str(row.get("in_date") or "") - end = str(row.get("out_date") or "") - return (not start or start <= trade_date) and (not end or end > trade_date) - - -def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Merge duplicate Y/N membership rows before evaluating their date interval.""" - reconciled: dict[tuple[str, str, str, str, str], dict[str, Any]] = {} - for raw in rows: - row = dict(raw) - key = ( - str(row.get("ts_code") or ""), - str(row.get("l1_code") or ""), - str(row.get("l2_code") or ""), - str(row.get("l3_code") or ""), - str(row.get("in_date") or ""), - ) - current = reconciled.get(key) - if current is None: - reconciled[key] = row - continue - current_end = str(current.get("out_date") or "") - candidate_end = str(row.get("out_date") or "") - if candidate_end and not current_end: - current["out_date"] = candidate_end - current["is_new"] = row.get("is_new") or current.get("is_new") - for field, value in row.items(): - if not current.get(field) and value not in (None, ""): - current[field] = value - return list(reconciled.values()) - - -def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None: - if not rows: - return None - target = identifier.strip().upper() - code_match = next( - (row for row in rows if str(row.get("ts_code") or "").strip().upper() == target), - None, - ) - if code_match: - return code_match - - def normalized(value: Any) -> str: - text = str(value or "").strip().replace(" ", "") - for suffix in ("板块", "概念", "行业"): - text = text.removesuffix(suffix) - aliases = { - "元器件": "元件", - "电子元器件": "元件", - } - return aliases.get(text, text) - - target_name = normalized(identifier) - exact = [row for row in rows if normalized(row.get("name")) == target_name] - if exact: - return min(exact, key=_sector_match_priority) - fuzzy = [ - row for row in rows - if target_name and ( - target_name in normalized(row.get("name")) - or normalized(row.get("name")) in target_name - ) - ] - return min( - fuzzy, - key=lambda row: (len(normalized(row.get("name"))), *_sector_match_priority(row)), - ) if fuzzy else None - - -def _sector_match_priority(row: dict[str, Any]) -> tuple[int, int, int]: - code = str(row.get("ts_code") or "") - exchange = str(row.get("exchange") or "").upper() - return ( - 0 if exchange == "A" else 1, - 0 if code.startswith("881") else 1, - 0 if _number(row.get("count")) > 0 else 1, - ) - - -def _prices_equal(left: Any, right: Any) -> bool: - if left is None or right is None: - return False - return abs(_number(left) - _number(right)) < 0.005 - - -def _value_percentile(value: float, population: list[float]) -> float: - valid = sorted(item for item in population if item >= 0) - if not valid: - return 0.0 - below = sum(item < value for item in valid) - equal = sum(item == value for item in valid) - return (below + equal * 0.5) / len(valid) - - -def _trading_session_progress(current_time: dt_time) -> float: - morning_start = dt_time(9, 30) - morning_end = dt_time(11, 30) - afternoon_start = dt_time(13, 0) - afternoon_end = dt_time(15, 0) - if current_time <= morning_start: - return 0.05 - if current_time <= morning_end: - minutes = (current_time.hour * 60 + current_time.minute) - (9 * 60 + 30) - return max(0.05, min(0.5, minutes / 240)) - if current_time < afternoon_start: - return 0.5 - if current_time <= afternoon_end: - minutes = (current_time.hour * 60 + current_time.minute) - 13 * 60 - return max(0.5, min(1.0, 0.5 + minutes / 240)) - return 1.0 - - -def _display_time(value: Any) -> str: - raw = str(value or "").replace(":", "").zfill(6) - if not raw.strip("0"): - return "--" - return f"{raw[:2]}:{raw[2:4]}:{raw[4:6]}" - - -def _display_date(value: str) -> str: - return f"{value[:4]}-{value[4:6]}-{value[6:8]}" if len(value) == 8 else value - - -def _realtime_market_status(current_time: dt_time) -> str: - if current_time < dt_time(9, 25): - return "pre_open" - if current_time < dt_time(9, 30): - return "auction" - if current_time <= dt_time(11, 30) or dt_time(13, 0) <= current_time <= dt_time(15, 0): - return "trading" - if current_time < dt_time(13, 0): - return "lunch_break" - return "closed" - - -def _build_overview( - daily: list[dict[str, Any]], - up_rows: list[dict[str, Any]], - down_rows: list[dict[str, Any]], - broken_rows: list[dict[str, Any]], -) -> dict[str, Any]: - up_count = sum(1 for row in daily if _number(row.get("pct_chg")) > 0) - down_count = sum(1 for row in daily if _number(row.get("pct_chg")) < 0) - flat_count = len(daily) - up_count - down_count - amount_billion = sum( - _number(row.get("amount")) - / (100000000 if row.get("amount_unit") == "yuan" else 100000) - for row in daily - ) - limit_count = len(up_rows) - broken_count = len(broken_rows) - seal_rate = round(limit_count / max(limit_count + broken_count, 1) * 100, 1) - return { - "up_count": up_count, - "down_count": down_count, - "flat_count": flat_count, - "limit_up_count": limit_count, - "limit_down_count": len(down_rows), - "broken_count": broken_count, - "amount_billion": round(amount_billion, 1), - "seal_rate": seal_rate, - } - - -def _build_ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - groups: dict[int, list[dict[str, Any]]] = {} - for row in rows: - groups.setdefault(int(row.get("streak") or 1), []).append(row) - return [ - { - "level": level, - "label": "首板" if level == 1 else f"{level}板", - "count": len(stocks), - "stocks": sorted(stocks, key=lambda item: item.get("first_time") or "99:99:99"), - } - for level, stocks in sorted(groups.items(), reverse=True) - ] - - -def _build_sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - counts = Counter(row.get("sector") or "其他" for row in rows) - result: list[dict[str, Any]] = [] - for name, count in counts.most_common(20): - stocks = [row for row in rows if (row.get("sector") or "其他") == name] - max_streak = max(item.get("streak", 1) for item in stocks) - leader = max(stocks, key=lambda item: (item.get("streak", 1), item.get("amount_billion", 0))) - result.append( - { - "name": name, - "count": count, - "strength": min(100, 44 + count * 8 + max_streak * 5), - "amount_billion": round(sum(item.get("amount_billion", 0) for item in stocks), 1), - "leader": leader.get("name", "--"), - "change": round(sum(item.get("change", 0) for item in stocks) / count, 2), - "max_streak": max_streak, - } - ) - return result - - -def _build_yesterday_performance( - previous_limits: list[dict[str, Any]], - daily: list[dict[str, Any]], - current_limits: list[dict[str, Any]], - current_broken: list[dict[str, Any]], - current_down: list[dict[str, Any]], -) -> list[dict[str, Any]]: - daily_map = {str(row.get("ts_code", "")).split(".")[0]: row for row in daily} - limit_map = {row["code"]: row for row in current_limits} - broken_codes = {row["code"] for row in current_broken} - down_codes = {row["code"] for row in current_down} - result = [] - for previous in previous_limits: - code = previous["code"] - daily_row = daily_map.get(code, {}) - current = limit_map.get(code) - if current: - outcome = "晋级" - elif code in broken_codes: - outcome = "炸板" - elif code in down_codes: - outcome = "跌停" - else: - outcome = "断板" - result.append( - { - "code": code, - "name": previous["name"], - "prior_streak": previous.get("streak", 1), - "current_streak": current.get("streak", 0) if current else 0, - "current_change": _number(daily_row.get("pct_chg")), - "current_price": _number(daily_row.get("close")), - "sector": previous.get("sector", "其他"), - "reason": previous.get("reason", "待补充"), - "outcome": outcome, - } - ) - return result - - -def _build_limit_performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - result = [] - for level in sorted({int(row.get("prior_streak") or 1) for row in rows}, reverse=True): - group = [row for row in rows if int(row.get("prior_streak") or 1) == level] - advanced = sum(row.get("outcome") == "晋级" for row in group) - positive = sum(_number(row.get("current_change")) > 0 for row in group) - result.append( - { - "level": level, - "label": "昨日首板" if level == 1 else f"昨日{level}板", - "count": len(group), - "advanced": advanced, - "advance_rate": round(advanced / len(group) * 100, 1), - "positive_rate": round(positive / len(group) * 100, 1), - "average_change": round(sum(_number(row.get("current_change")) for row in group) / len(group), 2), - } - ) - return result - - -def _build_sector_rotation( - current: list[dict[str, Any]], previous: list[dict[str, Any]] -) -> list[dict[str, Any]]: - previous_map = {row["name"]: row for row in previous} - result = [] - for index, sector in enumerate(current, start=1): - previous_count = int(previous_map.get(sector["name"], {}).get("count", 0)) - delta = int(sector["count"]) - previous_count - result.append( - { - **sector, - "rank": index, - "previous_count": previous_count, - "delta": delta, - "trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平", - } - ) - return result +sys.modules[__name__] = _implementation diff --git a/docs/migration/evidence/slice-02/README.md b/docs/migration/evidence/slice-02/README.md new file mode 100644 index 0000000..6134016 --- /dev/null +++ b/docs/migration/evidence/slice-02/README.md @@ -0,0 +1,61 @@ +# 切片 02:公共行情、搜索、详情、图表与数据网关 + +> 基线:`4002f09`(切片 01) +> 回档标签:`xiaobai-preservation-slice-02-20260731` +> 结论:源码、API、数据库、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片从原版副本机械移动公共行情纵向链路,没有从 `next/` 取用代码,也没有修改计算逻辑。 + +| 原位置 | 新的唯一实现位置 | 原位置兼容 | +|---|---|---| +| `app/backend/application.py` 的 30 个总览、搜索、详情、图表方法 | `app/backend/features/market/service.py` | `DashboardService` 继承 `MarketServiceMixin` | +| `app/database.py` 的 11 个行情快照、搜索目录、同步记录方法 | `app/backend/features/market/repository.py` | `ReviewDatabase` 继承 `MarketRepositoryMixin` | +| `app/tushare_client.py` | `app/backend/data/providers/tushare_client.py` | 根模块为同一模块对象的兼容别名 | +| `app/ifind_client.py` | `app/backend/data/providers/ifind_client.py` | 根模块为同一模块对象的兼容别名 | +| `app/realtime_aggregator.py` | `app/backend/data/realtime.py` | 根模块为同一模块对象的兼容别名 | +| `app/chart_data_provider.py` | `app/backend/features/market/charts.py` | 根模块为同一模块对象的兼容别名 | + +`app/tools/move_class_methods.py` 使用 Python AST 确定方法及装饰器的源码边界,只移动原文本片段。 +该工具会在缺失方法、目标标记不唯一或源码无法解析时停止,供后续切片继续复用。 + +## 2. 等价证据 + +- `test_preservation_slice_market.py` 对 30 个业务方法和 11 个 Repository 方法逐项执行无位置信息 + AST 比较,全部与根目录原版 `server.py`、`database.py` 完全相同。 +- Tushare、iFinD 和实时观察器文件与原版 SHA-256 完全相同;图表模块全部类和函数 AST 与原版相同, + 仅内部导入改为新规范位置。 +- 四个根级兼容模块与新模块共享同一类对象,旧导入和旧 monkeypatch 路径继续有效。 +- `config/api.config.json`、API路径、鉴权角色、错误结构和数据库 schema 未修改。 +- `app/static/` 未修改;七个核心 HTML/JS/CSS 文件哈希继续与原版相同。 +- `app-light-1280x720.png` 为真实 `8785` 服务完成载入后的日间模式截图,SHA-256 为 + `a7ee1b682f68c418d792727dbc4534d7494dae4f4811cdbba208bd86dcf25d10`。 + +## 3. 真实运行检查 + +- 迁移副本:`http://127.0.0.1:8785/`,管理员登录成功。 +- 总览:返回 2026-07-30 Tushare 已缓存行情,涨停 56 只。 +- 搜索:搜索“中国平安”返回 `601318`,点击后打开完整个股详情、日 K、资金流、事件逻辑和复盘笔记。 +- 页面:1280px 视口无横向溢出,数据加载状态正常,浏览器控制台 0 个错误。 +- 分时:迁移版与原版在当前本机网络环境均返回同一个 `Intraday chart request failed`,因此记录为 + 既存外部接口状态,不是本切片差异;没有擅自增加降级或改变来源策略。 + +## 4. 自动验证 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 241 项通过 | +| `python -m unittest tests.test_preservation_slice_market -q` | 6 项通过 | +| 行情、图表、实时与数据库专项集合 | 61 项通过 | +| `npx playwright test --reporter=dot` | 45 项通过 | +| `python -m compileall -q ...` | 通过 | +| `git diff --check` | 通过 | + +## 5. 保留边界 + +- 情绪计算仍在 `application.py`,切片 03 再归位;行情服务只通过继承调用,没有复制。 +- 竞价、题材、人气、龙虎榜和问天对公共行情客户端的调用仍可通过兼容别名工作,待各自切片迁移。 +- 根级四个数据模块、`DashboardService` 和 `ReviewDatabase` 的兼容面在所有消费者完成迁移前保留。 +- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。 + diff --git a/docs/migration/evidence/slice-02/app-light-1280x720.png b/docs/migration/evidence/slice-02/app-light-1280x720.png new file mode 100644 index 0000000000000000000000000000000000000000..9fdd8ed2a085ebe1c08a1939126ed19369b06a16 GIT binary patch literal 94270 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 z
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 literal 0 HcmV?d00001 diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index b5919c8..0c1666a 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-31T00:37:00+08:00", + "updated_at": "2026-07-31T00:56:00+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,10 +9,10 @@ "failed_roots": [ "next" ], - "current_slice": "slice-02-market-search-charts-data", - "last_completed_slice": "slice-01-startup-http-accounts-system", - "last_checkpoint": "xiaobai-preservation-slice-01-20260731", - "next_action": "capture_slice-02_market_search_chart_data_contracts_then_move_original_implementations", + "current_slice": "slice-03-sentiment-pools-performance", + "last_completed_slice": "slice-02-market-search-charts-data", + "last_checkpoint": "xiaobai-preservation-slice-02-20260731", + "next_action": "capture_slice-03_sentiment_pool_performance_contracts_then_move_original_implementations", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index 351223e..c244517 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片01“启动、HTTP、账号、会员与系统管理”已完成 +> 当前状态:正式迁移,切片02“公共行情、搜索、详情、图表与数据网关”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -21,6 +21,7 @@ | 2026-07-30 | `xiaobai-preservation-migration-charter-20260730` | 建立保真迁移总纲、状态和恢复协议 | 尚未开始新迁移 | | 2026-07-30 | `41329943c4878fc09ed82ec376eb93ab151e4092` | 完成只读资产清查并由用户批准`app/`结构 | 开始切片00 | | 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 | +| 2026-07-31 | `xiaobai-preservation-slice-02-20260731` | 公共行情、搜索、详情、图表与数据适配原实现归位 | 自动与浏览器差分通过,进入切片03 | ## 资产处置登记 @@ -61,6 +62,17 @@ - 回档:标签`xiaobai-preservation-slice-01-20260731`。 - 完整证据:`docs/migration/evidence/slice-01/README.md`。 +已完成切片:`slice-02-market-search-charts-data`。 + +- 原版基线:提交`4002f09`,即切片01回档点。 +- 迁移范围:30个公共行情服务方法、11个行情持久化方法及Tushare/iFinD/图表/实时观察实现。 +- 兼容边界:四个根级数据模块保留模块别名;未迁移功能继续使用旧导入且指向同一实现。 +- 等价证明:41个方法AST逐项一致,三个数据文件哈希一致,图表定义AST一致,静态资产哈希一致。 +- 验收:241项Python测试、6项切片源码等价测试、45项Playwright测试及真实服务搜索/详情流程通过。 +- 既存状态:原版和迁移版的实时分时在当前环境均返回相同外部请求失败,不作为迁移回归处理。 +- 回档:标签`xiaobai-preservation-slice-02-20260731`。 +- 完整证据:`docs/migration/evidence/slice-02/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 |