"""东方财富 ETF 快照(免 token 直连 HTTP,仅用于 etf_basic 列表与规模字段)。 K 线数据不走这里:统一走 Tushare fund_daily(见 etf_sync / fetcher), 东财在链路里只承担一件 Tushare quicksync 镜像做不到的事—— 全市场 ETF 名单 + 总市值/流通市值/换手率(镜像上 fund_etf_basic 不存在)。 接口注意:clist 实测 pz 上限 100(传 50000 也只回 100),必须按 pn 翻页 拿全 ~1600 只;push2 主站短连发几次会直接断连,push2delay 镜像稳, 列表/市值用延迟值无妨(价格另有 K 线)。 """ from __future__ import annotations import asyncio import httpx from .symbols import is_etf_symbol _HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", } # 东财 ETF 板块(akshare fund_etf_spot_em 同款集合:股票/混合/债券/货币/商品/跨境等) _SPOT_HOSTS = [ "https://push2delay.eastmoney.com", "https://push2.eastmoney.com", ] _SPOT_PARAMS = { "pn": "1", "pz": "100", "po": "1", "np": "1", "ut": "bd1d9ddb04089700cf9c27f6f7426281", "fltt": "2", "invt": "2", "fid": "f12", "fs": "b:MK0021,b:MK0022,b:MK0023,b:MK0024,b:MK0026,b:MK0027,b:MK0028,b:MK0029", # f12 代码 / f13 市场(1沪0深) / f14 名称 / f8 换手% / f20 总市值 / f21 流通市值(元) "fields": "f12,f13,f14,f8,f20,f21", } def _num(v) -> float | None: """东财 fltt=2 下停牌/缺数据的字段是 '-' 字符串。""" if v is None or isinstance(v, str): return None f = float(v) return None if f != f else f def new_client() -> httpx.AsyncClient: """统一构造(超时/UA);同步工厂,`async with new_client() as c` 使用。""" return httpx.AsyncClient(timeout=httpx.Timeout(15.0), headers=_HEADERS) async def _fetch_spot_page(client: httpx.AsyncClient, host: str, pn: int): params = {**_SPOT_PARAMS, "pn": str(pn)} resp = await client.get(f"{host}/api/qt/clist/get", params=params) resp.raise_for_status() data = resp.json().get("data") or {} return data.get("total") or 0, data.get("diff") or [] async def fetch_etf_spot(client: httpx.AsyncClient) -> list[dict]: """全市场场内 ETF 快照(翻页拿全 ~1600 只)-> [{ts_code, symbol, name, exchange, turnover_rate, total_mv, circ_mv}]。空结果视为异常(接口改版/被拦截时宁可不覆盖表)。""" diff: list[dict] = [] for host in _SPOT_HOSTS: try: total, first = await _fetch_spot_page(client, host, 1) diff = first pn = 2 while total and len(diff) < total: await asyncio.sleep(0.15) # 翻页间隔,礼貌控频 _, page = await _fetch_spot_page(client, host, pn) if not page: break diff.extend(page) pn += 1 break # 首个可用 host 拿完即止 except Exception: # noqa: BLE001 —— 主镜像抖动换备用镜像整重来 diff = [] continue rows: list[dict] = [] for d in diff: code = str(d.get("f12") or "").strip() name = str(d.get("f14") or "").strip() market = d.get("f13") if not code or not name or market is None: continue if not is_etf_symbol(code): continue # 板块返回里混进的 LOF/封基(16/50/57 开头)不进 ETF 表 exchange = "SH" if int(market) == 1 else "SZ" rows.append({ "ts_code": f"{code}.{exchange}", "symbol": code, "name": name, "exchange": exchange, "turnover_rate": _num(d.get("f8")), "total_mv": _num(d.get("f20")), "circ_mv": _num(d.get("f21")), }) if not rows: raise RuntimeError("东财 ETF 快照为空(接口可能改版或被限流)") return rows __all__ = ["fetch_etf_spot", "new_client"]