#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 概念异动检测 - 基于超额收益(Alpha)的方法 核心思想: - 异动 = 概念表现与市场整体的显著偏离 - Alpha = 概念涨幅 - 大盘涨幅(超额收益) - 用 Z-Score 衡量 Alpha 是否显著偏离历史正常范围 优点: - 自适应:不同概念有不同波动率,自动适应 - 相对比较:不看绝对涨跌,看相对表现 - 无需规则维护:统计方法自动学习"正常范围" - 通用:可迁移到其他市场 """ import pandas as pd import numpy as np from datetime import datetime, timedelta from sqlalchemy import create_engine, text from elasticsearch import Elasticsearch from clickhouse_driver import Client from collections import deque, defaultdict import time import logging import json import os import hashlib import argparse from typing import Dict, List, Optional, Tuple from dataclasses import dataclass, field # ==================== 配置 ==================== MYSQL_ENGINE = create_engine( "mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock", echo=False ) ES_CLIENT = Elasticsearch(['http://222.128.1.157:19200']) INDEX_NAME = 'concept_library_v3' CLICKHOUSE_CONFIG = { 'host': '222.128.1.157', 'port': 18000, 'user': 'default', 'password': 'Zzl33818!', 'database': 'stock' } HIERARCHY_FILE = 'concept_hierarchy_v3.json' REFERENCE_INDEX = '000001.SH' # ==================== Alpha 异动检测配置 ==================== ALPHA_CONFIG = { # Z-Score 阈值 'zscore_threshold': 2.0, # |Z| > 2.0 视为异动(降低阈值) # 绝对 Alpha 阈值(当历史数据不足时使用) 'absolute_alpha_threshold': 1.5, # |Alpha| > 1.5% 视为异动 # 历史窗口 'history_window': 60, # 保留最近60个数据点用于计算均值/标准差 'min_history': 5, # 最少需要5个数据点才用Z-Score(降低要求) # 冷却时间 'cooldown_minutes': 8, # 同一概念触发异动后的冷却时间 # 显示控制 'max_alerts_per_minute': 15, # 每分钟最多显示的异动数 'min_alpha_abs': 0.5, # 最小超额收益绝对值(过滤微小波动) # 重要性评分权重 'importance_weights': { 'alpha_zscore': 0.35, # Alpha Z-Score 绝对值 'alpha_abs': 0.25, # 超额收益绝对值 'rank_in_minute': 0.20, # 当分钟内的排名 'limit_up_count': 0.20, # 涨停数 } } # ==================== 日志配置 ==================== logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(f'concept_alert_alpha_{datetime.now().strftime("%Y%m%d")}.log', encoding='utf-8'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # ==================== 数据结构 ==================== @dataclass class AlphaStats: """概念的Alpha统计信息""" history: deque = field(default_factory=lambda: deque(maxlen=ALPHA_CONFIG['history_window'])) mean: float = 0.0 std: float = 1.0 last_update: datetime = None def update(self, alpha: float, timestamp: datetime): """更新统计""" self.history.append(alpha) self.last_update = timestamp if len(self.history) >= 2: self.mean = np.mean(self.history) self.std = max(np.std(self.history), 0.1) # 避免除零 def get_zscore(self, alpha: float) -> float: """计算Z-Score""" if len(self.history) < ALPHA_CONFIG['min_history']: return 0.0 # 数据不足,不触发 return (alpha - self.mean) / self.std def is_ready(self) -> bool: """是否有足够数据进行检测""" return len(self.history) >= ALPHA_CONFIG['min_history'] # ==================== 全局变量 ==================== ch_client = None # 每个概念的Alpha统计 alpha_stats: Dict[str, AlphaStats] = defaultdict(AlphaStats) # 冷却记录 cooldown_cache: Dict[str, datetime] = {} # 当前分钟的数据缓存(用于计算排名) current_minute_data: List[dict] = [] def get_ch_client(): global ch_client if ch_client is None: ch_client = Client(**CLICKHOUSE_CONFIG) return ch_client def generate_id(name: str) -> str: return hashlib.md5(name.encode('utf-8')).hexdigest()[:16] def code_to_ch_format(code: str) -> str: if not code or len(code) != 6 or not code.isdigit(): return None if code.startswith('6'): return f"{code}.SH" elif code.startswith('0') or code.startswith('3'): return f"{code}.SZ" else: return f"{code}.BJ" # ==================== 概念数据获取 ==================== def get_all_concepts(): """从ES获取所有叶子概念""" concepts = [] query = { "query": {"match_all": {}}, "size": 100, "_source": ["concept_id", "concept", "stocks"] } resp = ES_CLIENT.search(index=INDEX_NAME, body=query, scroll='2m') scroll_id = resp['_scroll_id'] hits = resp['hits']['hits'] while len(hits) > 0: for hit in hits: source = hit['_source'] concept_info = { 'concept_id': source.get('concept_id'), 'concept_name': source.get('concept'), 'stocks': [], 'concept_type': 'leaf' } if 'stocks' in source and isinstance(source['stocks'], list): for stock in source['stocks']: if isinstance(stock, dict) and 'code' in stock and stock['code']: concept_info['stocks'].append(stock['code']) if concept_info['stocks']: concepts.append(concept_info) resp = ES_CLIENT.scroll(scroll_id=scroll_id, scroll='2m') scroll_id = resp['_scroll_id'] hits = resp['hits']['hits'] ES_CLIENT.clear_scroll(scroll_id=scroll_id) return concepts def load_hierarchy_concepts(leaf_concepts: list) -> list: """加载层级结构,生成母概念""" hierarchy_path = os.path.join(os.path.dirname(__file__), HIERARCHY_FILE) if not os.path.exists(hierarchy_path): return [] with open(hierarchy_path, 'r', encoding='utf-8') as f: hierarchy_data = json.load(f) concept_to_stocks = {c['concept_name']: set(c['stocks']) for c in leaf_concepts} parent_concepts = [] for lv1 in hierarchy_data.get('hierarchy', []): lv1_name = lv1.get('lv1', '') lv1_stocks = set() for child in lv1.get('children', []): lv2_name = child.get('lv2', '') lv2_stocks = set() if 'children' in child: for lv3_child in child.get('children', []): lv3_name = lv3_child.get('lv3', '') lv3_stocks = set() for concept_name in lv3_child.get('concepts', []): if concept_name in concept_to_stocks: lv3_stocks.update(concept_to_stocks[concept_name]) if lv3_stocks: parent_concepts.append({ 'concept_id': generate_id(f"lv3_{lv3_name}"), 'concept_name': f"[三级] {lv3_name}", 'stocks': list(lv3_stocks), 'concept_type': 'lv3' }) lv2_stocks.update(lv3_stocks) else: for concept_name in child.get('concepts', []): if concept_name in concept_to_stocks: lv2_stocks.update(concept_to_stocks[concept_name]) if lv2_stocks: parent_concepts.append({ 'concept_id': generate_id(f"lv2_{lv2_name}"), 'concept_name': f"[二级] {lv2_name}", 'stocks': list(lv2_stocks), 'concept_type': 'lv2' }) lv1_stocks.update(lv2_stocks) if lv1_stocks: parent_concepts.append({ 'concept_id': generate_id(f"lv1_{lv1_name}"), 'concept_name': f"[一级] {lv1_name}", 'stocks': list(lv1_stocks), 'concept_type': 'lv1' }) return parent_concepts # ==================== 价格数据获取 ==================== def get_base_prices(stock_codes: list, current_date: str) -> dict: """获取昨收价""" if not stock_codes: return {} valid_codes = [code for code in stock_codes if code and len(code) == 6 and code.isdigit()] if not valid_codes: return {} stock_codes_str = "','".join(valid_codes) query = f""" SELECT SECCODE, F002N FROM ea_trade WHERE SECCODE IN ('{stock_codes_str}') AND TRADEDATE = ( SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{current_date}' ) AND F002N IS NOT NULL AND F002N > 0 """ try: with MYSQL_ENGINE.connect() as conn: result = conn.execute(text(query)) return {row[0]: float(row[1]) for row in result if row[1] and float(row[1]) > 0} except Exception as e: logger.error(f"获取基准价格失败: {e}") return {} def get_latest_prices(stock_codes: list) -> dict: """获取最新价格""" if not stock_codes: return {} client = get_ch_client() ch_codes = [] code_mapping = {} for code in stock_codes: ch_code = code_to_ch_format(code) if ch_code: ch_codes.append(ch_code) code_mapping[ch_code] = code if not ch_codes: return {} ch_codes_str = "','".join(ch_codes) query = f""" SELECT code, close, timestamp FROM ( SELECT code, close, timestamp, ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn FROM stock_minute WHERE code IN ('{ch_codes_str}') AND toDate(timestamp) = today() ) WHERE rn = 1 """ try: result = client.execute(query) prices = {} for row in result: ch_code, close, ts = row if close and close > 0: pure_code = code_mapping.get(ch_code) if pure_code: prices[pure_code] = {'close': float(close), 'timestamp': ts} return prices except Exception as e: logger.error(f"获取最新价格失败: {e}") return {} def get_prices_at_time(stock_codes: list, timestamp: datetime) -> dict: """获取指定时间的价格""" if not stock_codes: return {} client = get_ch_client() ch_codes = [] code_mapping = {} for code in stock_codes: ch_code = code_to_ch_format(code) if ch_code: ch_codes.append(ch_code) code_mapping[ch_code] = code if not ch_codes: return {} ch_codes_str = "','".join(ch_codes) ts_str = timestamp.strftime('%Y-%m-%d %H:%M:%S') query = f""" SELECT code, close, timestamp FROM stock_minute WHERE code IN ('{ch_codes_str}') AND timestamp = '{ts_str}' """ try: result = client.execute(query) prices = {} for row in result: ch_code, close, ts = row if close and close > 0: pure_code = code_mapping.get(ch_code) if pure_code: prices[pure_code] = {'close': float(close), 'timestamp': ts} return prices except Exception as e: logger.error(f"获取历史价格失败: {e}") return {} def get_index_change(index_code: str, trade_date: str, timestamp: datetime = None) -> Optional[float]: """获取指数涨跌幅""" client = get_ch_client() try: # 获取昨收 code_no_suffix = index_code.split('.')[0] with MYSQL_ENGINE.connect() as conn: prev_result = conn.execute(text(""" SELECT F006N FROM ea_exchangetrade WHERE INDEXCODE = :code AND TRADEDATE < :today ORDER BY TRADEDATE DESC LIMIT 1 """), {'code': code_no_suffix, 'today': trade_date}).fetchone() if not prev_result or not prev_result[0]: return None prev_close = float(prev_result[0]) # 获取当前价格 if timestamp: ts_str = timestamp.strftime('%Y-%m-%d %H:%M:%S') query = f""" SELECT close FROM index_minute WHERE code = '{index_code}' AND timestamp = '{ts_str}' LIMIT 1 """ else: query = f""" SELECT close FROM index_minute WHERE code = '{index_code}' AND toDate(timestamp) = today() ORDER BY timestamp DESC LIMIT 1 """ result = client.execute(query) if not result: return None current_price = float(result[0][0]) return (current_price - prev_close) / prev_close * 100 except Exception as e: logger.error(f"获取指数涨跌幅失败: {e}") return None def get_index_data(index_code: str, trade_date: str, timestamp: datetime = None) -> Optional[dict]: """获取指数完整数据""" client = get_ch_client() try: code_no_suffix = index_code.split('.')[0] with MYSQL_ENGINE.connect() as conn: prev_result = conn.execute(text(""" SELECT F006N FROM ea_exchangetrade WHERE INDEXCODE = :code AND TRADEDATE < :today ORDER BY TRADEDATE DESC LIMIT 1 """), {'code': code_no_suffix, 'today': trade_date}).fetchone() if not prev_result or not prev_result[0]: return None prev_close = float(prev_result[0]) if timestamp: ts_str = timestamp.strftime('%Y-%m-%d %H:%M:%S') query = f""" SELECT close, timestamp FROM index_minute WHERE code = '{index_code}' AND timestamp = '{ts_str}' LIMIT 1 """ else: query = f""" SELECT close, timestamp FROM index_minute WHERE code = '{index_code}' AND toDate(timestamp) = today() ORDER BY timestamp DESC LIMIT 1 """ result = client.execute(query) if not result: return None current_price, ts = result[0] change_pct = (float(current_price) - prev_close) / prev_close * 100 return { 'code': index_code, 'price': float(current_price), 'prev_close': prev_close, 'change_pct': round(change_pct, 4), 'timestamp': ts } except Exception as e: logger.error(f"获取指数数据失败: {e}") return None # ==================== 概念统计计算 ==================== def calculate_concept_stats(concepts: list, stock_changes: dict, index_change: float) -> list: """ 计算概念统计,包含超额收益(Alpha) Alpha = 概念涨幅 - 指数涨幅 """ stats = [] for concept in concepts: concept_id = concept['concept_id'] concept_name = concept['concept_name'] stock_codes = concept['stocks'] concept_type = concept.get('concept_type', 'leaf') changes = [] limit_up_count = 0 limit_down_count = 0 for code in stock_codes: if code in stock_changes: change_pct = stock_changes[code] changes.append(change_pct) if change_pct >= 9.8: limit_up_count += 1 elif change_pct <= -9.8: limit_down_count += 1 if not changes: continue avg_change = np.mean(changes) # 核心:计算超额收益 Alpha alpha = avg_change - index_change stats.append({ 'concept_id': concept_id, 'concept_name': concept_name, 'change_pct': round(avg_change, 4), 'alpha': round(alpha, 4), # 超额收益 'index_change': round(index_change, 4), 'stock_count': len(changes), 'concept_type': concept_type, 'limit_up_count': limit_up_count, 'limit_down_count': limit_down_count, }) # 按Alpha排序 stats.sort(key=lambda x: x['alpha'], reverse=True) for i, item in enumerate(stats): item['alpha_rank'] = i + 1 return stats # ==================== Alpha 异动检测 ==================== def check_cooldown(concept_id: str, current_time: datetime) -> bool: """检查冷却""" if concept_id in cooldown_cache: last_alert = cooldown_cache[concept_id] if current_time - last_alert < timedelta(minutes=ALPHA_CONFIG['cooldown_minutes']): return True return False def set_cooldown(concept_id: str, current_time: datetime): """设置冷却""" cooldown_cache[concept_id] = current_time def calculate_importance( alpha_zscore: float, alpha: float, alpha_rank: int, total_concepts: int, limit_up_count: int ) -> float: """计算重要性评分""" weights = ALPHA_CONFIG['importance_weights'] # Z-Score 分数(归一化到0-1) zscore_score = min(abs(alpha_zscore) / 5.0, 1.0) # Alpha 绝对值分数 alpha_score = min(abs(alpha) / 3.0, 1.0) # 排名分数(越靠前/越靠后越重要) # alpha_rank 小表示强势,alpha_rank 大表示弱势 rank_ratio = alpha_rank / max(total_concepts, 1) rank_score = max(1 - rank_ratio, rank_ratio) # 两端都重要 # 涨停分数 limit_score = min(limit_up_count / 3.0, 1.0) total = ( weights['alpha_zscore'] * zscore_score + weights['alpha_abs'] * alpha_score + weights['rank_in_minute'] * rank_score + weights['limit_up_count'] * limit_score ) return round(total, 4) def detect_alpha_alerts( stats: list, index_data: dict, trade_date: str, current_time: datetime ) -> list: """ 基于Alpha Z-Score的异动检测 检测逻辑: 1. 如果有足够历史数据(>= min_history):用 Z-Score 判断 2. 如果历史数据不足:用绝对 Alpha 阈值判断 """ global alpha_stats alerts = [] total_concepts = len(stats) for stat in stats: concept_id = stat['concept_id'] concept_name = stat['concept_name'] alpha = stat['alpha'] change_pct = stat['change_pct'] alpha_rank = stat['alpha_rank'] limit_up_count = stat['limit_up_count'] limit_down_count = stat['limit_down_count'] # 更新该概念的Alpha统计 concept_stats = alpha_stats[concept_id] # 计算Z-Score(在更新前计算,用历史数据) alpha_zscore = concept_stats.get_zscore(alpha) # 更新统计 concept_stats.update(alpha, current_time) # Alpha 太小,不值得关注 if abs(alpha) < ALPHA_CONFIG['min_alpha_abs']: continue # 检查冷却 if check_cooldown(concept_id, current_time): continue # 判断是否触发异动 is_alert = False trigger_reason = "" if concept_stats.is_ready(): # 方式1:有足够历史数据,用 Z-Score if abs(alpha_zscore) >= ALPHA_CONFIG['zscore_threshold']: is_alert = True trigger_reason = f"Z={alpha_zscore:.2f}" else: # 方式2:历史数据不足,用绝对 Alpha 阈值 if abs(alpha) >= ALPHA_CONFIG['absolute_alpha_threshold']: is_alert = True trigger_reason = f"Alpha={alpha:+.2f}%" alpha_zscore = alpha / 0.5 # 估算一个Z值(假设std=0.5) if not is_alert: continue # 判断异动类型 if alpha > 0: alert_type = 'alpha_surge' # 超额上涨 else: alert_type = 'alpha_drop' # 超额下跌 # 计算重要性 importance = calculate_importance( alpha_zscore, alpha, alpha_rank, total_concepts, limit_up_count ) alert = { 'concept_id': concept_id, 'concept_name': concept_name, 'alert_type': alert_type, 'alert_time': current_time, 'trade_date': trade_date, 'change_pct': change_pct, 'alpha': alpha, 'alpha_zscore': round(alpha_zscore, 4), 'alpha_mean': round(concept_stats.mean, 4), 'alpha_std': round(concept_stats.std, 4), 'index_change_pct': stat['index_change'], 'alpha_rank': alpha_rank, 'limit_up_count': limit_up_count, 'limit_down_count': limit_down_count, 'stock_count': stat['stock_count'], 'concept_type': stat['concept_type'], 'importance_score': importance, 'index_code': REFERENCE_INDEX, 'index_price': index_data['price'] if index_data else None, } alerts.append(alert) set_cooldown(concept_id, current_time) # 日志 direction = "📈 超额上涨" if alert_type == 'alpha_surge' else "📉 超额下跌" logger.info( f"{direction}: {concept_name} " f"Alpha={alpha:+.2f}% ({trigger_reason}) " f"概念{change_pct:+.2f}% vs 大盘{stat['index_change']:+.2f}%" ) # 按重要性排序,限制数量 alerts.sort(key=lambda x: x['importance_score'], reverse=True) return alerts[:ALPHA_CONFIG['max_alerts_per_minute']] # ==================== 数据持久化 ==================== def save_alerts_to_mysql(alerts: list): """保存异动到MySQL""" if not alerts: return 0 saved = 0 with MYSQL_ENGINE.begin() as conn: for alert in alerts: try: # 映射 alert_type 到数据库格式 db_alert_type = 'surge_up' if alert['alert_type'] == 'alpha_surge' else 'surge_down' insert_sql = text(""" INSERT INTO concept_minute_alert (concept_id, concept_name, alert_time, alert_type, trade_date, change_pct, prev_change_pct, change_delta, limit_up_count, prev_limit_up_count, limit_up_delta, rank_position, prev_rank_position, rank_delta, index_code, index_price, index_change_pct, stock_count, concept_type, zscore, importance_score, extra_info) VALUES (:concept_id, :concept_name, :alert_time, :alert_type, :trade_date, :change_pct, :prev_change_pct, :change_delta, :limit_up_count, 0, 0, :rank_position, NULL, NULL, :index_code, :index_price, :index_change_pct, :stock_count, :concept_type, :zscore, :importance_score, :extra_info) """) params = { 'concept_id': alert['concept_id'], 'concept_name': alert['concept_name'], 'alert_time': alert['alert_time'], 'alert_type': db_alert_type, 'trade_date': alert['trade_date'], 'change_pct': alert['change_pct'], 'prev_change_pct': alert['index_change_pct'], # 用大盘涨幅作为参照 'change_delta': alert['alpha'], # Alpha 作为变化量 'limit_up_count': alert['limit_up_count'], 'rank_position': alert['alpha_rank'], 'index_code': alert['index_code'], 'index_price': alert['index_price'], 'index_change_pct': alert['index_change_pct'], 'stock_count': alert['stock_count'], 'concept_type': alert['concept_type'], 'zscore': alert['alpha_zscore'], 'importance_score': alert['importance_score'], 'extra_info': json.dumps({ 'alpha': alert['alpha'], 'alpha_zscore': alert['alpha_zscore'], 'alpha_mean': alert['alpha_mean'], 'alpha_std': alert['alpha_std'], 'limit_down_count': alert['limit_down_count'], }, ensure_ascii=False) } conn.execute(insert_sql, params) saved += 1 except Exception as e: logger.error(f"保存异动失败: {alert['concept_name']} - {e}") return saved def save_index_snapshot(index_data: dict, trade_date: str): """保存指数快照""" if not index_data: return try: with MYSQL_ENGINE.begin() as conn: conn.execute(text(""" REPLACE INTO index_minute_snapshot (index_code, trade_date, snapshot_time, price, prev_close, change_pct) VALUES (:index_code, :trade_date, :snapshot_time, :price, :prev_close, :change_pct) """), { 'index_code': index_data['code'], 'trade_date': trade_date, 'snapshot_time': index_data['timestamp'], 'price': index_data['price'], 'prev_close': index_data.get('prev_close'), 'change_pct': index_data.get('change_pct') }) except Exception as e: logger.error(f"保存指数快照失败: {e}") # ==================== 交易时间 ==================== def is_trading_time() -> bool: now = datetime.now() if now.weekday() >= 5: return False current_time = now.hour * 60 + now.minute morning = (9 * 60 + 30 <= current_time <= 11 * 60 + 30) afternoon = (13 * 60 <= current_time <= 15 * 60) return morning or afternoon def get_next_update_time() -> int: now = datetime.now() if is_trading_time(): return 60 - now.second hour, minute = now.hour, now.minute if hour < 9 or (hour == 9 and minute < 30): target = now.replace(hour=9, minute=30, second=0) elif (hour == 11 and minute >= 30) or hour == 12: target = now.replace(hour=13, minute=0, second=0) elif hour >= 15: target = (now + timedelta(days=1)).replace(hour=9, minute=30, second=0) else: target = now + timedelta(minutes=1) return max(60, int((target - now).total_seconds())) # ==================== 主运行逻辑 ==================== def run_once(concepts: list, all_stocks: list, trade_date: str, timestamp: datetime = None) -> Tuple[int, int]: """执行一次检测""" timestamp = timestamp or datetime.now() # 获取基准价格 base_prices = get_base_prices(all_stocks, trade_date) if not base_prices: logger.warning("无法获取基准价格") return 0, 0 # 获取当前价格 if timestamp.date() == datetime.now().date(): latest_prices = get_latest_prices(all_stocks) else: latest_prices = get_prices_at_time(all_stocks, timestamp) if not latest_prices: logger.warning("无法获取最新价格") return 0, 0 # 计算股票涨跌幅 stock_changes = {} for code, price_data in latest_prices.items(): if code in base_prices and base_prices[code] > 0: change = (price_data['close'] - base_prices[code]) / base_prices[code] * 100 stock_changes[code] = round(change, 4) if not stock_changes: return 0, 0 # 获取指数数据 index_data = get_index_data(REFERENCE_INDEX, trade_date, timestamp) if not index_data: logger.warning("无法获取指数数据") return 0, 0 index_change = index_data['change_pct'] save_index_snapshot(index_data, trade_date) # 计算概念统计(包含Alpha) stats = calculate_concept_stats(concepts, stock_changes, index_change) # 检测异动 alerts = detect_alpha_alerts(stats, index_data, trade_date, timestamp) # 保存 if alerts: saved = save_alerts_to_mysql(alerts) logger.info(f"💾 保存了 {saved} 条异动") return len(stats), len(alerts) def run_realtime(): """实时运行""" logger.info("=" * 60) logger.info("🚀 启动概念异动检测服务(Alpha Z-Score 方法)") logger.info("=" * 60) logger.info(f"配置: Z-Score阈值={ALPHA_CONFIG['zscore_threshold']}, 冷却={ALPHA_CONFIG['cooldown_minutes']}分钟") # 加载概念 leaf_concepts = get_all_concepts() parent_concepts = load_hierarchy_concepts(leaf_concepts) all_concepts = leaf_concepts + parent_concepts all_stocks = set() for c in all_concepts: all_stocks.update(c['stocks']) all_stocks = list(all_stocks) logger.info(f"监控 {len(all_concepts)} 个概念, {len(all_stocks)} 只股票") total_alerts = 0 last_reload = datetime.now() while True: try: now = datetime.now() trade_date = now.strftime('%Y-%m-%d') # 每小时重载概念 if (now - last_reload).total_seconds() > 3600: leaf_concepts = get_all_concepts() parent_concepts = load_hierarchy_concepts(leaf_concepts) all_concepts = leaf_concepts + parent_concepts all_stocks = list(set(s for c in all_concepts for s in c['stocks'])) last_reload = now logger.info(f"重载概念: {len(all_concepts)} 个") if not is_trading_time(): wait = get_next_update_time() logger.info(f"⏰ 非交易时间,等待 {wait//60} 分钟") time.sleep(min(wait, 300)) continue updated, alert_count = run_once(all_concepts, all_stocks, trade_date) total_alerts += alert_count if alert_count > 0: logger.info(f"📊 本次 {alert_count} 条异动,累计 {total_alerts} 条") time.sleep(60 - datetime.now().second) except KeyboardInterrupt: logger.info("\n停止服务") break except Exception as e: logger.error(f"错误: {e}") import traceback traceback.print_exc() time.sleep(60) def run_backtest(trade_date: str, clear_existing: bool = True): """回测""" global alpha_stats, cooldown_cache logger.info("=" * 60) logger.info(f"🔄 回测: {trade_date} (Alpha Z-Score 方法)") logger.info("=" * 60) # 清空状态 alpha_stats.clear() cooldown_cache.clear() if clear_existing: with MYSQL_ENGINE.begin() as conn: conn.execute(text("DELETE FROM concept_minute_alert WHERE trade_date = :date"), {'date': trade_date}) conn.execute(text("DELETE FROM index_minute_snapshot WHERE trade_date = :date"), {'date': trade_date}) logger.info(f"已清除 {trade_date} 的数据") # 加载概念 leaf_concepts = get_all_concepts() parent_concepts = load_hierarchy_concepts(leaf_concepts) all_concepts = leaf_concepts + parent_concepts all_stocks = list(set(s for c in all_concepts for s in c['stocks'])) logger.info(f"概念: {len(all_concepts)}, 股票: {len(all_stocks)}") # 获取分钟时间戳 client = get_ch_client() result = client.execute(f""" SELECT DISTINCT timestamp FROM stock_minute WHERE toDate(timestamp) = '{trade_date}' ORDER BY timestamp """) timestamps = [row[0] for row in result] if not timestamps: logger.error(f"未找到 {trade_date} 的数据") return logger.info(f"时间点: {len(timestamps)}") total_alerts = 0 for i, ts in enumerate(timestamps): updated, alerts = run_once(all_concepts, all_stocks, trade_date, ts) total_alerts += alerts if (i + 1) % 30 == 0: logger.info(f"进度: {i+1}/{len(timestamps)} ({(i+1)*100//len(timestamps)}%), 异动: {total_alerts}") logger.info("=" * 60) logger.info(f"✅ 回测完成! 检测到 {total_alerts} 条异动") logger.info("=" * 60) def show_status(): """显示状态""" print("\n" + "=" * 60) print("概念异动检测服务 - Alpha Z-Score 方法") print("=" * 60) print(f"\n当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print(f"交易时间: {'是' if is_trading_time() else '否'}") print(f"Z-Score阈值: {ALPHA_CONFIG['zscore_threshold']}") try: with MYSQL_ENGINE.connect() as conn: result = conn.execute(text(""" SELECT alert_type, COUNT(*), AVG(change_delta) FROM concept_minute_alert WHERE trade_date = CURDATE() GROUP BY alert_type """)) print("\n今日异动统计:") rows = list(result) if rows: for row in rows: type_name = {'surge_up': '超额上涨', 'surge_down': '超额下跌'}.get(row[0], row[0]) avg_alpha = f"{row[2]:+.2f}%" if row[2] else "-" print(f" {type_name}: {row[1]} 条 (平均Alpha: {avg_alpha})") else: print(" 暂无异动") print("\n最新异动 (前10条):") result = conn.execute(text(""" SELECT concept_name, alert_type, alert_time, change_pct, change_delta, index_change_pct, zscore FROM concept_minute_alert WHERE trade_date = CURDATE() ORDER BY alert_time DESC LIMIT 10 """)) rows = list(result) if rows: print(f" {'概念':<18} | {'类型':<8} | {'时间':<5} | {'涨幅':>6} | {'Alpha':>6} | {'大盘':>5} | {'Z':>5}") print(" " + "-" * 75) for row in rows: name = (row[0][:16] + '..') if len(row[0]) > 18 else row[0] t = {'surge_up': '超额涨', 'surge_down': '超额跌'}.get(row[1], row[1][:6]) time_s = row[2].strftime('%H:%M') if row[2] else '-' chg = f"{row[3]:+.2f}%" if row[3] else '-' alpha = f"{row[4]:+.2f}%" if row[4] else '-' idx = f"{row[5]:+.1f}%" if row[5] else '-' z = f"{row[6]:.1f}" if row[6] else '-' print(f" {name:<18} | {t:<8} | {time_s:<5} | {chg:>6} | {alpha:>6} | {idx:>5} | {z:>5}") except Exception as e: print(f" 查询失败: {e}") def main(): parser = argparse.ArgumentParser(description='概念异动检测 - Alpha Z-Score 方法') parser.add_argument('command', nargs='?', default='realtime', choices=['realtime', 'once', 'status', 'backtest'], help='命令') parser.add_argument('--date', '-d', type=str, default=None, help='回测日期') parser.add_argument('--keep', '-k', action='store_true', help='保留已有数据') args = parser.parse_args() if args.command == 'realtime': run_realtime() elif args.command == 'once': leaf_concepts = get_all_concepts() parent_concepts = load_hierarchy_concepts(leaf_concepts) all_concepts = leaf_concepts + parent_concepts all_stocks = list(set(s for c in all_concepts for s in c['stocks'])) trade_date = datetime.now().strftime('%Y-%m-%d') run_once(all_concepts, all_stocks, trade_date) elif args.command == 'status': show_status() elif args.command == 'backtest': trade_date = args.date or datetime.now().strftime('%Y-%m-%d') run_backtest(trade_date, not args.keep) if __name__ == "__main__": main()