""" 统一算法模块 — 全部核心算法的唯一定义处(Single Source of Truth) 包含: 1. compute_recommend — 统一推荐逻辑(买入/卖出/加仓/观望等,严格遵循suanfa.md) 2. get_kline_data — 获取K线数据并返回 DataFrame(优先本地DB → 阿里云 → 腾讯 → 麦蕊 → AKShare) 3. fetch_kline_rows — 获取K线数据并返回 tuple 行列表(用于写入DB同步) 4. get_latest_price — 获取股票最新价格 5. code_to_market — 股票代码→市场判断(SH/SZ/BJ) 6. 各 API session 管理 7. compute_bull_stage — 牛股阶段识别(底部→起爆→确立→加速→补涨) 8. find_bull_stocks — 从扫描结果中找出潜在牛股 调用方: - routes/analysis.py → compute_recommend, get_kline_data - services/scheduler.py → compute_recommend, get_latest_price - full_signal_scan.py → get_kline_data, API sessions - sync_kline.py → fetch_kline_rows, API sessions - routes/market.py → get_kline_data """ import threading from datetime import datetime, timedelta import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from config import Config # ═══════════════════════════════════════════════ # 1. 股票代码 → 市场 工具函数 # ═══════════════════════════════════════════════ def code_to_market(code): """6位股票代码 → 市场代码(SH/SZ/BJ)""" if code.startswith(('0', '3')): return 'SZ' elif code.startswith(('8', '9')): return 'BJ' else: return 'SH' def is_bj_stock(code): """是否是北交所股票""" return code.startswith(('8', '9')) def code_to_ali_symbol(code): """6位股票代码 → 阿里云API格式(SH600519 / SZ000001 / BJ920720)""" return f'{code_to_market(code)}{code}' def code_to_tencent_symbol(code): """6位股票代码 → 腾讯API格式(sh600519 / sz000001 / bj920720)""" return f'{code_to_market(code).lower()}{code}' # ═══════════════════════════════════════════════ # 2. API Session 管理(线程安全,连接池复用) # ═══════════════════════════════════════════════ _ali_session = None _ali_lock = threading.Lock() _tencent_session = None _tencent_lock = threading.Lock() def get_ali_session(): """获取阿里云API专用 Session(线程安全,单例)""" global _ali_session if _ali_session is None: with _ali_lock: if _ali_session is None: s = requests.Session() retry = Retry(total=2, backoff_factor=0.3, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=20) s.mount('https://', adapter) s.headers.update({ 'Authorization': f'APPCODE {Config.ALICLOUD_APPCODE}', 'Content-Type': 'application/x-www-form-urlencoded', }) _ali_session = s return _ali_session def get_tencent_session(): """获取腾讯K线API专用 Session(线程安全,单例)""" global _tencent_session if _tencent_session is None: with _tencent_lock: if _tencent_session is None: s = requests.Session() retry = Retry(total=2, backoff_factor=0.3, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=20) s.mount('https://', adapter) s.headers.update({ 'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 'AppleWebKit/537.36'), }) _tencent_session = s return _tencent_session # API URL 常量 ALICLOUD_KLINE_URL = Config.ALICLOUD_KLINE_URL TENCENT_KLINE_URL = 'https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get' # ═══════════════════════════════════════════════ # 3. K线数据获取 — DataFrame 格式(供信号检测/分析用) # ═══════════════════════════════════════════════ def get_kline_data(stock_code, days=120, use_local_db=True): """ 获取K线数据,返回 pandas DataFrame (columns: date, open, high, low, close, volume) 数据源优先级: 本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare 参数: stock_code: 6位股票代码 days: 获取天数 use_local_db: 是否优先使用本地DB(全景扫描时为True, 实时分析时可为False) 返回: DataFrame 或 None """ import pandas as pd # 1. 优先从本地数据库读取 if use_local_db: df = _get_kline_from_local_db(stock_code, days) if df is not None: return df # 2. 阿里云K线API(沪深最稳定,北交所可能不支持) if not is_bj_stock(stock_code): df = _fetch_ali_kline_df(stock_code, days) if df is not None and len(df) >= 30: return df # 3. 腾讯K线API(全市场,含北交所) df = _fetch_tencent_kline_df(stock_code, days) if df is not None and len(df) >= 30: return df # 4. 麦蕊API df = _fetch_mairui_kline_df(stock_code, days) if df is not None and len(df) >= 30: return df # 5. AKShare df = _fetch_akshare_kline_df(stock_code, days) if df is not None and len(df) >= 30: return df return None def _get_kline_from_local_db(stock_code, days=120): """从本地数据库读取K线(最快,毫秒级)""" import pandas as pd try: import psycopg2 conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) conn.autocommit = True start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') with conn.cursor() as cur: cur.execute(""" SELECT trade_date, open, high, low, close, volume FROM stock_kline_daily WHERE code = %s AND trade_date >= %s ORDER BY trade_date """, (stock_code, start_date)) rows = cur.fetchall() conn.close() if rows and len(rows) >= 30: df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume']) df['date'] = df['date'].astype(str) for col in ('open', 'high', 'low', 'close', 'volume'): df[col] = df[col].astype(float) return df except Exception: pass return None # ---- 线程本地连接(供多线程扫描时使用,避免频繁建连) ---- _thread_local = threading.local() def get_kline_from_local_db_threaded(stock_code, days=120): """多线程扫描专用:使用线程本地连接从本地DB读取K线""" import pandas as pd try: conn = getattr(_thread_local, 'kline_conn', None) if conn is None or conn.closed: import psycopg2 conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) conn.autocommit = True _thread_local.kline_conn = conn start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') with conn.cursor() as cur: cur.execute(""" SELECT trade_date, open, high, low, close, volume FROM stock_kline_daily WHERE code = %s AND trade_date >= %s ORDER BY trade_date """, (stock_code, start_date)) rows = cur.fetchall() if rows and len(rows) >= 30: df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume']) df['date'] = df['date'].astype(str) for col in ('open', 'high', 'low', 'close', 'volume'): df[col] = df[col].astype(float) return df except Exception: pass return None def _fetch_ali_kline_df(stock_code, days=120): """阿里云K线API → DataFrame""" import pandas as pd try: session = get_ali_session() symbol = code_to_ali_symbol(stock_code) resp = session.post(ALICLOUD_KLINE_URL, data={ 'symbol': symbol, 'type': '240', 'limit': str(min(days, 300)), 'ma': '5', }, timeout=10) if resp.status_code == 200: data = resp.json() if data.get('success') and data.get('data', {}).get('list'): records = [] for item in data['data']['list']: day_str = item.get('day', '') if not day_str or len(day_str) < 10: continue records.append({ 'date': day_str[:10], 'open': float(item.get('open', 0)), 'high': float(item.get('high', 0)), 'low': float(item.get('low', 0)), 'close': float(item.get('close', 0)), 'volume': float(item.get('volume', 0)), }) if records: return pd.DataFrame(records) except Exception: pass return None def _fetch_tencent_kline_df(stock_code, days=120): """腾讯K线API → DataFrame(全市场含北交所)""" import pandas as pd try: session = get_tencent_session() symbol = code_to_tencent_symbol(stock_code) start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d') resp = session.get(TENCENT_KLINE_URL, params={ 'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq', }, timeout=15) if resp.status_code == 200: data = resp.json() stock_data = data.get('data', {}).get(symbol, {}) klines = stock_data.get('qfqday') or stock_data.get('day') or [] if klines: records = [] for item in klines: if len(item) < 6: continue # 腾讯格式: [date, open, close, high, low, volume, ...] records.append({ 'date': item[0][:10], 'open': float(item[1]), 'high': float(item[3]), # high = position 3 'low': float(item[4]), # low = position 4 'close': float(item[2]), # close = position 2 'volume': float(item[5]), }) if records: return pd.DataFrame(records) except Exception: pass return None def _fetch_mairui_kline_df(stock_code, days=120): """麦蕊API → DataFrame""" import pandas as pd try: from services.mairui_api import get_kline result = get_kline(stock_code, period='d', days=days, adjust='f') if result['success'] and result['data']: df = pd.DataFrame(result['data']) df.rename(columns={ 'date': 'date', 'open': 'open', 'high': 'high', 'low': 'low', 'close': 'close', 'volume': 'volume', }, inplace=True) if len(df) >= 30: return df except Exception: pass return None def _fetch_akshare_kline_df(stock_code, days=120): """AKShare → DataFrame (支持自动降级到腾讯数据源)""" import pandas as pd try: from utils.data_fetcher import fetch_stock_hist end_date = datetime.now().strftime('%Y%m%d') start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') df = fetch_stock_hist( stock_code=stock_code, period='daily', start_date=start_date, end_date=end_date, adjust='qfq', ) if df is not None and not df.empty: df = df.rename(columns={ '日期': 'date', '开盘': 'open', '最高': 'high', '最低': 'low', '收盘': 'close', '成交量': 'volume', }) df = df[['date', 'open', 'high', 'low', 'close', 'volume']] return df except Exception: pass return None # ═══════════════════════════════════════════════ # 4. K线数据获取 — tuple行格式(供 sync_kline.py 写入DB用) # ═══════════════════════════════════════════════ def fetch_kline_rows(code, days): """ 获取K线数据,返回 list of tuple: (code, date, open, high, low, close, volume, amount) 数据源优先级: 阿里云API → 腾讯API → 麦蕊API → AKShare 北交所(8XX/9XX)直接走腾讯API 供 sync_kline.py 同步到本地数据库使用。 """ bj = is_bj_stock(code) # 北交所:直接用腾讯API(阿里云/Mairui不支持BJ) if bj: rows = _fetch_tencent_kline_rows(code, days) if rows: return rows return None # 1. 首选:阿里云K线API rows = _fetch_ali_kline_rows(code, days) if rows: return rows # 2. 回退:腾讯API rows = _fetch_tencent_kline_rows(code, days) if rows: return rows # 3. 回退:Mairui API rows = _fetch_mairui_kline_rows(code, days) if rows: return rows # 4. 最后回退:AKShare rows = _fetch_akshare_kline_rows(code, days) if rows: return rows return None def _fetch_ali_kline_rows(code, days): """阿里云K线API → tuple rows""" try: session = get_ali_session() symbol = code_to_ali_symbol(code) resp = session.post(ALICLOUD_KLINE_URL, data={ 'symbol': symbol, 'type': '240', 'limit': str(min(days, 300)), 'ma': '5', }, timeout=10) if resp.status_code == 200: data = resp.json() if data.get('success') and data.get('data', {}).get('list'): rows = [] for item in data['data']['list']: day_str = item.get('day', '') if not day_str or len(day_str) < 10: continue rows.append(( code, day_str[:10], float(item.get('open', 0)), float(item.get('high', 0)), float(item.get('low', 0)), float(item.get('close', 0)), int(item.get('volume', 0)), float(item.get('amount', 0)), )) if rows: return rows except Exception: pass return None def _fetch_tencent_kline_rows(code, days): """腾讯K线API → tuple rows(全市场含北交所)""" try: symbol = code_to_tencent_symbol(code) session = get_tencent_session() start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d') resp = session.get(TENCENT_KLINE_URL, params={ 'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq', }, timeout=15) if resp.status_code == 200: data = resp.json() stock_data = data.get('data', {}).get(symbol, {}) klines = stock_data.get('qfqday') or stock_data.get('day') or [] if klines: rows = [] for item in klines: if len(item) < 6: continue date_str = item[0] if not date_str or len(date_str) < 10: continue rows.append(( code, date_str[:10], float(item[1]), # open float(item[3]), # high (position 3) float(item[4]), # low (position 4) float(item[2]), # close (position 2) int(float(item[5])), # volume float(item[8]) * 10000 if len(item) > 8 and item[8] else 0, )) if rows: return rows except Exception: pass return None def _fetch_mairui_kline_rows(code, days): """麦蕊API → tuple rows""" try: from services.mairui_api import get_kline result = get_kline(code, period='d', days=days, adjust='f') if result['success'] and result['data']: rows = [] for item in result['data']: date_str = item.get('date', '') if not date_str: continue rows.append(( code, date_str, item.get('open', 0), item.get('high', 0), item.get('low', 0), item.get('close', 0), int(item.get('volume', 0)), item.get('amount', 0), )) if rows: return rows except Exception: pass return None def _fetch_akshare_kline_rows(code, days): """AKShare → tuple rows (支持自动降级到腾讯数据源)""" try: from utils.data_fetcher import fetch_stock_hist end_date = datetime.now().strftime('%Y%m%d') start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') df = fetch_stock_hist( stock_code=code, period='daily', start_date=start_date, end_date=end_date, adjust='qfq', ) if df is not None and not df.empty: rows = [] for _, r in df.iterrows(): rows.append(( code, str(r['日期']), float(r['开盘']), float(r['最高']), float(r['最低']), float(r['收盘']), int(r['成交量']), float(r.get('成交额', 0)), )) if rows: return rows except Exception: pass return None # ═══════════════════════════════════════════════ # 5. 统一推荐算法 # ═══════════════════════════════════════════════ def compute_recommend(signal_status, indicators, triggered_count, is_holding): """ 统一推荐逻辑 — 全局唯一定义(严格遵循 suanfa.md 体系最强战法)。 体系最强战法流程(suanfa.md): 1. 日线底背离 → 纳入关注范围 2. 龙抬头出现 → 执行买入操作(实操核心买点) 3. 真龙/主升浪 → 持有仓位+加仓(不是新买入!) 4. 不见主升浪 → 不出场 参数: signal_status: list[dict] 信号状态列表(来自 signal_detector._check_all_signal_status) indicators: dict 最新技术指标(含 macd.dif, macd.dea 等) triggered_count: int 触发信号数量 is_holding: bool 当前是否持仓该股票 返回: tuple: (signal_type, display_text, reason, recommend_rate) - signal_type: 'buy' | 'sell' | 'watch' - display_text: '买入' | '卖出' | '加仓' | '持有' | '关注' | '观察' | '观望' - reason: str 推荐理由 - recommend_rate: int 推荐评分 0-100 使用场景: - 全景扫描结果推荐列 - 策略建议分档 - 提醒 tab 买卖推荐 - 模拟交易自动买卖决策 """ if not signal_status: return ('watch', '观望', '暂无信号数据', 0) ss = signal_status sig_map = {} for s in ss: sig_map[s.get('type', '')] = s has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False) has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False) has_dragon = sig_map.get('dragon_head', {}).get('triggered', False) has_real_dragon = sig_map.get('true_dragon', {}).get('triggered', False) macd = (indicators or {}).get('macd', {}) dif = macd.get('dif', 0) dea = macd.get('dea', 0) triggered_signals = [s.get('name', s.get('type', '')) for s in ss if s.get('triggered')] # ════════════════════════════════════════════ # 持仓逻辑(suanfa.md 步骤3-4) # ════════════════════════════════════════════ if is_holding: # 卖出条件: MACD死叉 + 无主升浪 → 趋势走弱,不见主升浪则出场 if dif < dea and not has_main_wave: return ('sell', '卖出', f"MACD死叉(DIF={dif:.3f}= dea) if macd_golden: return ('buy', '买入', '日线底背离+龙抬头 → 最佳买入信号', 95) return ('watch', '关注', f'底背离+龙抬头但MACD死叉(DIF={dif:.3f}= dea) # MACD 金叉或无数据 if has_main_wave and macd_ok: return ('buy', '买入', '龙抬头+主升浪 → 强势买入信号', 90) if macd_ok: return ('buy', '买入', '龙抬头出现 → 短线起爆点,执行买入', 80) # MACD死叉 + 龙抬头 → 信号冲突,降级为关注 return ('watch', '关注', f'龙抬头出现但MACD死叉(DIF={dif:.3f} 0: sigs = '、'.join(triggered_signals[:3]) return ('watch', '观察', f"触发{triggered_count}个信号: {sigs}", 40) return ('watch', '观望', '无核心信号触发', 0) # ═══════════════════════════════════════════════ # 6. 获取最新价格 # ═══════════════════════════════════════════════ def get_latest_price(stock_code): """ 获取股票最新价格(从本地 stock_realtime_price 表) 返回: float: 最新价格, 失败返回 0 """ try: import psycopg2 conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) cur = conn.cursor() cur.execute(""" SELECT price FROM stock_realtime_price WHERE code = %s AND price > 0 """, (stock_code,)) row = cur.fetchone() conn.close() if row: return float(row[0]) except Exception: pass return 0 # ═══════════════════════════════════════════════ # 7. 牛股阶段识别(suanfa.md 标准牛股启动信号先后顺序) # ═══════════════════════════════════════════════ # 标准牛股启动流程(底部→拉升): # 阶段1 → 日线底背离/短底背离(跌到底部,停止下跌) # 阶段2 → 龙抬头(资金进场,短线起爆) # 阶段3 → 真龙(趋势正式确立) # 阶段4 → ★主升浪(进入加速段,利润兑现最快) # 阶段5 → 反弹(中途回调后的补涨信号) # 补充 → 老鼠仓可在底部任意位置提前出现 BULL_STAGES = { 1: {'name': '底部探测', 'icon': '', 'color': '#2196F3', 'desc': '日线底背离/短底背离 → 跌到底部,停止下跌'}, 2: {'name': '资金进场', 'icon': '', 'color': '#4CAF50', 'desc': '龙抬头 → 资金进场,短线起爆点(最佳买入时机)'}, 3: {'name': '趋势确立', 'icon': '', 'color': '#FF9800', 'desc': '真龙 → 中期趋势正式确立'}, 4: {'name': '加速拉升', 'icon': '', 'color': '#F44336', 'desc': '★主升浪 → 进入加速段,利润兑现最快'}, 5: {'name': '回调补涨', 'icon': '', 'color': '#9C27B0', 'desc': '反弹 → 中途回调后的补涨信号'}, } def compute_bull_stage(signal_status): """ 识别股票在标准牛股启动流程中的阶段。 参数: signal_status: list[dict] 信号状态列表 返回: dict: { 'stage': int (0-5, 0=未进入流程), 'stage_name': str, 'stage_icon': str, 'stage_color': str, 'stage_desc': str, 'signals_active': list[str], # 当前活跃的信号名称 'progress': int (0-100), # 牛股流程进度百分比 'next_signal': str, # 下一个期待的信号 'investment_advice': str, # 投资建议 'has_rat_trading': bool, # 是否有老鼠仓(提前埋伏信号) } """ if not signal_status: return { 'stage': 0, 'stage_name': '观望', 'stage_icon': '', 'stage_color': '#9E9E9E', 'stage_desc': '无信号触发', 'signals_active': [], 'progress': 0, 'next_signal': '等待底背离/短底背离', 'investment_advice': '暂无操作机会', 'has_rat_trading': False, } sig_map = {} for s in signal_status: sig_map[s.get('type', '')] = s has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False) has_short_div = sig_map.get('short_bottom_divergence', {}).get('triggered', False) has_dragon = sig_map.get('dragon_head', {}).get('triggered', False) has_true_dragon = sig_map.get('true_dragon', {}).get('triggered', False) has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False) has_rebound = sig_map.get('rebound', {}).get('triggered', False) has_rat = sig_map.get('rat_trading', {}).get('triggered', False) signals_active = [] if has_divergence: signals_active.append('日线底背离') if has_short_div: signals_active.append('短底背离') if has_dragon: signals_active.append('龙抬头') if has_true_dragon: signals_active.append('真龙') if has_main_wave: signals_active.append('主升浪') if has_rebound: signals_active.append('反弹') if has_rat: signals_active.append('老鼠仓') # 确定阶段(按最高阶段判定) stage = 0 if has_main_wave: stage = 4 elif has_true_dragon: stage = 3 elif has_dragon: stage = 2 elif has_divergence or has_short_div: stage = 1 elif has_rebound: stage = 5 elif has_rat: stage = 1 # 老鼠仓归入底部阶段 if stage == 0: return { 'stage': 0, 'stage_name': '观望', 'stage_icon': '', 'stage_color': '#9E9E9E', 'stage_desc': '无核心信号触发', 'signals_active': signals_active, 'progress': 0, 'next_signal': '等待底背离/短底背离', 'investment_advice': '暂无操作机会', 'has_rat_trading': has_rat, } info = BULL_STAGES[stage] # 计算流程进度(越靠后越高) # 加分项:多信号叠加说明流程更完整 base_progress = {1: 20, 2: 45, 3: 65, 4: 85, 5: 50} progress = base_progress.get(stage, 0) if stage <= 2 and has_divergence: progress += 10 # 有底背离做基础更好 if stage >= 2 and has_dragon: progress += 5 if stage >= 3 and has_true_dragon: progress += 5 if has_rat: progress += 5 # 老鼠仓加分 progress = min(progress, 100) # 下一步信号期待 next_signals = { 1: '等待龙抬头(资金进场信号)', 2: '等待真龙(趋势确认信号)', 3: '等待主升浪(加速拉升信号)', 4: '持有!不见主升浪消失不出场', 5: '等待龙抬头/真龙确认趋势', } # 投资建议 advices = { 1: '纳入关注池,等待龙抬头出现后买入', 2: '最佳买入时机!龙抬头=实操核心买点', 3: '趋势已确立,可以追入,建议等回调买入', 4: '已在加速段,持仓者加仓/持有,新入者谨慎追高', 5: '回调中可关注,但需确认不是假反弹', } return { 'stage': stage, 'stage_name': info['name'], 'stage_icon': info['icon'], 'stage_color': info['color'], 'stage_desc': info['desc'], 'signals_active': signals_active, 'progress': progress, 'next_signal': next_signals.get(stage, ''), 'investment_advice': advices.get(stage, ''), 'has_rat_trading': has_rat, } def find_bull_stocks(scan_rows, holding_codes=None): """ 从扫描结果中找出潜在牛股,按阶段分组排序。 参数: scan_rows: list[dict] 扫描结果列表 (含 code, name, signal_status, indicators, triggered_count) holding_codes: set 持仓代码集合 返回: dict: { 'stages': {1: [...], 2: [...], ...}, # 按阶段分组的股票列表 'summary': {1: count, 2: count, ...}, # 各阶段数量统计 'total': int, # 有信号的总数 } """ if holding_codes is None: holding_codes = set() stages = {1: [], 2: [], 3: [], 4: [], 5: []} summary = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0} for row in scan_rows: signal_status = row.get('signal_status') or [] if not signal_status: summary[0] += 1 continue # 判断牛股阶段 bull = compute_bull_stage(signal_status) stage = bull['stage'] summary[stage] += 1 if stage == 0: continue # 计算推荐 is_holding = row.get('code', '') in holding_codes st, disp, reason, rate = compute_recommend( signal_status, row.get('indicators'), row.get('triggered_count'), is_holding, ) item = { 'code': row.get('code', ''), 'name': row.get('name', ''), 'stage': stage, 'stage_name': bull['stage_name'], 'stage_icon': bull['stage_icon'], 'stage_color': bull['stage_color'], 'signals_active': bull['signals_active'], 'progress': bull['progress'], 'next_signal': bull['next_signal'], 'investment_advice': bull['investment_advice'], 'has_rat_trading': bull['has_rat_trading'], 'recommend_type': st, 'recommend_text': disp, 'recommend_reason': reason, 'recommend_rate': rate, 'is_holding': is_holding, 'triggered_count': row.get('triggered_count', 0), } stages[stage].append(item) # 每个阶段内按推荐评分降序排序 for stage_num in stages: stages[stage_num].sort(key=lambda x: (-x['recommend_rate'], -x['progress'])) total = sum(len(v) for v in stages.values()) return { 'stages': stages, 'summary': summary, 'total': total, 'stage_info': BULL_STAGES, }