up
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
"""东方财富 API 客户端(资金流向数据)"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from services.cache import get_cache, set_cache
|
||||
|
||||
# ---- API Key 轮询 ----
|
||||
|
||||
_api_keys: List[str] = []
|
||||
_key_index: int = -1
|
||||
|
||||
|
||||
def _load_api_keys() -> List[str]:
|
||||
"""收集所有可用 MX API key(支持逗号分隔和 MX_APIKEY_1 编号后缀)"""
|
||||
keys: List[str] = []
|
||||
seen: set = set()
|
||||
def add(k: str):
|
||||
k = k.strip()
|
||||
if k and k not in seen:
|
||||
seen.add(k)
|
||||
keys.append(k)
|
||||
raw = os.environ.get("MX_APIKEY", "")
|
||||
if raw:
|
||||
for k in raw.split(","):
|
||||
add(k)
|
||||
for i in range(1, 10):
|
||||
raw = os.environ.get(f"MX_APIKEY_{i}", "")
|
||||
if raw:
|
||||
for k in raw.split(","):
|
||||
add(k)
|
||||
return keys
|
||||
|
||||
|
||||
def _ensure_keys() -> bool:
|
||||
global _api_keys, _key_index
|
||||
if not _api_keys:
|
||||
_api_keys = _load_api_keys()
|
||||
if _key_index == -1:
|
||||
val = get_cache("_mx_key_index")
|
||||
_key_index = int(val) if val else 0
|
||||
return bool(_api_keys)
|
||||
|
||||
|
||||
def _get_key() -> str:
|
||||
"""获取当前使用的 API key"""
|
||||
if not _ensure_keys():
|
||||
return ""
|
||||
return _api_keys[_key_index % len(_api_keys)]
|
||||
|
||||
|
||||
def _rotate_key():
|
||||
"""切换到下一个 key 并持久化当前索引"""
|
||||
global _key_index
|
||||
_key_index += 1
|
||||
set_cache("_mx_key_index", str(_key_index), ttl_hours=24)
|
||||
|
||||
|
||||
# ---- 工具函数 ----
|
||||
|
||||
|
||||
def get_eastmoney_market(code: str) -> str:
|
||||
"""获取东方财富格式的市场标识"""
|
||||
if code.startswith("688"):
|
||||
return "6"
|
||||
if code.startswith("60"):
|
||||
return "1"
|
||||
return "0"
|
||||
|
||||
|
||||
def parse_amount(text) -> float:
|
||||
"""解析金额文本(支持 1.432亿元, -9915万元, 0元)"""
|
||||
if isinstance(text, (int, float)):
|
||||
return float(text)
|
||||
if not text or not isinstance(text, str):
|
||||
return 0
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0
|
||||
sign = -1 if text.startswith("-") else 1
|
||||
clean = text.lstrip("+-").strip()
|
||||
m = re.match(r"^([\d.]+)\s*(亿|万|元)?$", clean)
|
||||
if not m:
|
||||
return 0
|
||||
value = float(m.group(1)) if m.group(1) else 0
|
||||
unit = m.group(2) or "元"
|
||||
if unit == "亿":
|
||||
return sign * value * 100000000
|
||||
elif unit == "万":
|
||||
return sign * value * 10000
|
||||
return sign * value
|
||||
|
||||
|
||||
# ---- MX API ----
|
||||
|
||||
|
||||
async def _call_mx_api(api_key: str, name: str, days: int) -> Optional[dict]:
|
||||
"""执行一次 MX API 调用,返回原始 JSON"""
|
||||
url = "https://mkapi2.dfcfs.com/finskillshub/api/claw/query"
|
||||
payload = {"toolQuery": f"{name}最近{days}天主力资金流向和成交额"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"apikey": api_key,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] MX API error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mx_response(result: dict) -> Optional[List[dict]]:
|
||||
"""解析 MX API 返回,提取资金流向数据列表"""
|
||||
if result.get("status") != 0:
|
||||
return None
|
||||
dto_list = result.get("data", {}).get("data", {}).get("searchDataResultDTO", {}).get("dataTableDTOList", [])
|
||||
if not dto_list:
|
||||
return None
|
||||
dto = dto_list[0]
|
||||
name_map = dto.get("nameMap", {})
|
||||
raw_table = dto.get("rawTable", {})
|
||||
table = dto.get("table", {})
|
||||
dates = table.get("headName", [])
|
||||
if not dates:
|
||||
return None
|
||||
|
||||
name_to_id = {}
|
||||
for kid, v in name_map.items():
|
||||
if isinstance(v, str):
|
||||
name_to_id[v] = kid
|
||||
|
||||
main_net_col = name_to_id.get("(区间)主力净流入资金")
|
||||
amount_col = name_to_id.get("区间成交额")
|
||||
data_list = []
|
||||
|
||||
for i, date_str in enumerate(dates):
|
||||
date_str = str(date_str).strip()
|
||||
m1 = re.match(r"^(\d{4})[/-](\d{1,2})[/-](\d{1,2})", date_str)
|
||||
if m1:
|
||||
date_str = f"{m1.group(1)}-{int(m1.group(2)):02d}-{int(m1.group(3)):02d}"
|
||||
else:
|
||||
m2 = re.match(r"^(\d{1,2})[/-](\d{1,2})", date_str)
|
||||
if m2:
|
||||
year = datetime.now().year
|
||||
date_str = f"{year}-{int(m2.group(1)):02d}-{int(m2.group(2)):02d}"
|
||||
else:
|
||||
continue
|
||||
|
||||
main_net = 0
|
||||
if main_net_col and raw_table.get(main_net_col) and i < len(raw_table[main_net_col]):
|
||||
main_net = parse_amount(raw_table[main_net_col][i])
|
||||
|
||||
amount = 0
|
||||
if amount_col and raw_table.get(amount_col) and i < len(raw_table[amount_col]):
|
||||
amount = parse_amount(raw_table[amount_col][i])
|
||||
|
||||
data_list.append({"date": date_str, "mainNetInflow": main_net, "amount": amount})
|
||||
|
||||
return data_list
|
||||
|
||||
|
||||
async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]:
|
||||
"""调用东方财富妙想MX API获取资金流向(缓存6小时,多key轮询)"""
|
||||
cache_key = f"mx_fund_flow:{name}:{days}"
|
||||
|
||||
# 缓存命中
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
# 多 key 轮询:按序尝试,遇到 113(超限) 自动切下一个 key
|
||||
if not _ensure_keys():
|
||||
return None
|
||||
|
||||
for attempt in range(len(_api_keys)):
|
||||
key = _get_key()
|
||||
result = await _call_mx_api(key, name, days)
|
||||
if result is None:
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
status = result.get("status", -1)
|
||||
if status == 113:
|
||||
print(f"[eastmoney] key {_key_index % len(_api_keys)} 已达每日上限,切换到下一个")
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
if status == 114:
|
||||
print(f"[eastmoney] key {_key_index % len(_api_keys)} 无效(114),跳过")
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
if status != 0:
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
# 成功
|
||||
data_list = _parse_mx_response(result)
|
||||
if data_list:
|
||||
set_cache(cache_key, json.dumps(data_list, ensure_ascii=False))
|
||||
return data_list
|
||||
|
||||
print(f"[eastmoney] 所有 {len(_api_keys)} 个 MX API key 均已耗尽")
|
||||
return None
|
||||
|
||||
|
||||
# ---- 板块数据 ----
|
||||
|
||||
# 东方财富板块类型映射
|
||||
SECTOR_TYPE_MAP = {
|
||||
"industry": "m:90+t:2", # 行业板块
|
||||
"concept": "m:90+t:3", # 概念板块
|
||||
}
|
||||
|
||||
# 板块列表字段:f12=代码, f14=名称, f3=涨跌幅%, f62=主力净流入, f184=主力净流入占比
|
||||
# f66=超大单净流入, f69=超大单净流入占比, f70=成交额, f78=小单净流入
|
||||
SECTOR_FIELDS = "f12,f14,f2,f3,f4,f62,f184,f66,f69,f70,f78"
|
||||
|
||||
|
||||
# ---- 板块数据(通过 akshare 调用同花顺数据源)----
|
||||
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
|
||||
|
||||
async def fetch_sector_list(sector_type: str) -> list[dict]:
|
||||
"""
|
||||
获取板块资金流向数据
|
||||
sector_type: "industry" 或 "concept"
|
||||
返回按主力净流入降序排列的板块列表
|
||||
编码优先东方财富(BKxxxx),降级同花顺(6位数字)
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _get_data():
|
||||
# 1) 板块编码映射:东方财富 BK 编码 → 降级同花顺编码
|
||||
code_map = {}
|
||||
try:
|
||||
if sector_type == "industry":
|
||||
code_df = ak.stock_board_industry_name_em()
|
||||
else:
|
||||
code_df = ak.stock_board_concept_name_em()
|
||||
if code_df is not None and not code_df.empty:
|
||||
for _, r in code_df.iterrows():
|
||||
code_map[str(r.get("f14", ""))] = str(r.get("f12", ""))
|
||||
except Exception:
|
||||
try:
|
||||
if sector_type == "industry":
|
||||
code_df = ak.stock_board_industry_name_ths()
|
||||
else:
|
||||
code_df = ak.stock_board_concept_name_ths()
|
||||
if code_df is not None and not code_df.empty:
|
||||
for _, r in code_df.iterrows():
|
||||
code_map[str(r.get("name", ""))] = str(r.get("code", ""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) 资金流向(10jqka 同花顺数据源)
|
||||
if sector_type == "industry":
|
||||
df = ak.stock_fund_flow_industry()
|
||||
else:
|
||||
df = ak.stock_fund_flow_concept()
|
||||
|
||||
return code_map, df
|
||||
|
||||
try:
|
||||
code_map, df = await loop.run_in_executor(None, _get_data)
|
||||
|
||||
if df is None or df.empty:
|
||||
return []
|
||||
|
||||
df = df.sort_values("净额", ascending=False)
|
||||
|
||||
items = []
|
||||
for _, row in df.iterrows():
|
||||
name = str(row.get("行业", "")).strip()
|
||||
items.append({
|
||||
"code": code_map.get(name, ""),
|
||||
"name": name,
|
||||
"level": _safe_float(row.get("行业指数")),
|
||||
"changePercent": _safe_float(row.get("行业-涨跌幅")),
|
||||
"changeAmount": None,
|
||||
"mainNetInflow": _safe_float(row.get("净额", 0)) * 100000000, # 亿→元
|
||||
"mainNetInflowPercent": None,
|
||||
"superLargeInflow": None,
|
||||
"superLargeInflowPercent": None,
|
||||
"turnover": _safe_float(row.get("流入资金", 0)) * 100000000 + _safe_float(row.get("流出资金", 0)) * 100000000,
|
||||
"smallNetInflow": None,
|
||||
})
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] 获取{sector_type}板块失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _safe_float(val) -> float:
|
||||
if val is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
async def fetch_sector_list_direct(sector_type: str) -> list[dict]:
|
||||
"""
|
||||
直接从东方财富 push2 API 获取板块列表(备用,当 akshare 不可用时)
|
||||
"""
|
||||
fs = SECTOR_TYPE_MAP.get(sector_type)
|
||||
if not fs:
|
||||
return []
|
||||
|
||||
url = (
|
||||
f"https://push2.eastmoney.com/api/qt/clist/get"
|
||||
f"?fs={fs}&fields={SECTOR_FIELDS}"
|
||||
f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2"
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://quote.eastmoney.com/",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
result = resp.json()
|
||||
if result.get("rc") != 0:
|
||||
return []
|
||||
diff = result.get("data", {}).get("diff", [])
|
||||
items = []
|
||||
for item in diff:
|
||||
items.append({
|
||||
"code": item.get("f12", ""),
|
||||
"name": item.get("f14", ""),
|
||||
"level": item.get("f2"),
|
||||
"changePercent": item.get("f3"),
|
||||
"changeAmount": item.get("f4"),
|
||||
"mainNetInflow": item.get("f62", 0),
|
||||
"mainNetInflowPercent": item.get("f184", 0),
|
||||
"superLargeInflow": item.get("f66", 0),
|
||||
"superLargeInflowPercent": item.get("f69", 0),
|
||||
"turnover": item.get("f70", 0),
|
||||
"smallNetInflow": item.get("f78", 0),
|
||||
})
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] 获取{sector_type}板块失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_push2his(code: str, days: int) -> Optional[list]:
|
||||
"""回退到东方财富 push2his 接口(自动重试一次)"""
|
||||
market = get_eastmoney_market(code)
|
||||
secid = f"{market}.{code}"
|
||||
url = (
|
||||
f"https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get"
|
||||
f"?lmt={days}&fields1=f1,f2,f3,f7"
|
||||
f"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63,f64,f65,f66,f67,f68,f69"
|
||||
f"&ut=b2884a393a59ad64002292a3e90d46a5&secid={secid}"
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://quote.eastmoney.com/",
|
||||
}
|
||||
|
||||
for attempt in range(2):
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
if attempt == 0:
|
||||
continue
|
||||
print(f"[eastmoney] push2his HTTP {resp.status_code}")
|
||||
return None
|
||||
result = resp.json()
|
||||
klines = result.get("data", {}).get("klines", [])
|
||||
if not klines:
|
||||
if attempt == 0:
|
||||
continue
|
||||
print(f"[eastmoney] push2his no klines in response")
|
||||
return None
|
||||
return klines
|
||||
except Exception as e:
|
||||
if attempt == 0:
|
||||
continue
|
||||
print(f"[eastmoney] push2his error: {e}")
|
||||
return None
|
||||
return None
|
||||
Reference in New Issue
Block a user