提交
This commit is contained in:
109
backend/app/data/etf_provider.py
Normal file
109
backend/app/data/etf_provider.py
Normal 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"]
|
||||
Reference in New Issue
Block a user