"""东方财富分钟K线数据客户端 通过 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 logger = logging.getLogger(__name__) # 东方财富分钟K线接口 EASTMONEY_KLINE_URL = "http://push2his.eastmoney.com/api/qt/stock/kline/get" HEADERS = { "Referer": "http://finance.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_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, ) data = resp.json() 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}") return pd.DataFrame() 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