"""东方财富数据客户端 通过 push2.eastmoney.com / push2his.eastmoney.com 获取实时数据: - 板块实时涨跌幅排名(行业/概念板块) - A 股分钟级 K 线数据(盘中量能分布分析) 免费接口,无需认证,注意限频。 """ import logging import httpx import pandas as pd from datetime import datetime from app.data.cache import cache from app.config import settings from app.db.error_logger import log_error logger = logging.getLogger(__name__) # 东方财富接口 EASTMONEY_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get" SECTOR_LIST_URL = "https://push2.eastmoney.com/api/qt/clist/get" HEADERS = { "Referer": "https://finance.eastmoney.com", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", } SECTOR_HEADERS = { "Referer": "https://data.eastmoney.com", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", } def _ts_code_to_eastmoney(ts_code: str) -> str: """600519.SH -> 1.600519 (上海=1, 深圳=0)""" code, market = ts_code.split(".") prefix = "1" if market == "SH" else "0" return f"{prefix}.{code}" async def get_sector_realtime_ranking( fs: str = "m:90+t:2", sort_by: str = "f3", descending: bool = True, page_size: int = 100, ) -> list[dict]: """获取东方财富板块实时涨跌幅排名 一次请求获取所有板块的实时数据,盘中和盘后均可使用。 替代通过腾讯批量获取成分股行情再手动计算的方式。 Args: fs: 板块类型 - "m:90+t:2": 行业板块(东方财富分类,约496个) - "m:90+t:3": 概念板块(约493个) - "m:90+t:1": 地域板块(约31个) sort_by: 排序字段,默认 f3=涨跌幅 descending: True=降序(涨幅从高到低) page_size: 返回板块数量 Returns: list[dict],每个 dict 包含: - sector_code: 东方财富板块代码 (如 "BK0428") - sector_name: 板块名称 (如 "酿酒行业") - pct_change: 实时涨跌幅 % - amount: 成交额(元) - turnover_rate: 换手率 % - up_count: 上涨家数 - down_count: 下跌家数 - leading_stock_name: 领涨股名称 (f128) - leading_stock_code: 领涨股代码 (f140) - leading_stock_pct: 领涨股涨跌幅 % (f141) """ cache_key = f"sector_rt:{fs}:{sort_by}" cached = cache.get(cache_key) if cached is not None: return cached params = { "pn": "1", "pz": str(page_size), "po": "0" if descending else "1", "np": "1", "ut": "b1f8f8f8", "fltt": "2", "invt": "2", "fid": sort_by, "fs": fs, "fields": "f2,f3,f4,f6,f8,f12,f14,f104,f105,f128,f140,f141", } try: async with httpx.AsyncClient() as client: resp = await client.get( SECTOR_LIST_URL, params=params, headers=SECTOR_HEADERS, timeout=10, follow_redirects=True, ) data = _parse_eastmoney_json(resp, "板块实时排名") items = data.get("data", {}).get("diff", []) if not items: logger.debug("东方财富板块实时排名无数据") return [] result = [] for item in items: # f3 可能是 "-"(停牌等异常情况) pct = item.get("f3") if pct == "-" or pct is None: pct = 0.0 result.append({ "sector_code": item.get("f12", ""), "sector_name": item.get("f14", ""), "pct_change": float(pct), "amount": float(item.get("f6", 0) or 0), "turnover_rate": float(item.get("f8", 0) or 0), "up_count": int(item.get("f104", 0) or 0), "down_count": int(item.get("f105", 0) or 0), "leading_stock_name": item.get("f128", ""), "leading_stock_code": item.get("f140", ""), "leading_stock_pct": float(item.get("f141", 0) or 0), }) # 缓存:盘中 60 秒,盘后 300 秒 ttl = 60 if _is_trading_hours() else 300 cache.set(cache_key, result, ttl) logger.info(f"东方财富板块实时排名: 获取 {len(result)} 个板块") return result except Exception as e: logger.error(f"东方财富板块实时排名获取失败: {e}") await log_error( "eastmoney", f"东方财富板块实时排名获取失败: {e}", detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}", ) return [] async def get_min_kline( ts_code: str, period: str = "5", count: int = 48, ) -> pd.DataFrame: """获取分钟级 K 线数据 Args: ts_code: Tushare 格式代码,如 600519.SH period: 分钟周期 ("1", "5", "15", "30", "60") count: 请求条数(5分钟×48=4小时≈一个交易日) Returns: DataFrame with columns: time, open, close, high, low, volume, amount 空 DataFrame if failed """ cache_key = f"min_kline:{ts_code}:{period}" cached = cache.get(cache_key) if cached is not None: return cached secid = _ts_code_to_eastmoney(ts_code) params = { "secid": secid, "fields1": "f1,f2,f3,f4,f5,f6", "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61", "klt": period, # 分钟周期 "fqt": "1", # 前复权 "beg": "0", "end": "20500101", "lmt": str(count), # 返回条数限制 } try: async with httpx.AsyncClient() as client: resp = await client.get( EASTMONEY_KLINE_URL, params=params, headers=HEADERS, timeout=10, follow_redirects=True, ) data = _parse_eastmoney_json(resp, f"分钟K线 {ts_code}") klines = data.get("data", {}).get("klines", []) if not klines: logger.debug(f"东方财富分钟K线 {ts_code} 无数据") return pd.DataFrame() # 解析:格式 "2026-04-15 09:30,5.71,5.73,5.74,5.70,12345,70000.00" rows = [] for line in klines: parts = line.split(",") if len(parts) < 7: continue rows.append({ "time": parts[0], "open": float(parts[1]), "close": float(parts[2]), "high": float(parts[3]), "low": float(parts[4]), "volume": float(parts[5]), "amount": float(parts[6]), }) df = pd.DataFrame(rows) if df.empty: return df # 缓存(分钟数据盘中3分钟过期,盘后30分钟过期) ttl = 180 if _is_trading_hours() else 1800 cache.set(cache_key, df, ttl) return df except Exception as e: logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}") await log_error( "eastmoney", f"东方财富分钟K线获取失败 {ts_code}: {e}", detail=f"period={period}, count={count}", ) return pd.DataFrame() def _parse_eastmoney_json(resp: httpx.Response, label: str) -> dict: """解析东方财富 JSON 响应,遇到 302/HTML 等非 JSON 情况给出更清晰日志。""" resp.raise_for_status() content_type = resp.headers.get("content-type", "") text_preview = (resp.text or "")[:160].replace("\n", " ").replace("\r", " ") if "json" not in content_type.lower() and not resp.text.strip().startswith("{"): raise ValueError( f"{label} 返回非JSON响应(status={resp.status_code}, content_type={content_type}, body={text_preview})" ) try: return resp.json() except Exception as e: raise ValueError( f"{label} JSON解析失败(status={resp.status_code}, content_type={content_type}, body={text_preview})" ) from e def analyze_intraday_volume_distribution(min_df: pd.DataFrame) -> dict: """分析盘中量能分布(基于5分钟K线) Returns: { "morning_volume_ratio": float, # 上午成交额占比 "afternoon_volume_ratio": float, # 下午成交额占比 "opening_strength": float, # 开盘30分钟成交额/全天占比 "closing_strength": float, # 尾盘30分钟成交额/全天占比 "volume_trend": str, # "morning_dominant" / "afternoon_dominant" / "balanced" "key_periods": list[str], # 关键放量时段描述 } """ if min_df.empty or len(min_df) < 4: return { "morning_volume_ratio": 0.5, "afternoon_volume_ratio": 0.5, "opening_strength": 0.25, "closing_strength": 0.25, "volume_trend": "balanced", "key_periods": [], } total_amount = min_df["amount"].sum() if total_amount == 0: return { "morning_volume_ratio": 0.5, "afternoon_volume_ratio": 0.5, "opening_strength": 0.25, "closing_strength": 0.25, "volume_trend": "balanced", "key_periods": [], } # 按时间段分割 morning = min_df[min_df["time"].str.contains("09:|10:|11:0", na=False)] afternoon = min_df[min_df["time"].str.contains("13:|14:0|14:1|14:2|14:3|14:4|14:5", na=False)] morning_ratio = morning["amount"].sum() / total_amount if not morning.empty else 0.5 afternoon_ratio = afternoon["amount"].sum() / total_amount if not afternoon.empty else 0.5 # 开盘30分钟(9:30-10:00) opening_30 = min_df[min_df["time"].str.contains("09:3|09:4|09:5|10:0", na=False)] opening_strength = opening_30["amount"].sum() / total_amount if not opening_30.empty else 0.25 # 尾盘30分钟(14:30-15:00) closing_30 = min_df[min_df["time"].str.contains("14:3|14:4|14:5|15:0", na=False)] closing_strength = closing_30["amount"].sum() / total_amount if not closing_30.empty else 0.25 # 量能趋势判断 if morning_ratio > 0.6: volume_trend = "morning_dominant" elif afternoon_ratio > 0.6: volume_trend = "afternoon_dominant" else: volume_trend = "balanced" # 关键放量时段 key_periods = [] avg_amount = min_df["amount"].mean() hot_bars = min_df[min_df["amount"] > avg_amount * 2] for _, bar in hot_bars.iterrows(): time_str = bar["time"].split(" ")[1] if " " in bar["time"] else bar["time"] pct = bar["amount"] / total_amount * 100 key_periods.append(f"{time_str}放量(占比{pct:.0f}%)") return { "morning_volume_ratio": round(morning_ratio, 2), "afternoon_volume_ratio": round(afternoon_ratio, 2), "opening_strength": round(opening_strength, 2), "closing_strength": round(closing_strength, 2), "volume_trend": volume_trend, "key_periods": key_periods[:3], } def _is_trading_hours() -> bool: """简单判断是否在交易时间""" now = datetime.now() if now.weekday() >= 5: # 周六周日 return False hour = now.hour minute = now.minute # 9:30 - 11:30 或 13:00 - 15:00 if (hour == 9 and minute >= 30) or (hour == 10) or (hour == 11 and minute <= 30): return True if (hour == 13) or (hour == 14) or (hour == 15 and minute == 0): return True return False