"""盘中实时扫描 盘中 Tushare 的 daily / moneyflow / limit_list_d 等接口尚无当日数据, 因此盘中扫描采用混合策略: - 板块热度:使用前一日 Tushare 数据作为基线(哪些板块是近期热点) - 个股筛选:用腾讯实时行情替代 Tushare 的当日 daily_basic / moneyflow - 技术面:历史 K 线(Tushare) + 当日实时价格(腾讯)拼接后计算 盘中重点捕捉: 1. 热门板块成分股中,盘中放量拉升的个股 2. 量比 > 1.5、换手率适中、涨幅在 2%-7% 的活跃股 3. 用实时价格更新支撑/压力位和技术信号 """ import logging import httpx import pandas as pd from datetime import datetime from app.data.tushare_client import tushare_client from app.data import tencent_client from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS, _parse_eastmoney_json from app.analysis.sector_scanner import scan_hot_sectors from app.analysis.technical import add_all_indicators from app.analysis.signals import generate_signals from app.analysis.capital_flow import _score_valuation from app.config import settings logger = logging.getLogger(__name__) async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTemperature: """盘中市场温度:用腾讯实时行情计算涨跌数据 + 东方财富统计涨停跌停""" index_data = await tencent_client.get_index_realtime() sh_index = index_data.get("000001.SH") if not sh_index: return prev_temp # ── 用腾讯实时行情计算涨跌数量 ── up_count = prev_temp.up_count down_count = prev_temp.down_count limit_up_count = prev_temp.limit_up_count limit_down_count = prev_temp.limit_down_count try: stock_basic = tushare_client.get_stock_basic() if not stock_basic.empty: all_codes = stock_basic[~stock_basic["name"].str.contains("ST", na=False)]["ts_code"].tolist() if all_codes: quotes = await tencent_client.get_realtime_quotes_batch(all_codes) up_count = sum(1 for q in quotes.values() if q.pct_chg > 0) down_count = sum(1 for q in quotes.values() if q.pct_chg < 0) logger.info( f"盘中实时涨跌统计: 上涨={up_count} 下跌={down_count} (共{len(quotes)}只)" ) except Exception as e: logger.warning(f"获取盘中实时涨跌统计失败,使用上一日数据: {e}") # ── 用东方财富 clist API 统计涨停跌停(比腾讯涨停价字段更可靠) ── try: limit_up_count = 0 limit_down_count = 0 for fs, threshold in [ ("m:0+t:6,m:0+t:80,m:0+t:81+s:2048", 9.9), # 主板 10% ("m:1+t:2,m:1+t:23", 19.9), # 创业板/科创板 20% ]: async with httpx.AsyncClient(follow_redirects=True) as client: # 涨停:按涨幅降序取 top 200 params_up = { "pn": "1", "pz": "200", "po": "1", "np": "1", "ut": "b1f8f8f8", "fltt": "2", "invt": "2", "fid": "f3", "fs": fs, "fields": "f3,f12,f14", } resp = await client.get(SECTOR_LIST_URL, params=params_up, headers=SECTOR_HEADERS, timeout=10) data_up = _parse_eastmoney_json(resp, "涨停统计") items = data_up.get("data", {}).get("diff", []) if data_up.get("data") else [] for item in items: pct = item.get("f3") if pct == "-" or pct is None: continue if float(pct) >= threshold: limit_up_count += 1 # 跌停:按涨幅升序取 top 200 params_down = { "pn": "1", "pz": "200", "po": "0", "np": "1", "ut": "b1f8f8f8", "fltt": "2", "invt": "2", "fid": "f3", "fs": fs, "fields": "f3,f12,f14", } resp_down = await client.get(SECTOR_LIST_URL, params=params_down, headers=SECTOR_HEADERS, timeout=10) data_down = _parse_eastmoney_json(resp_down, "跌停统计") items_down = data_down.get("data", {}).get("diff", []) if data_down.get("data") else [] neg_threshold = -threshold for item in items_down: pct = item.get("f3") if pct == "-" or pct is None: continue if float(pct) <= neg_threshold: limit_down_count += 1 logger.info(f"东方财富盘中涨跌停: 涨停={limit_up_count} 跌停={limit_down_count}") except Exception as e: logger.warning(f"东方财富涨跌停统计失败,使用基线数据: {e}") # ── 温度分数:基于实时涨跌比重新计算 ── ratio = up_count / max(down_count, 1) temp_from_ratio = min(ratio / 3.0 * 25, 25) # 涨跌比维度 (0-25) temp_from_limit_up = min(limit_up_count / 2, 25) # 涨停数维度 (0-25) # 指数方向调整 (0-25) pct = sh_index.get("pct_chg", 0) temp_from_index = min(max(pct * 8 + 12.5, 0), 25) new_temp = round(temp_from_ratio + temp_from_limit_up + temp_from_index + 25, 1) new_temp = min(max(new_temp, 0), 100) return MarketTemperature( trade_date=datetime.now().strftime("%Y%m%d"), up_count=up_count, down_count=down_count, limit_up_count=limit_up_count, limit_down_count=limit_down_count, max_streak=prev_temp.max_streak, broken_rate=prev_temp.broken_rate, index_above_ma20=pct > 0 if sh_index else prev_temp.index_above_ma20, temperature=new_temp, ) async def intraday_filter_stocks( hot_sectors: list[SectorInfo], ) -> list[dict]: """盘中个股筛选:从热门板块成分股中用腾讯实时行情筛选 替代 capital_flow.py 中依赖 Tushare 日级数据的逻辑。 盘中无法获取资金流向,改用 量比 + 换手率 + 涨幅 + 市值 筛选。 """ # 收集热门板块成分股 stock_sectors: dict[str, list[str]] = {} for sector in hot_sectors: members = tushare_client.get_ths_members(sector.sector_code) if members.empty or "con_code" not in members.columns: continue for _, m in members.iterrows(): code = m.get("con_code", "") if not code or "." not in str(code): continue if code not in stock_sectors: stock_sectors[code] = [] stock_sectors[code].append(sector.sector_name) if not stock_sectors: logger.warning("盘中筛选: 热门板块成分股为空") return [] all_codes = list(stock_sectors.keys()) logger.info(f"盘中筛选: {len(all_codes)} 只成分股,开始获取实时行情") # 过滤 ST 和次新 stock_basic = tushare_client.get_stock_basic() st_codes = set() new_codes = set() if not stock_basic.empty: st_codes = set(stock_basic[stock_basic["name"].str.contains("ST", na=False)]["ts_code"]) from datetime import timedelta cutoff = (datetime.now() - timedelta(days=settings.min_list_days)).strftime("%Y%m%d") new_codes = set(stock_basic[stock_basic["list_date"] > cutoff]["ts_code"]) eligible = [c for c in all_codes if c not in st_codes and c not in new_codes] # 获取前一日 daily_basic(PE/PB 估值数据) prev_date = tushare_client.get_latest_trade_date() basic_df = tushare_client.get_daily_basic(prev_date) # 批量获取腾讯实时行情 quotes = await tencent_client.get_realtime_quotes_batch(eligible) results = [] for ts_code, quote in quotes.items(): # 硬性条件 if quote.pct_chg <= 0: continue # 盘中只关注上涨股 if quote.pct_chg > 9.8: continue # 已涨停,无买入空间 if quote.turnover_rate < settings.min_turnover_rate: continue if quote.turnover_rate > settings.max_turnover_rate: continue if quote.circ_mv is not None: if quote.circ_mv < settings.min_circ_mv or quote.circ_mv > settings.max_circ_mv: continue # 评分 score = _score_intraday(quote) # 估值评分(用前一日 PE/PB) pe = None pb = None if not basic_df.empty: b_row = basic_df[basic_df["ts_code"] == ts_code] if not b_row.empty: b = b_row.iloc[0] pe = float(b["pe_ttm"]) if pd.notna(b.get("pe_ttm")) else None pb = float(b["pb"]) if pd.notna(b.get("pb")) else None valuation_score = _score_valuation(pe, pb) # 名称 name = quote.name or ts_code results.append({ "ts_code": ts_code, "name": name, "sector": stock_sectors[ts_code][0], "sectors": stock_sectors[ts_code], "price": quote.price, "pct_chg": quote.pct_chg, "turnover_rate": quote.turnover_rate, "volume_ratio": quote.volume_ratio, "circ_mv": quote.circ_mv, "capital_score": round(score, 1), "valuation_score": round(valuation_score, 1), # 盘中无资金流向数据 "main_net_inflow": 0, "inflow_ratio": 0, }) results.sort(key=lambda x: x["capital_score"], reverse=True) top = results[:settings.top_stock_count] for r in top: logger.info( f"盘中筛选: {r['name']}({r['ts_code']}) 板块={r['sector']} " f"涨幅={r['pct_chg']}% 量比={r['volume_ratio']} " f"换手率={r['turnover_rate']}% 评分={r['capital_score']}" ) return top def _score_intraday(quote: StockQuote) -> float: """盘中评分逻辑(替代资金流向评分) 盘中无法获取大单/特大单数据,改用以下指标: - 涨幅区间 (25%): 2%-7% 为活跃区间 - 量比 (30%): > 1.5 说明资金活跃 - 换手率 (20%): 5%-10% 最佳 - 振幅 (10%): 适度振幅说明活跃 - 市值适配 (15%): 100-300亿最佳 """ score = 0.0 # 涨幅区间 (25分) pct = quote.pct_chg if 3 <= pct <= 7: score += 25 elif 2 <= pct < 3: score += 18 elif 7 < pct <= 9: score += 15 elif 0 < pct < 2: score += 8 # 量比 (30分) vr = quote.volume_ratio if vr is not None: if vr > 3.0: score += 30 elif vr > 2.0: score += 24 elif vr > 1.5: score += 18 elif vr > 1.0: score += 10 # 换手率 (20分) tr = quote.turnover_rate if 5 <= tr <= 10: score += 20 elif 3 <= tr < 5: score += 14 elif 10 < tr <= 15: score += 10 # 振幅 (10分) - 适度振幅 amp = quote.amplitude or 0 if 3 <= amp <= 8: score += 10 elif 2 <= amp < 3: score += 6 elif amp > 8: score += 3 # 市值适配 (15分) if quote.circ_mv is not None: mv = quote.circ_mv if 100 <= mv <= 300: score += 15 elif settings.min_circ_mv <= mv <= settings.max_circ_mv: score += 10 elif mv > 0: score += 3 return score async def intraday_sector_scan(prev_sectors: list[SectorInfo]) -> list[SectorInfo]: """盘中板块热度更新:用东方财富实时板块数据刷新涨幅和涨停数 一次请求替代之前腾讯批量获取数千只成分股的方式。 """ if not prev_sectors: return prev_sectors # 从东方财富获取实时板块排名(1次 HTTP 请求) try: from app.data.eastmoney_client import get_sector_realtime_ranking em_sectors = await get_sector_realtime_ranking() except Exception as e: logger.warning(f"东方财富板块实时数据获取失败: {e}") return prev_sectors if not em_sectors: return prev_sectors # 按板块名称匹配更新数据 matched = 0 for sector in prev_sectors: em_data = None # 先精确匹配 for em_s in em_sectors: if em_s["sector_name"] == sector.sector_name: em_data = em_s break # 模糊匹配 if not em_data: for em_s in em_sectors: em_name = em_s["sector_name"].rstrip("行业").rstrip("板块").strip() ts_name = sector.sector_name.rstrip("行业").rstrip("板块").strip() if em_name == ts_name or (len(em_name) <= len(ts_name) and em_name in ts_name) or (len(ts_name) <= len(em_name) and ts_name in em_name): em_data = em_s break if em_data: matched += 1 sector.pct_change = em_data["pct_change"] # 涨停数保留 Tushare 数据(东方财富此字段不可用) logger.info( f"盘中板块实时更新: {matched}/{len(prev_sectors)} 匹配成功, " f"涨幅最高={max(prev_sectors, key=lambda s: s.pct_change).sector_name} " f"({max(s.pct_change for s in prev_sectors):.1f}%)" ) return prev_sectors