This commit is contained in:
2026-09-07 18:07:31 +08:00
parent 359f9ae2e4
commit bc1c72d558
27 changed files with 4532 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
"""东方财富 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"]