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, timedelta from threading import Lock from typing import Any, ClassVar from 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 today = datetime.now().astimezone().strftime("%Y%m%d") if compact_end == today: 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")) if latest > 0: realtime = { "trade_date": end.strftime("%Y-%m-%d"), "open": _number(quote.get("open")) or latest, "high": _number(quote.get("high")) or latest, "low": _number(quote.get("low")) or latest, "close": latest, "change": round((latest / previous - 1) * 100, 4) if previous else 0.0, "volume": _number(quote.get("volume")), "amount_billion": _number(quote.get("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)