""" 龙头股筛选(Tushare 版本) 从异动板块中筛选出龙头股 """ import pandas as pd from typing import Dict, List from datetime import datetime from app.utils.logger import logger class TushareStockSelector: """龙头股筛选器(使用 Tushare)""" def __init__(self, tushare_client, top_n: int = 3): """ 初始化筛选器 Args: tushare_client: TushareClient 实例 top_n: 返回前 N 只龙头股 """ self.top_n = top_n self.ts_client = tushare_client def select_leading_stocks(self, ts_code: str, sector_name: str) -> List[Dict]: """ 筛选板块龙头股 Args: ts_code: 板块指数代码 sector_name: 板块名称 Returns: 龙头股列表(已排序) """ try: # 获取成分股 members_df = self.ts_client.get_sector_members(ts_code) if members_df.empty: logger.warning(f"获取板块 {sector_name} 成分股失败") return [] # ths_member 返回的是 con_code(成分股代码),需要用这个来查行情 stock_codes = members_df['con_code'].tolist() # 限制数量,避免请求过多 if len(stock_codes) > 50: stock_codes = stock_codes[:50] # 获取实时行情 realtime_df = self.ts_client.get_realtime_data(stock_codes) if realtime_df.empty: logger.warning(f"获取板块 {sector_name} 成分股行情失败") return [] # 获取每日指标(换手率、量比) from datetime import datetime trade_date = datetime.now().strftime('%Y%m%d') basic_df = self.ts_client.get_stock_daily_basic(stock_codes, trade_date) # 合并数据 - 注意:ths_member 的 con_code 对应 daily 的 ts_code members_df = members_df.rename(columns={'con_code': 'stock_code'}) realtime_df = realtime_df.rename(columns={'ts_code': 'stock_code'}) if not basic_df.empty: basic_df = basic_df.rename(columns={'ts_code': 'stock_code'}) merged = pd.merge( members_df[['stock_code', 'con_name']], realtime_df, on='stock_code', how='inner' ) merged = pd.merge( merged, basic_df[['stock_code', 'turnover_rate', 'volume_ratio']], on='stock_code', how='left' ) else: merged = pd.merge( members_df[['stock_code', 'con_name']], realtime_df, on='stock_code', how='inner' ) if merged.empty: return [] # 数据类型转换 - daily 接口返回 pct_chg 不是 pct_change merged['close'] = pd.to_numeric(merged['close'], errors='coerce') merged['pct_chg'] = pd.to_numeric(merged['pct_chg'], errors='coerce') merged['change'] = pd.to_numeric(merged['change'], errors='coerce') merged['vol'] = pd.to_numeric(merged['vol'], errors='coerce') # 注意:daily 接口的 amount 单位是千元,需要转换为元 merged['amount'] = pd.to_numeric(merged['amount'], errors='coerce') * 1000 # 换手率和量比填充默认值 if 'turnover_rate' in merged.columns: merged['turnover_rate'] = pd.to_numeric(merged['turnover_rate'], errors='coerce').fillna(0) else: merged['turnover_rate'] = 0.0 if 'volume_ratio' in merged.columns: merged['volume_ratio'] = pd.to_numeric(merged['volume_ratio'], errors='coerce').fillna(1.0) else: merged['volume_ratio'] = 1.0 # 过滤:只保留有成交额的股票 merged = merged[merged['amount'] > 0].copy() if merged.empty: return [] # 计算综合评分 merged['score'] = merged.apply(self._calculate_score, axis=1) # 排序:按综合得分 merged = merged.sort_values('score', ascending=False) # 取前 N 只 top_stocks = merged.head(self.top_n) # 转换结果 results = [] for _, row in top_stocks.iterrows(): # 计算涨速等级 change_pct = row['pct_chg'] if change_pct >= 5: speed_level = "⚡⚡⚡ 极快" elif change_pct >= 3: speed_level = "⚡⚡ 快速" elif change_pct >= 1: speed_level = "⚡ 较快" else: speed_level = "🐌 平稳" # 计算振幅 amplitude = 0.0 if 'high' in row and 'low' in row and row['low'] > 0: amplitude = (row['high'] - row['low']) / row['low'] * 100 results.append({ 'code': row['stock_code'], 'name': row['con_name'], 'price': float(row['close']), 'change_pct': float(row['pct_chg']), 'change_amount': float(row['change']), 'amount': float(row['amount']), 'turnover': float(row.get('turnover_rate', 0)), 'volume_ratio': float(row.get('volume_ratio', 1.0)), 'amplitude': amplitude, 'score': float(row['score']), 'speed_level': speed_level, }) logger.info(f"板块 {sector_name} 龙头股筛选完成,Top {len(results)}") return results except Exception as e: logger.error(f"筛选龙头股失败 {sector_name}: {e}") return [] def _calculate_score(self, row: pd.Series) -> float: """ 计算综合得分 评分维度: - 涨跌幅 (40%) - 成交额 (30%) - 涨速 (20%) - 换手率 (10%) Args: row: 股票数据行 Returns: 综合得分 """ score = 0.0 # 1. 涨跌幅得分 (40分) - 涨幅越高得分越高 change_pct = row['pct_chg'] if change_pct >= 7: score += 40 # 涨停级别 elif change_pct >= 5: score += 35 elif change_pct >= 3: score += 30 elif change_pct >= 2: score += 25 elif change_pct >= 1: score += 20 elif change_pct > 0: score += 15 else: score += max(0, 10 + change_pct * 5) # 下跌也有基础分 # 2. 成交额得分 (30分) - 成交额越大得分越高 # 注意:amount 已在 select_leading_stocks 中从千元转换为元 amount = row['amount'] # 单位是元 if amount >= 1000000000: # 10亿以上 score += 30 elif amount >= 500000000: # 5亿以上 score += 25 elif amount >= 100000000: # 1亿以上 score += 20 elif amount >= 50000000: # 5000万以上 score += 15 elif amount >= 10000000: # 1000万以上 score += 10 else: score += 5 # 3. 涨速得分 (20分) - 简化用涨幅代替 if change_pct >= 5: score += 20 elif change_pct >= 3: score += 15 elif change_pct >= 1: score += 10 else: score += 5 # 4. 换手率得分 (10分) - 使用真实换手率数据 turnover_rate = row.get('turnover_rate', 0) if turnover_rate >= 15: score += 10 # 换手率极高,资金活跃 elif turnover_rate >= 10: score += 9 elif turnover_rate >= 7: score += 8 elif turnover_rate >= 5: score += 7 elif turnover_rate >= 3: score += 6 elif turnover_rate >= 1: score += 4 elif turnover_rate >= 0.5: score += 2 else: score += 1 # 换手率较低 return score