diff --git a/app.py b/app.py index 56bc3250..cdfdbe86 100755 --- a/app.py +++ b/app.py @@ -6412,6 +6412,10 @@ def get_stock_kline(stock_code): except ValueError: return jsonify({'error': 'Invalid event_time format'}), 400 + # 确保股票代码包含后缀(ClickHouse 中数据带后缀) + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + # 获取股票名称 with engine.connect() as conn: result = conn.execute(text( @@ -7819,7 +7823,7 @@ def get_index_realtime(index_code): }) except Exception as e: - logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}") + app.logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}") return jsonify({ 'success': False, 'error': str(e), @@ -7837,8 +7841,13 @@ def get_index_kline(index_code): except ValueError: return jsonify({'error': 'Invalid event_time format'}), 400 + # 确保指数代码包含后缀(ClickHouse 中数据带后缀) + # 399xxx -> 深交所, 其他(000xxx等)-> 上交所 + if '.' not in index_code: + index_code = f"{index_code}.SZ" if index_code.startswith('39') else f"{index_code}.SH" + # 指数名称(暂无索引表,先返回代码本身) - index_name = index_code + index_name = index_code.split('.')[0] if chart_type == 'minute': return get_index_minute_kline(index_code, event_datetime, index_name) @@ -12044,10 +12053,11 @@ def get_market_summary(seccode): @app.route('/api/stocks/search', methods=['GET']) def search_stocks(): - """搜索股票(支持股票代码、股票简称、拼音首字母)""" + """搜索股票和指数(支持代码、名称搜索)""" try: query = request.args.get('q', '').strip() limit = request.args.get('limit', 20, type=int) + search_type = request.args.get('type', 'all') # all, stock, index if not query: return jsonify({ @@ -12055,73 +12065,132 @@ def search_stocks(): 'error': '请输入搜索关键词' }), 400 + results = [] + with engine.connect() as conn: - test_sql = text(""" - SELECT SECCODE, SECNAME, F001V, F003V, F010V, F011V - FROM ea_stocklist - WHERE SECCODE = '300750' - OR F001V LIKE '%ndsd%' LIMIT 5 - """) - test_result = conn.execute(test_sql).fetchall() + # 搜索指数(优先显示指数,因为通常用户搜索代码时指数更常用) + if search_type in ('all', 'index'): + index_sql = text(""" + SELECT DISTINCT + INDEXCODE as stock_code, + SECNAME as stock_name, + INDEXNAME as full_name, + F018V as exchange + FROM ea_exchangeindex + WHERE ( + UPPER(INDEXCODE) LIKE UPPER(:query_pattern) + OR UPPER(SECNAME) LIKE UPPER(:query_pattern) + OR UPPER(INDEXNAME) LIKE UPPER(:query_pattern) + ) + ORDER BY CASE + WHEN UPPER(INDEXCODE) = UPPER(:exact_query) THEN 1 + WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2 + WHEN UPPER(INDEXCODE) LIKE UPPER(:prefix_pattern) THEN 3 + WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 4 + ELSE 5 + END, + INDEXCODE + LIMIT :limit + """) - # 构建搜索SQL - 支持股票代码、股票简称、拼音简称搜索 - search_sql = text(""" - SELECT DISTINCT SECCODE as stock_code, - SECNAME as stock_name, - F001V as pinyin_abbr, - F003V as security_type, - F005V as exchange, - F011V as listing_status - FROM ea_stocklist - WHERE ( - UPPER(SECCODE) LIKE UPPER(:query_pattern) - OR UPPER(SECNAME) LIKE UPPER(:query_pattern) - OR UPPER(F001V) LIKE UPPER(:query_pattern) - ) - -- 基本过滤条件:只搜索正常的A股和B股 - AND (F011V = '正常上市' OR F010V = '013001') -- 正常上市状态 - AND F003V IN ('A股', 'B股') -- 只搜索A股和B股 - ORDER BY CASE - WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1 - WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2 - WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3 - WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4 - WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5 - WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6 - ELSE 7 - END, - SECCODE LIMIT :limit - """) + index_result = conn.execute(index_sql, { + 'query_pattern': f'%{query}%', + 'exact_query': query, + 'prefix_pattern': f'{query}%', + 'limit': limit + }).fetchall() - result = conn.execute(search_sql, { - 'query_pattern': f'%{query}%', - 'exact_query': query, - 'prefix_pattern': f'{query}%', - 'limit': limit - }).fetchall() + for row in index_result: + results.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'full_name': row.full_name, + 'exchange': row.exchange, + 'isIndex': True, + 'security_type': '指数' + }) - stocks = [] - for row in result: - # 获取当前价格 - current_price, _ = get_latest_price_from_clickhouse(row.stock_code) + # 搜索股票 + if search_type in ('all', 'stock'): + stock_sql = text(""" + SELECT DISTINCT SECCODE as stock_code, + SECNAME as stock_name, + F001V as pinyin_abbr, + F003V as security_type, + F005V as exchange, + F011V as listing_status + FROM ea_stocklist + WHERE ( + UPPER(SECCODE) LIKE UPPER(:query_pattern) + OR UPPER(SECNAME) LIKE UPPER(:query_pattern) + OR UPPER(F001V) LIKE UPPER(:query_pattern) + ) + AND (F011V = '正常上市' OR F010V = '013001') + AND F003V IN ('A股', 'B股') + ORDER BY CASE + WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1 + WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2 + WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3 + WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4 + WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5 + WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6 + ELSE 7 + END, + SECCODE + LIMIT :limit + """) - stocks.append({ - 'stock_code': row.stock_code, - 'stock_name': row.stock_name, - 'current_price': current_price or 0, # 添加当前价格 - 'pinyin_abbr': row.pinyin_abbr, - 'security_type': row.security_type, - 'exchange': row.exchange, - 'listing_status': row.listing_status - }) + stock_result = conn.execute(stock_sql, { + 'query_pattern': f'%{query}%', + 'exact_query': query, + 'prefix_pattern': f'{query}%', + 'limit': limit + }).fetchall() + + for row in stock_result: + results.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'pinyin_abbr': row.pinyin_abbr, + 'security_type': row.security_type, + 'exchange': row.exchange, + 'listing_status': row.listing_status, + 'isIndex': False + }) + + # 如果搜索全部,按相关性重新排序(精确匹配优先) + if search_type == 'all': + def sort_key(item): + code = item['stock_code'].upper() + name = item['stock_name'].upper() + q = query.upper() + # 精确匹配代码优先 + if code == q: + return (0, not item['isIndex'], code) # 指数优先 + # 精确匹配名称 + if name == q: + return (1, not item['isIndex'], code) + # 前缀匹配代码 + if code.startswith(q): + return (2, not item['isIndex'], code) + # 前缀匹配名称 + if name.startswith(q): + return (3, not item['isIndex'], code) + return (4, not item['isIndex'], code) + + results.sort(key=sort_key) + + # 限制总数 + results = results[:limit] return jsonify({ 'success': True, - 'data': stocks, - 'count': len(stocks) + 'data': results, + 'count': len(results) }) except Exception as e: + app.logger.error(f"搜索股票/指数错误: {e}") return jsonify({ 'success': False, 'error': str(e) @@ -12403,7 +12472,21 @@ def get_daily_top_concepts(): top_concepts = [] for concept in data.get('results', []): - # 保持与 /concept-api/search 相同的字段结构 + # 处理 stocks 字段:兼容 {name, code} 和 {stock_name, stock_code} 两种格式 + raw_stocks = concept.get('stocks', []) + formatted_stocks = [] + for stock in raw_stocks: + # 优先使用 stock_name,其次使用 name + stock_name = stock.get('stock_name') or stock.get('name', '') + stock_code = stock.get('stock_code') or stock.get('code', '') + formatted_stocks.append({ + 'stock_name': stock_name, + 'stock_code': stock_code, + 'name': stock_name, # 兼容旧格式 + 'code': stock_code # 兼容旧格式 + }) + + # 保持与 /concept-api/search 相同的字段结构,并添加新字段 top_concepts.append({ 'concept_id': concept.get('concept_id'), 'concept': concept.get('concept'), # 原始字段名 @@ -12414,8 +12497,10 @@ def get_daily_top_concepts(): 'match_type': concept.get('match_type'), 'price_info': concept.get('price_info', {}), # 完整的价格信息 'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段 - 'happened_times': concept.get('happened_times', []), # 历史触发时间 - 'stocks': concept.get('stocks', []), # 返回完整股票列表 + 'tags': concept.get('tags', []), # 标签列表 + 'outbreak_dates': concept.get('outbreak_dates', []), # 爆发日期列表 + 'hierarchy': concept.get('hierarchy'), # 层级信息 {lv1, lv2, lv3} + 'stocks': formatted_stocks, # 返回格式化后的股票列表 'hot_score': concept.get('hot_score') }) @@ -12442,6 +12527,557 @@ def get_daily_top_concepts(): }), 500 +# ==================== 热点概览 API ==================== + +@app.route('/api/market/hotspot-overview', methods=['GET']) +def get_hotspot_overview(): + """ + 获取热点概览数据(用于个股中心的热点概览图表) + 返回:指数分时数据 + 概念异动标注 + + 数据来源: + - 指数分时:ClickHouse index_minute 表 + - 概念异动:MySQL concept_anomaly_hybrid 表(来自 realtime_detector.py) + """ + try: + trade_date = request.args.get('date') + index_code = request.args.get('index', '000001.SH') + + # 如果没有指定日期,使用最新交易日 + if not trade_date: + today = date.today() + if today in trading_days_set: + trade_date = today.strftime('%Y-%m-%d') + else: + target_date = get_trading_day_near_date(today) + trade_date = target_date.strftime('%Y-%m-%d') if target_date else today.strftime('%Y-%m-%d') + + # 1. 获取指数分时数据 + client = get_clickhouse_client() + target_date_obj = datetime.strptime(trade_date, '%Y-%m-%d').date() + + index_data = client.execute( + """ + SELECT timestamp, open, high, low, close, volume + FROM index_minute + WHERE code = %(code)s + AND toDate(timestamp) = %(date)s + ORDER BY timestamp + """, + { + 'code': index_code, + 'date': target_date_obj + } + ) + + # 获取昨收价 + code_no_suffix = index_code.split('.')[0] + prev_close = None + with 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': target_date_obj + }).fetchone() + if prev_result and prev_result[0]: + prev_close = float(prev_result[0]) + + # 格式化指数数据 + index_timeline = [] + for row in index_data: + ts, open_p, high_p, low_p, close_p, vol = row + change_pct = None + if prev_close and close_p: + change_pct = round((float(close_p) - prev_close) / prev_close * 100, 4) + + index_timeline.append({ + 'time': ts.strftime('%H:%M'), + 'timestamp': ts.isoformat(), + 'price': float(close_p) if close_p else None, + 'open': float(open_p) if open_p else None, + 'high': float(high_p) if high_p else None, + 'low': float(low_p) if low_p else None, + 'volume': int(vol) if vol else 0, + 'change_pct': change_pct + }) + + # 2. 获取概念异动数据(优先从 V2 表,fallback 到旧表) + alerts = [] + use_v2 = False + + with engine.connect() as conn: + # 尝试查询 V2 表(时间片对齐 + 持续确认版本) + try: + v2_result = conn.execute(text(""" + SELECT + concept_id, alert_time, trade_date, alert_type, + final_score, rule_score, ml_score, trigger_reason, confirm_ratio, + alpha, alpha_zscore, amt_zscore, rank_zscore, + momentum_3m, momentum_5m, limit_up_ratio, triggered_rules + FROM concept_anomaly_v2 + WHERE trade_date = :trade_date + ORDER BY alert_time + """), {'trade_date': trade_date}) + v2_rows = v2_result.fetchall() + if v2_rows: + use_v2 = True + for row in v2_rows: + triggered_rules = None + if row[16]: + try: + triggered_rules = json.loads(row[16]) if isinstance(row[16], str) else row[16] + except: + pass + + alerts.append({ + 'concept_id': row[0], + 'concept_name': row[0], # 后面会填充 + 'time': row[1].strftime('%H:%M') if row[1] else None, + 'timestamp': row[1].isoformat() if row[1] else None, + 'alert_type': row[3], + 'final_score': float(row[4]) if row[4] else None, + 'rule_score': float(row[5]) if row[5] else None, + 'ml_score': float(row[6]) if row[6] else None, + 'trigger_reason': row[7], + # V2 新增字段 + 'confirm_ratio': float(row[8]) if row[8] else None, + 'alpha': float(row[9]) if row[9] else None, + 'alpha_zscore': float(row[10]) if row[10] else None, + 'amt_zscore': float(row[11]) if row[11] else None, + 'rank_zscore': float(row[12]) if row[12] else None, + 'momentum_3m': float(row[13]) if row[13] else None, + 'momentum_5m': float(row[14]) if row[14] else None, + 'limit_up_ratio': float(row[15]) if row[15] else 0, + 'triggered_rules': triggered_rules, + # 兼容字段 + 'importance_score': float(row[4]) / 100 if row[4] else None, + 'is_v2': True, + }) + except Exception as v2_err: + app.logger.debug(f"V2 表查询失败,使用旧表: {v2_err}") + + # Fallback: 查询旧表 + if not use_v2: + try: + alert_result = conn.execute(text(""" + SELECT + a.concept_id, a.alert_time, a.trade_date, a.alert_type, + a.final_score, a.rule_score, a.ml_score, a.trigger_reason, + a.alpha, a.alpha_delta, a.amt_ratio, a.amt_delta, + a.rank_pct, a.limit_up_ratio, a.stock_count, a.total_amt, + a.triggered_rules + FROM concept_anomaly_hybrid a + WHERE a.trade_date = :trade_date + ORDER BY a.alert_time + """), {'trade_date': trade_date}) + + for row in alert_result: + triggered_rules = None + if row[16]: + try: + triggered_rules = json.loads(row[16]) if isinstance(row[16], str) else row[16] + except: + pass + + limit_up_ratio = float(row[13]) if row[13] else 0 + stock_count = int(row[14]) if row[14] else 0 + limit_up_count = int(limit_up_ratio * stock_count) if stock_count > 0 else 0 + + alerts.append({ + 'concept_id': row[0], + 'concept_name': row[0], + 'time': row[1].strftime('%H:%M') if row[1] else None, + 'timestamp': row[1].isoformat() if row[1] else None, + 'alert_type': row[3], + 'final_score': float(row[4]) if row[4] else None, + 'rule_score': float(row[5]) if row[5] else None, + 'ml_score': float(row[6]) if row[6] else None, + 'trigger_reason': row[7], + 'alpha': float(row[8]) if row[8] else None, + 'alpha_delta': float(row[9]) if row[9] else None, + 'amt_ratio': float(row[10]) if row[10] else None, + 'amt_delta': float(row[11]) if row[11] else None, + 'rank_pct': float(row[12]) if row[12] else None, + 'limit_up_ratio': limit_up_ratio, + 'limit_up_count': limit_up_count, + 'stock_count': stock_count, + 'total_amt': float(row[15]) if row[15] else None, + 'triggered_rules': triggered_rules, + 'importance_score': float(row[4]) / 100 if row[4] else None, + 'is_v2': False, + }) + except Exception as old_err: + app.logger.debug(f"旧表查询也失败: {old_err}") + + # 尝试批量获取概念名称 + if alerts: + concept_ids = list(set(a['concept_id'] for a in alerts)) + concept_names = {} # 初始化 concept_names 字典 + try: + from elasticsearch import Elasticsearch + es_client = Elasticsearch(["http://222.128.1.157:19200"]) + es_result = es_client.mget( + index='concept_library_v3', + body={'ids': concept_ids}, + _source=['concept'] + ) + for doc in es_result.get('docs', []): + if doc.get('found') and doc.get('_source'): + concept_names[doc['_id']] = doc['_source'].get('concept', doc['_id']) + # 更新 alerts 中的概念名称 + for alert in alerts: + if alert['concept_id'] in concept_names: + alert['concept_name'] = concept_names[alert['concept_id']] + except Exception as e: + app.logger.warning(f"获取概念名称失败: {e}") + + # 计算统计信息 + day_high = max([d['price'] for d in index_timeline if d['price']], default=None) + day_low = min([d['price'] for d in index_timeline if d['price']], default=None) + latest_price = index_timeline[-1]['price'] if index_timeline else None + latest_change_pct = index_timeline[-1]['change_pct'] if index_timeline else None + + return jsonify({ + 'success': True, + 'data': { + 'trade_date': trade_date, + 'index': { + 'code': index_code, + 'name': '上证指数' if index_code == '000001.SH' else index_code, + 'prev_close': prev_close, + 'latest_price': latest_price, + 'change_pct': latest_change_pct, + 'high': day_high, + 'low': day_low, + 'timeline': index_timeline + }, + 'alerts': alerts, + 'alert_count': len(alerts), + 'alert_summary': { + 'surge': len([a for a in alerts if a['alert_type'] == 'surge']), + 'surge_up': len([a for a in alerts if a['alert_type'] == 'surge_up']), + 'surge_down': len([a for a in alerts if a['alert_type'] == 'surge_down']), + 'limit_up': len([a for a in alerts if a['alert_type'] == 'limit_up']), + 'volume_spike': len([a for a in alerts if a['alert_type'] == 'volume_spike']), + 'rank_jump': len([a for a in alerts if a['alert_type'] == 'rank_jump']) + } + } + }) + + except Exception as e: + import traceback + error_trace = traceback.format_exc() + app.logger.error(f"获取热点概览数据失败: {error_trace}") + return jsonify({ + 'success': False, + 'error': str(e), + 'traceback': error_trace # 临时返回完整错误信息用于调试 + }), 500 + + +@app.route('/api/concept//stocks', methods=['GET']) +def get_concept_stocks(concept_id): + """ + 获取概念的相关股票列表(带实时涨跌幅) + + Args: + concept_id: 概念 ID(来自 ES concept_library_v3) + + Returns: + - stocks: 股票列表 [{code, name, reason, change_pct}, ...] + """ + try: + from elasticsearch import Elasticsearch + from clickhouse_driver import Client + + # 1. 从 ES 获取概念的股票列表 + es_client = Elasticsearch(["http://222.128.1.157:19200"]) + es_result = es_client.get(index='concept_library_v3', id=concept_id) + + if not es_result.get('found'): + return jsonify({ + 'success': False, + 'error': f'概念 {concept_id} 不存在' + }), 404 + + source = es_result.get('_source', {}) + concept_name = source.get('concept', concept_id) + raw_stocks = source.get('stocks', []) + + if not raw_stocks: + return jsonify({ + 'success': True, + 'data': { + 'concept_id': concept_id, + 'concept_name': concept_name, + 'stocks': [] + } + }) + + # 提取股票代码和原因 + stocks_info = [] + stock_codes = [] + for s in raw_stocks: + if isinstance(s, dict): + code = s.get('code', '') + if code and len(code) == 6: + stocks_info.append({ + 'code': code, + 'name': s.get('name', ''), + 'reason': s.get('reason', '') + }) + stock_codes.append(code) + + if not stock_codes: + return jsonify({ + 'success': True, + 'data': { + 'concept_id': concept_id, + 'concept_name': concept_name, + 'stocks': stocks_info + } + }) + + # 2. 获取最新交易日和前一交易日 + today = datetime.now().date() + trading_day = None + prev_trading_day = None + + with engine.connect() as conn: + # 获取最新交易日 + result = conn.execute(text(""" + SELECT EXCHANGE_DATE FROM trading_days + WHERE EXCHANGE_DATE <= :today + ORDER BY EXCHANGE_DATE DESC LIMIT 1 + """), {"today": today}).fetchone() + if result: + trading_day = result[0].date() if hasattr(result[0], 'date') else result[0] + + # 获取前一交易日 + if trading_day: + result = conn.execute(text(""" + SELECT EXCHANGE_DATE FROM trading_days + WHERE EXCHANGE_DATE < :date + ORDER BY EXCHANGE_DATE DESC LIMIT 1 + """), {"date": trading_day}).fetchone() + if result: + prev_trading_day = result[0].date() if hasattr(result[0], 'date') else result[0] + + # 3. 从 MySQL ea_trade 获取前一交易日收盘价(F007N) + prev_close_map = {} + if prev_trading_day and stock_codes: + with engine.connect() as conn: + placeholders = ','.join([f':code{i}' for i in range(len(stock_codes))]) + params = {f'code{i}': code for i, code in enumerate(stock_codes)} + params['trade_date'] = prev_trading_day + + result = conn.execute(text(f""" + SELECT SECCODE, F007N + FROM ea_trade + WHERE SECCODE IN ({placeholders}) + AND TRADEDATE = :trade_date + AND F007N > 0 + """), params).fetchall() + + prev_close_map = {row[0]: float(row[1]) for row in result if row[1]} + + # 4. 从 ClickHouse 获取最新价格 + current_price_map = {} + if stock_codes: + try: + ch_client = Client( + host='127.0.0.1', + port=9000, + user='default', + password='Zzl33818!', + database='stock' + ) + + # 转换为 ClickHouse 格式 + ch_codes = [] + code_mapping = {} + for code in stock_codes: + if code.startswith('6'): + ch_code = f"{code}.SH" + elif code.startswith('0') or code.startswith('3'): + ch_code = f"{code}.SZ" + else: + ch_code = f"{code}.BJ" + ch_codes.append(ch_code) + code_mapping[ch_code] = code + + ch_codes_str = "','".join(ch_codes) + + # 查询当天最新价格 + query = f""" + SELECT code, close + FROM stock_minute + WHERE code IN ('{ch_codes_str}') + AND toDate(timestamp) = today() + ORDER BY timestamp DESC + LIMIT 1 BY code + """ + result = ch_client.execute(query) + + for row in result: + ch_code, close_price = row + if ch_code in code_mapping and close_price: + original_code = code_mapping[ch_code] + current_price_map[original_code] = float(close_price) + + except Exception as ch_err: + app.logger.warning(f"ClickHouse 获取价格失败: {ch_err}") + + # 5. 计算涨跌幅并合并数据 + result_stocks = [] + for stock in stocks_info: + code = stock['code'] + prev_close = prev_close_map.get(code) + current_price = current_price_map.get(code) + + change_pct = None + if prev_close and current_price and prev_close > 0: + change_pct = round((current_price - prev_close) / prev_close * 100, 2) + + result_stocks.append({ + 'code': code, + 'name': stock['name'], + 'reason': stock['reason'], + 'change_pct': change_pct, + 'price': current_price, + 'prev_close': prev_close + }) + + # 按涨跌幅排序(涨停优先) + result_stocks.sort(key=lambda x: x.get('change_pct') if x.get('change_pct') is not None else -999, reverse=True) + + return jsonify({ + 'success': True, + 'data': { + 'concept_id': concept_id, + 'concept_name': concept_name, + 'stock_count': len(result_stocks), + 'trading_day': str(trading_day) if trading_day else None, + 'stocks': result_stocks + } + }) + + except Exception as e: + import traceback + app.logger.error(f"获取概念股票失败: {traceback.format_exc()}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/concept-alerts', methods=['GET']) +def get_concept_alerts(): + """ + 获取概念异动列表(支持分页和筛选) + """ + try: + trade_date = request.args.get('date') + alert_type = request.args.get('type') # surge/limit_up/rank_jump + concept_type = request.args.get('concept_type') # leaf/lv1/lv2/lv3 + limit = request.args.get('limit', 50, type=int) + offset = request.args.get('offset', 0, type=int) + + # 构建查询条件 + conditions = [] + params = {'limit': limit, 'offset': offset} + + if trade_date: + conditions.append("trade_date = :trade_date") + params['trade_date'] = trade_date + else: + conditions.append("trade_date = CURDATE()") + + if alert_type: + conditions.append("alert_type = :alert_type") + params['alert_type'] = alert_type + + if concept_type: + conditions.append("concept_type = :concept_type") + params['concept_type'] = concept_type + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + with engine.connect() as conn: + # 获取总数 + count_sql = text(f"SELECT COUNT(*) FROM concept_minute_alert WHERE {where_clause}") + total = conn.execute(count_sql, params).scalar() + + # 获取数据 + query_sql = text(f""" + SELECT + id, 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_price, index_change_pct, + stock_count, concept_type, extra_info + FROM concept_minute_alert + WHERE {where_clause} + ORDER BY alert_time DESC + LIMIT :limit OFFSET :offset + """) + + result = conn.execute(query_sql, params) + + alerts = [] + for row in result: + extra_info = None + if row[19]: + try: + extra_info = json.loads(row[19]) if isinstance(row[19], str) else row[19] + except: + pass + + alerts.append({ + 'id': row[0], + 'concept_id': row[1], + 'concept_name': row[2], + 'alert_time': row[3].isoformat() if row[3] else None, + 'alert_type': row[4], + 'trade_date': row[5].isoformat() if row[5] else None, + 'change_pct': float(row[6]) if row[6] else None, + 'prev_change_pct': float(row[7]) if row[7] else None, + 'change_delta': float(row[8]) if row[8] else None, + 'limit_up_count': row[9], + 'prev_limit_up_count': row[10], + 'limit_up_delta': row[11], + 'rank_position': row[12], + 'prev_rank_position': row[13], + 'rank_delta': row[14], + 'index_price': float(row[15]) if row[15] else None, + 'index_change_pct': float(row[16]) if row[16] else None, + 'stock_count': row[17], + 'concept_type': row[18], + 'extra_info': extra_info + }) + + return jsonify({ + 'success': True, + 'data': alerts, + 'total': total, + 'limit': limit, + 'offset': offset + }) + + except Exception as e: + import traceback + app.logger.error(f"获取概念异动列表失败: {traceback.format_exc()}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + @app.route('/api/market/rise-analysis/', methods=['GET']) def get_rise_analysis(seccode): """获取股票涨幅分析数据(从 Elasticsearch 获取)""" diff --git a/concept_hierarchy_v3.json b/concept_hierarchy_v3.json index a7eb2200..e5b74263 100644 --- a/concept_hierarchy_v3.json +++ b/concept_hierarchy_v3.json @@ -2,435 +2,602 @@ "hierarchy": [ { "lv1": "人工智能", - "lv1_id": "lv1_1", + "lv1_id": "ai", "children": [ { - "lv2": "AI基础设施", - "lv2_id": "lv2_1_1", + "lv2": "AI芯片与存储", + "lv2_id": "ai_chips_memory", + "concepts": [ + "AI算力芯片", + "AI芯片", + "GPU概念股", + "HBM", + "SRAM存储", + "RISC-V", + "TPU芯片", + "中昊芯英概念股", + "国产算力芯片", + "国产GPU", + "国产芯片参股公司", + "摩尔线程", + "摩尔线程IPO", + "沐曦集成", + "超威半导体AMD", + "英伟达概念", + "英伟达H20", + "芯片替代", + "谷歌", + "谷歌概念", + "阿里AI芯片", + "利基型存储DDR4", + "存储芯片产业", + "存储", + "存储芯片" + ] + }, + { + "lv2": "AI硬件与基础设施", + "lv2_id": "ai_hardware_infra", "children": [ { - "lv3": "AI算力硬件", - "lv3_id": "lv3_1_1_1", - "concepts": [ - "AI一体机", - "AI算力芯片", - "AI芯片", - "DeepSeek智算一体机", - "GPU概念股", - "TPU芯片", - "一体机核心标的弹性测算", - "中昊芯英概念股", - "国产算力芯片", - "国产GPU", - "服务器", - "昇腾推理一体机", - "摩尔线程", - "摩尔线程IPO", - "沐曦集成", - "阿里AI芯片" - ] - }, - { - "lv3": "AI关键组件", - "lv3_id": "lv3_1_1_2", + "lv3": "AI PCB与封装", + "lv3_id": "ai_pcb_packaging", "concepts": [ "AI PCB", "AI PCB英伟达M9", - "AI服务器钽电容", - "HBM", - "OCS光电路交换机", "PCB", "PCB设备及耗材", - "SRAM存储", - "光纤", - "光通信CPO", - "光通信", - "光芯片", - "光纤列阵单元FAU", - "博通交换机", - "改良型半加成工艺mSAP", - "服务器零部件", - "硅光技术", - "空芯光纤", - "薄膜铌酸锂", - "存储", - "存储芯片", - "存储芯片产业", - "铜互连", - "铜连接", - "钽电容", - "忆阻器", - "利基型存储DDR4" + "改良型半加成工艺mSAP" ] }, { - "lv3": "AI配套设施", - "lv3_id": "lv3_1_1_3", + "lv3": "AI一体机", + "lv3_id": "ai_integrated_machines", + "concepts": [ + "AI一体机", + "DeepSeek智算一体机", + "一体机核心标的弹性测算", + "昇腾推理一体机" + ] + }, + { + "lv3": "AI服务器组件", + "lv3_id": "ai_server_components", + "concepts": [ + "AI服务器钽电容", + "钽电容", + "功率半导体", + "电池备份单元", + "超级电容器" + ] + }, + { + "lv3": "AI数据中心与电力", + "lv3_id": "ai_data_center_power", "concepts": [ "AIDC供配电设备弹性", "数据中心", - "数据中心液冷", "数据中心电力设备", - "微泵液冷", - "微通道水冷板", - "液冷", - "液冷数据中心", - "液态金属散热", - "电池备份单元", - "电磁屏蔽", - "超级电容器", "柴油发电机", - "钻石散热", "英伟达电源方案" ] }, { - "lv3": "算力网络与服务", - "lv3_id": "lv3_1_1_4", + "lv3": "AI散热", + "lv3_id": "ai_cooling", "concepts": [ - "中国星际之门芜湖", - "上海算力", - "四川算力", - "大厂算力订单", - "字节算力", - "星际之门概念", - "杭州算力大会", - "核心城市智算算力", - "毫秒用算", - "算力" + "微泵液冷", + "微通道水冷板", + "数据中心液冷", + "液态金属散热", + "液冷", + "钻石散热", + "电磁屏蔽" + ] + }, + { + "lv3": "AI网络设备", + "lv3_id": "ai_network_equipment", + "concepts": [ + "博通交换机", + "铜连接", + "铜互连", + "铜互联" ] } ] }, { - "lv2": "AI模型与软件", - "lv2_id": "lv2_1_2", + "lv2": "AI光通信", + "lv2_id": "ai_optical_comm", + "concepts": [ + "光纤", + "光通信CPO", + "光通信", + "光芯片", + "光纤列阵单元FAU", + "OCS光电路交换机", + "硅光技术", + "空芯光纤", + "薄膜铌酸锂" + ] + }, + { + "lv2": "AI算力基础设施", + "lv2_id": "ai_computing_power_infra", + "concepts": [ + "中国星际之门芜湖", + "上海算力", + "四川算力", + "大厂算力订单", + "字节算力", + "核心城市智算算力", + "毫秒用算", + "星际之门概念", + "杭州算力大会", + "甲骨文概念股", + "算力" + ] + }, + { + "lv2": "AI模型与算法", + "lv2_id": "ai_models_algorithms", "concepts": [ "DeepSeek FP8", "DeepSeek", "DeepSeek、国产算力", - "KIMI", "MOE模型", "Minimax", - "Nano Banana", "SORA概念", "国产大模型", + "阶跃星辰" + ] + }, + { + "lv2": "AI应用与服务", + "lv2_id": "ai_applications_services", + "concepts": [ + "AI4S", + "AI应用AI语料", + "AI编程", + "Nano Banana", + "KIMI", + "低代码", + "内容审核概念", "文生视频", - "秘塔AI", - "阶跃星辰", + "智象未来", + "版权", + "秘塔AI" + ] + }, + { + "lv2": "AI智能体与陪伴", + "lv2_id": "ai_agents_companions", + "concepts": [ + "AI伴侣", + "AI成人陪伴", + "AI应用陪伴智能体", + "AI智能体", + "AI应用智能体", + "AI智能体AI应用", + "AI语音助手", + "AI陪伴", + "Manus", + "字节AI陪伴", + "开发智能体" + ] + }, + { + "lv2": "AI通用概念", + "lv2_id": "ai_general_concepts", + "concepts": [ + "AI-细分延伸更新", + "AI合集", + "物理AI", "马斯克Grok3大模型" ] }, { - "lv2": "AI应用", - "lv2_id": "lv2_1_3", - "children": [ - { - "lv3": "智能体与陪伴", - "lv3_id": "lv3_1_3_1", - "concepts": [ - "AI伴侣", - "AI成人陪伴", - "AI应用陪伴智能体", - "AI智能体", - "AI应用智能体", - "AI智能体AI应用", - "AI语音助手", - "AI陪伴", - "Manus", - "字节AI陪伴", - "开发智能体" - ] - }, - { - "lv3": "行业应用", - "lv3_id": "lv3_1_3_2", - "concepts": [ - "AI4S", - "AI应用AI语料", - "AI编程", - "低代码", - "内容审核概念", - "物理AI" - ] - } - ] - }, - { - "lv2": "AI生态系统", - "lv2_id": "lv2_1_4", - "children": [ - { - "lv3": "通用生态", - "lv3_id": "lv3_1_4_1", - "concepts": [ - "云计算各厂商云", - "微软Azure云平台", - "甲骨文概念股", - "英伟达代理", - "英伟达概念", - "英伟达H20", - "超威半导体AMD", - "谷歌", - "谷歌概念" - ] - }, - { - "lv3": "阿里生态", - "lv3_id": "lv3_1_4_2", - "concepts": [ - "通义千问阿里云", - "阿里云", - "阿里云通义千问", - "阿里AI千问、灵光", - "阿里“千问”项目", - "阿里AI来听" - ] - }, - { - "lv3": "腾讯生态", - "lv3_id": "lv3_1_4_3", - "concepts": [ - "腾讯元宝", - "腾讯大模型", - "腾讯混元大模型", - "腾讯云及大模型合作公司" - ] - }, - { - "lv3": "字节跳动生态", - "lv3_id": "lv3_1_4_4", - "concepts": [ - "努比亚手机", - "华为抖音支付", - "字节概念豆包AI手机", - "字节豆包概念股", - "抖音概念", - "豆包大模型" - ] - } - ] - }, - { - "lv2": "AI综合与趋势", - "lv2_id": "lv2_1_5", + "lv2": "云平台与大模型", + "lv2_id": "cloud_platforms_large_models", "concepts": [ - "AI-细分延伸更新", - "AI合集" + "云手机", + "云计算各厂商云", + "微软Azure云平台", + "腾讯元宝", + "腾讯大模型", + "腾讯混元大模型", + "腾讯云及大模型合作公司", + "通义千问阿里云", + "阿里云通义千问", + "阿里AI千问、灵光", + "阿里“千问”项目", + "阿里AI来听", + "阿里云" ] } ] }, { "lv1": "半导体", - "lv1_id": "lv1_2", + "lv1_id": "semiconductor", "children": [ { - "lv2": "半导体设备", - "lv2_id": "lv2_2_1", + "lv2": "半导体制造与材料", + "lv2_id": "semiconductor_mfg_materials", "concepts": [ "EDA", + "GAA晶体管", + "PSPI", "上海微电子", - "光刻机", + "先进陶瓷", + "光刻胶", "光刻机宇量昇", + "光刻机", + "半导体抛光液", + "半导体封测", + "半导体设计", + "半导体材料", + "半导体产业链", "半导体设备", + "半导体混合键合技术", + "国产光刻胶", + "国产半导体", "大湾区芯片展览会-新凯莱", "新凯来概念股", "新凯来示波器", - "电子束光刻机“羲之”" - ] - }, - { - "lv2": "半导体材料", - "lv2_id": "lv2_2_2", - "concepts": [ - "PSPI", - "先进陶瓷", - "光刻胶", - "半导体抛光液", - "半导体材料", - "国产光刻胶", - "石英砂", - "磷化铟" - ] - }, - { - "lv2": "芯片设计与制造", - "lv2_id": "lv2_2_3", - "concepts": [ - "ASIC", - "GAA晶体管", - "ISP视觉", - "RISC-V", - "功率半导体", - "半导体设计", - "第三代半导体", - "端侧AI芯片", - "碳化硅", - "英诺赛科概念股" - ] - }, - { - "lv2": "先进封装", - "lv2_id": "lv2_2_4", - "concepts": [ - "半导体封测", - "半导体混合键合技术", + "电子束光刻机“羲之”", "玻璃基板", + "石英砂", + "磷化铟", + "盛合晶微概念股", "盛合晶微", - "盛合晶微概念股" - ] - }, - { - "lv2": "重点企业与IPO", - "lv2_id": "lv2_2_5", - "concepts": [ - "地平线", - "地平线概念", + "第三代半导体", "紫光展锐IPO", + "碳化硅", + "科特估半导体", + "英诺赛科概念股", + "超硬材料", "长鑫存储", - "长鑫、长江产业链", - "高通概念" + "长鑫、长江产业链" ] }, { - "lv2": "综合与政策", - "lv2_id": "lv2_2_6", + "lv2": "半导体政策与地缘", + "lv2_id": "semiconductor_policy_geopolitics", "concepts": [ - "半导体产业链", - "国产半导体", - "国产芯片参股公司", - "科特估半导体", - "芯片替代", - "英特尔概念股" + "中美关系", + "出口管制" ] } ] }, { - "lv1": "机器人", - "lv1_id": "lv1_3", + "lv1": "机器人与智能制造", + "lv1_id": "robotics_smart_mfg", "children": [ { - "lv2": "人形机器人整机", - "lv2_id": "lv2_3_1", + "lv2": "人形机器人整机与生态", + "lv2_id": "humanoid_robot_systems_ecosystems", "concepts": [ "Optimus特斯拉机器人", "乐聚机器人", - "人形机器人", + "人形机器产业链", "人形机器人Figure", + "人形机器人", "云深处", "优必选", + "人形机器人万向节", + "人形机器人核心标的概览", + "人形机器人核心标的估值弹性测算", "优必选机器人", "华为人形机器人", "各厂商机器人", "奇瑞机器人潜在产业链", "天太机器人", - "天工机器人", "宇树人形机器人", "宇树机器人", + "小米智元机器人产业链机构机构版", + "天工机器人", "小鹏机器人", "小米机器人", - "小米智元机器人产业链机构版", "开普勒机器人", - "松延动力机器人", - "特斯拉人形机器人", + "工业机器人", "智元机器人", + "松延动力机器人", + "机器狗四足机器人", + "特斯拉人形机器人价值量", + "特斯拉人形机器人", + "特斯拉产业链", + "特斯拉人形机器人弹性测算", "荣耀华为人形机器人", + "美的库卡机器人", "赛力斯机器人" ] }, { - "lv2": "机器人核心零部件", - "lv2_id": "lv2_3_2", + "lv2": "机器人核心部件与材料", + "lv2_id": "robotics_core_components_materials", "concepts": [ + "MIM概念", "PCB轴向磁通电机", "人形机器人-滚柱丝杆丝杠", - "人形机器人万向节", + "人形机器人轻量化-PEEK材料", "人形机器人腱绳", - "机电", - "机器人皮肤仿生皮肤", + "冷锻产业链", + "机器人轻量化-PEEK", + "机器人轻量化—碳纤维", + "机器人零部件加工设备", + "机器人轻量化-镁铝合金", "摆线减速器", - "电子皮肤", - "轴向磁通电机" + "金属粉末注射成形MIM", + "超高分子量聚乙烯纤维", + "机器人氮化镓", + "轴向磁通电机", + "机电" ] }, { - "lv2": "机器人产业链", - "lv2_id": "lv2_3_3", - "children": [ - { - "lv3": "综合与价值链", - "lv3_id": "lv3_3_3_1", - "concepts": [ - "人形机器产业链", - "人形机器人核心标的估值弹性测算", - "人形机器人核心标的概览", - "特斯拉人形机器人价值量", - "特斯拉人形机器人弹性测算", - "特斯拉产业链" - ] - }, - { - "lv3": "轻量化材料", - "lv3_id": "lv3_3_3_2", - "concepts": [ - "人形机器人轻量化-PEEK", - "人形机器人轻量化-PEEK材料", - "机器人轻量化-镁铝合金", - "机器人轻量化—碳纤维", - "超高分子量聚乙烯纤维" - ] - }, - { - "lv3": "制造工艺与设备", - "lv3_id": "lv3_3_3_3", - "concepts": [ - "MIM概念", - "冷锻产业链", - "机器人零部件加工设备", - "金属粉末注射成形MIM" - ] - } + "lv2": "机器人感知与交互", + "lv2_id": "robotics_perception_interaction", + "concepts": [ + "机器人动作捕捉", + "机器人皮肤仿生皮肤", + "电子皮肤" ] }, { - "lv2": "机器人软件与AI", - "lv2_id": "lv2_3_4", + "lv2": "机器人AI与控制", + "lv2_id": "robotics_ai_control", "concepts": [ "机器人-神经网络", - "机器人动作捕捉" + "神经网络" ] }, { - "lv2": "其他类型机器人", - "lv2_id": "lv2_3_5", + "lv2": "信创与工业自动化", + "lv2_id": "xinchuang_industrial_automation", "concepts": [ - "外骨骼机器人", - "工业机器人", - "机器狗四足机器人", - "美的库卡机器人" + "信创概念", + "关键软件", + "国产信创概览", + "工业设备更新", + "工业软件", + "工业母机", + "灯塔工厂", + "自主可控", + "软件自主可控", + "设备更新" ] } ] }, { - "lv1": "消费电子", - "lv1_id": "lv1_4", + "lv1": "新能源与电力", + "lv1_id": "new_energy_power", "children": [ { - "lv2": "智能终端(端侧AI)", - "lv2_id": "lv2_4_1", + "lv2": "光伏产业", + "lv2_id": "photovoltaic_industry", "concepts": [ - "2025CES参展公司", - "AI PC", - "AIPC", - "AI手机" + "N型产业链", + "光伏", + "光伏行业兼并重组", + "光伏产业链", + "反内卷光伏", + "叠层钙钛矿", + "钙钛矿电池" ] }, { - "lv2": "XR与空间计算", - "lv2_id": "lv2_4_2", + "lv2": "先进电池技术", + "lv2_id": "advanced_battery_tech", + "concepts": [ + "固态电池-硅基负极", + "固态电池-硫化物", + "固态电池", + "固态电池设备", + "固态电池负极集流体材料-铜箔", + "固态电池产业链", + "复合集流体", + "富锂锰基材料", + "硅基负极材料", + "钠离子电池", + "隔膜", + "陶瓷隔膜骨架膜" + ] + }, + { + "lv2": "充电与换电", + "lv2_id": "charging_battery_swapping", + "concepts": [ + "充电桩", + "华为智能充电", + "华为智能充电网络", + "换电", + "换电重卡", + "比亚迪兆瓦闪充" + ] + }, + { + "lv2": "电力基础设施与AI能源", + "lv2_id": "power_infra_ai_energy", + "concepts": [ + "北美缺电AI电力", + "变压器出海", + "固体氧化物燃料电池-SOFC", + "固态变压器SST", + "燃料电池", + "燃气设备", + "电力产业链", + "燃气轮机HRSG", + "电力电网", + "电力设备", + "电力" + ] + }, + { + "lv2": "核能技术与应用", + "lv2_id": "nuclear_energy_applications", + "concepts": [ + "可控核聚变", + "微型核电", + "核电钍基熔盐堆", + "核聚变超导", + "核电产业链", + "超聚变" + ] + }, + { + "lv2": "核能通用概念", + "lv2_id": "nuclear_energy_general_concepts", + "concepts": [ + "高温概念" + ] + } + ] + }, + { + "lv1": "智能出行与交通", + "lv1_id": "smart_mobility_trans", + "children": [ + { + "lv2": "智能驾驶", + "lv2_id": "autonomous_driving", + "concepts": [ + "Robotaxi", + "小马智行", + "无人驾驶公交", + "文远知行", + "智能驾驶产业链", + "无人驾驶-线控转向", + "无人驾驶", + "特斯拉FSD", + "特斯拉RoboTaxi概念", + "矿山智驾", + "特斯拉无人驾驶出租车Robotaxi", + "自动驾驶" + ] + }, + { + "lv2": "无人物流", + "lv2_id": "unmanned_logistics", + "concepts": [ + "京东物流Robovan", + "无人物流", + "无人环卫车", + "无人物流车九识智能", + "菜鸟无人物流车" + ] + }, + { + "lv2": "智能驾驶基础设施", + "lv2_id": "autonomous_driving_infra", + "concepts": [ + "机器人充电", + "汽车无线充电" + ] + }, + { + "lv2": "智能出行服务", + "lv2_id": "smart_mobility_services", + "concepts": [ + "网约车" + ] + }, + { + "lv2": "智能驾驶与整车", + "lv2_id": "smart_driving_oems", + "concepts": [ + "mobileye替代概念", + "地平线概念", + "地平线", + "比亚迪产业链", + "比亚迪智驾", + "理想汽车" + ] + }, + { + "lv2": "低空经济", + "lv2_id": "low_altitude_economy", + "concepts": [ + "eVTOL材料", + "亿航智能订单量", + "低空经济&飞行汽车", + "低空经济", + "低空管控", + "低空物流", + "低空经济亿航智能", + "低空设计", + "低空经济产业链汇集", + "小鹏汇天", + "小鹏汇天供应商", + "空中成像", + "长安飞行汽车机器人概念", + "飞行汽车eVTOL" + ] + } + ] + }, + { + "lv1": "通信与航天", + "lv1_id": "comm_aerospace", + "children": [ + { + "lv2": "5G与未来通信", + "lv2_id": "5g_future_comm", + "concepts": [ + "5G毫米波", + "5G-A", + "5.5G", + "eSIM概念", + "通感一体" + ] + }, + { + "lv2": "卫星通信与应用", + "lv2_id": "satellite_comm_applications", + "concepts": [ + "6G概念", + "低轨卫星通信华为", + "北斗信使", + "北斗导航", + "商业航天卫星通信", + "卫星出海", + "卫星互联网", + "商业航天", + "太空行走", + "太空旅行", + "太空算力", + "手机直连卫星", + "星河动力", + "星网", + "蓝箭航天朱雀三号", + "长征十二号甲", + "SpaceX", + "卫星能源太阳翼" + ] + }, + { + "lv2": "航空航天", + "lv2_id": "aviation_space", + "concepts": [ + "九天无人机", + "凌空天行", + "大飞机", + "珠海航展", + "空客合作" + ] + } + ] + }, + { + "lv1": "消费电子与XR", + "lv1_id": "consumer_electronics_xr", + "children": [ + { + "lv2": "智能穿戴与XR", + "lv2_id": "smart_wearables_xr", "concepts": [ "AI手势识别", "AI眼镜", @@ -440,665 +607,56 @@ "META智能眼镜", "MR", "Rokid AR", - "消费电子-玄玑感知系统", + "小米眼镜", "智能穿戴", "智能眼镜", - "小米眼镜", - "阿里夸克AI眼镜", - "谷歌AI眼镜-合作XREAL" - ] - }, - { - "lv2": "华为产业链", - "lv2_id": "lv2_4_3", - "children": [ - { - "lv3": "终端产品", - "lv3_id": "lv3_4_3_1", - "concepts": [ - "华为P70", - "华为Mate80", - "华为Pura70", - "华为Mate70手表", - "华为MATE70" - ] - }, - { - "lv3": "技术与生态", - "lv3_id": "lv3_4_3_2", - "concepts": [ - "华为", - "华为5G", - "华为AI容器", - "华为云", - "华为昇腾", - "华为昇腾超节点", - "华为通信大模型", - "华为海思星闪", - "华为鸿蒙", - "华为鸿蒙甄选与支付", - "华字辈", - "昇腾异构计算架构-CANN", - "鸿蒙PC" - ] - }, - { - "lv3": "芯片与算力", - "lv3_id": "lv3_4_3_3", - "concepts": [ - "华为910C", - "华为AI存储", - "华为存储OceanStor", - "华为麒麟芯片", - "磁电存储" - ] - } - ] - }, - { - "lv2": "苹果产业链", - "lv2_id": "lv2_4_4", - "concepts": [ - "果链OPEN AI复用", + "消费电子-玄玑感知系统", "苹果MR产业链", + "谷歌AI眼镜-合作XREAL", + "阿里夸克AI眼镜", + "雷鸟创新光波导" + ] + }, + { + "lv2": "AI终端与芯片", + "lv2_id": "ai_devices_chips", + "concepts": [ + "2025CES参展公司", + "AI PC", + "AIPC", + "AI手机", + "ASIC", + "ISP视觉", + "果链OPEN AI复用", + "端侧AI芯片", "苹果供应商核心公司", "苹果机器人", - "苹果手机产业链" + "苹果手机产业链", + "高通概念" ] }, { "lv2": "新型显示技术", - "lv2_id": "lv2_4_5", + "lv2_id": "new_display_technologies", "concepts": [ "华为三折叠屏", "折叠屏", "显影液及硅基OLED", "苹果OLED潜在受益", "苹果折叠屏", - "面板" + "面板", + "TV面板LCD" ] } ] }, { - "lv1": "智能驾驶与汽车", - "lv1_id": "lv1_5", + "lv1": "文化传媒与娱乐", + "lv1_id": "culture_media_ent", "children": [ { - "lv2": "自动驾驶解决方案", - "lv2_id": "lv2_5_1", - "concepts": [ - "Robotaxi", - "京东物流Robovan", - "小马智行", - "文远知行", - "无人物流", - "无人物流车九识智能", - "无人驾驶", - "特斯拉FSD", - "特斯拉RoboTaxi概念", - "特斯拉无人驾驶出租车Robotaxi", - "自动驾驶", - "菜鸟无人物流车" - ] - }, - { - "lv2": "智能汽车产业链", - "lv2_id": "lv2_5_2", - "concepts": [ - "比亚迪产业链", - "比亚迪智驾", - "孙潇雅团队概念股", - "小米YU7供应链弹性测算", - "小米大模型", - "小米概念", - "小米算力AI互联", - "小米汽车产业链", - "小米汽车产业链弹性", - "小米汽车产业链弹性测算", - "小鹏产业链", - "理想汽车" - ] - }, - { - "lv2": "车路协同与特定场景", - "lv2_id": "lv2_5_3", - "concepts": [ - "无人驾驶-线控转向", - "无人驾驶公交", - "无人环卫车", - "矿山智驾", - "车路云-车路协同运营建设", - "车路云一体化", - "车路协同" - ] - }, - { - "lv2": "产业链综合", - "lv2_id": "lv2_5_4", - "concepts": [ - "mobileye替代概念", - "智能驾驶产业链", - "汽车安全" - ] - }, - { - "lv2": "出行服务", - "lv2_id": "lv2_5_5", - "concepts": [ - "网约车" - ] - }, - { - "lv2": "智能座舱", - "lv2_id": "lv2_5_6", - "concepts": [ - "空中成像" - ] - } - ] - }, - { - "lv1": "新能源与电力", - "lv1_id": "lv1_6", - "children": [ - { - "lv2": "新型电池技术", - "lv2_id": "lv2_6_1", - "children": [ - { - "lv3": "固态电池", - "lv3_id": "lv3_6_1_1", - "concepts": [ - "固态电池", - "固态电池-硅基负极", - "固态电池-硫化物", - "固态电池产业链", - "固态电池设备", - "固态电池负极集流体材料-铜箔", - "陶瓷隔膜骨架膜" - ] - }, - { - "lv3": "其他材料与技术", - "lv3_id": "lv3_6_1_2", - "concepts": [ - "复合集流体", - "富锂锰基材料", - "硅基负极材料", - "钠离子电池", - "隔膜" - ] - } - ] - }, - { - "lv2": "电力设备与电网", - "lv2_id": "lv2_6_2", - "concepts": [ - "北美缺电AI电力", - "变压器出海", - "固体氧化物燃料电池-SOFC", - "固态变压器SST", - "燃料电池", - "燃气设备", - "燃气轮机HRSG", - "电力", - "电力产业链", - "电力电网", - "电力设备" - ] - }, - { - "lv2": "清洁能源", - "lv2_id": "lv2_6_3", - "children": [ - { - "lv3": "光伏", - "lv3_id": "lv3_6_3_1", - "concepts": [ - "N型产业链", - "光伏", - "光伏行业兼并重组", - "光伏产业链", - "反内卷光伏", - "叠层钙钛矿", - "钙钛矿电池" - ] - }, - { - "lv3": "核能", - "lv3_id": "lv3_6_3_2", - "concepts": [ - "可控核聚变", - "微型核电", - "核电产业链", - "核电钍基熔盐堆", - "核聚变超导", - "超聚变", - "高温概念" - ] - } - ] - }, - { - "lv2": "充电桩与补能", - "lv2_id": "lv2_6_4", - "concepts": [ - "充电桩", - "华为智能充电", - "华为智能充电网络", - "换电", - "换电重卡", - "机器人充电", - "汽车无线充电", - "比亚迪兆瓦闪充" - ] - } - ] - }, - { - "lv1": "空天经济", - "lv1_id": "lv1_7", - "children": [ - { - "lv2": "低空经济", - "lv2_id": "lv2_7_1", - "concepts": [ - "eVTOL材料", - "九天无人机", - "亿航智能订单量", - "低空经济", - "低空经济&飞行汽车", - "低空经济产业链汇集", - "低空经济亿航智能", - "低空管控", - "低空物流", - "低空设计", - "小鹏汇天", - "小鹏汇天供应商", - "飞行汽车eVTOL", - "长安飞行汽车机器人概念" - ] - }, - { - "lv2": "商业航天", - "lv2_id": "lv2_7_2", - "concepts": [ - "凌空天行", - "北斗信使", - "北斗导航", - "商业航天", - "商业航天卫星通信", - "卫星出海", - "卫星互联网", - "太空行走", - "太空旅行", - "太空算力", - "手机直连卫星", - "星河动力", - "星网", - "蓝箭航天朱雀三号" - ] - }, - { - "lv2": "通信技术", - "lv2_id": "lv2_7_3", - "concepts": [ - "5.5G", - "5G-A", - "5G毫米波", - "6G概念", - "eSIM概念", - "通感一体" - ] - }, - { - "lv2": "民用航空", - "lv2_id": "lv2_7_4", - "concepts": [ - "大飞机", - "空客合作" - ] - }, - { - "lv2": "综合与主题", - "lv2_id": "lv2_7_5", - "concepts": [ - "珠海航展" - ] - } - ] - }, - { - "lv1": "国防军工", - "lv1_id": "lv1_8", - "children": [ - { - "lv2": "无人作战与信息化", - "lv2_id": "lv2_8_1", - "concepts": [ - "AI军工", - "AI无人机军工信息化", - "信息支援概念整理", - "军工信息化", - "军用无人机反无人机", - "无人机蜂群" - ] - }, - { - "lv2": "海军装备", - "lv2_id": "lv2_8_2", - "concepts": [ - "军工水面水下作战", - "国产航母", - "水下军工", - "海军", - "电磁弹射概念股", - "电磁发射设备", - "航母福建舰240430" - ] - }, - { - "lv2": "空军装备", - "lv2_id": "lv2_8_3", - "concepts": [ - "军机" - ] - }, - { - "lv2": "陆军装备", - "lv2_id": "lv2_8_4", - "concepts": [ - "远程火力" - ] - }, - { - "lv2": "军贸出海", - "lv2_id": "lv2_8_5", - "concepts": [ - "军贸", - "巴印军贸", - "巴黎航展" - ] - }, - { - "lv2": "综合与主题", - "lv2_id": "lv2_8_6", - "concepts": [ - "军工", - "军工-阅兵", - "国防军工" - ] - } - ] - }, - { - "lv1": "政策与主题", - "lv1_id": "lv1_9", - "children": [ - { - "lv2": "国企改革与市值管理", - "lv2_id": "lv2_9_1", - "concepts": [ - "中兵集团并购重组", - "中字头", - "中字头央企", - "中船合并", - "央企市值管理", - "央国企", - "央国企地产", - "央国企重组", - "安徽国资", - "地面兵装", - "整车央企重组", - "河南国资能源集团重组", - "市值管理16条-破净股", - "珠海国资", - "破净央国企", - "破净股合集", - "福建国资", - "湖北三资改革", - "国资高息股", - "高送转概念股", - "央国企AI一张图" - ] - }, - { - "lv2": "并购重组", - "lv2_id": "lv2_9_2", - "concepts": [ - "IPO终止相关企业重组预期", - "上海并购重组", - "券商合并预期", - "宝德计算机", - "并购重组", - "并购重组预期", - "消费医疗重组预期", - "湘财合并大智慧", - "科创板并购重组", - "秦淮数据", - "科技重组", - "超聚变借壳预期", - "证券", - "荣耀股改", - "重组-中科院系&海光系" - ] - }, - { - "lv2": "信创与自主可控", - "lv2_id": "lv2_9_3", - "concepts": [ - "信创概念", - "关键软件", - "国产信创概览", - "工业软件", - "自主可控", - "软件自主可控", - "信息安全", - "安全概念股", - "网络安全", - "通信设备", - "通信安全" - ] - }, - { - "lv2": "重大基建", - "lv2_id": "lv2_9_4", - "concepts": [ - "三峡水运新通道", - "新藏铁路", - "新疆概念", - "水利", - "水利工程", - "混凝土减水剂、砂石设备", - "节水产业240423", - "西部大开发", - "西部大开发240424", - "西南水电", - "西南水电站", - "西南水电站-机构测算", - "隧洞设备盾构机", - "雅下水电对电力设备增量测算-机构", - "雅下水电站", - "雅下水电站大件物流" - ] - }, - { - "lv2": "供给侧改革", - "lv2_id": "lv2_9_5", - "concepts": [ - "反内卷", - "反内卷造纸", - "反内卷食用盐", - "反内卷快递", - "反内卷合集", - "生猪", - "物流", - "牛肉", - "统一大市场", - "钢铁" - ] - }, - { - "lv2": "产业升级与制造", - "lv2_id": "lv2_9_6", - "concepts": [ - "工业设备更新", - "工业母机", - "设备更新", - "灯塔工厂" - ] - }, - { - "lv2": "国家战略", - "lv2_id": "lv2_9_7", - "concepts": [ - "2025年政府工作报告利好行业及个股", - "新型经济", - "新质生产力", - "深海数智化", - "深海经济", - "深地经济", - "首发经济" - ] - }, - { - "lv2": "区域发展与自贸区", - "lv2_id": "lv2_9_8", - "concepts": [ - "上海自贸区", - "免税离境退税", - "新型离岸贸易", - "海南", - "海南自贸区", - "海南自贸港", - "零售消费免税" - ] - }, - { - "lv2": "行业监管与规范", - "lv2_id": "lv2_9_9", - "concepts": [ - "充电宝", - "农药证件厂家", - "食品安全", - "食品安全全链条", - "预制菜" - ] - } - ] - }, - { - "lv1": "周期与材料", - "lv1_id": "lv1_10", - "children": [ - { - "lv2": "化工", - "lv2_id": "lv2_10_1", - "children": [ - { - "lv3": "行业趋势", - "lv3_id": "lv3_10_1_1", - "concepts": [ - "化工", - "化工概念", - "化工品涨价", - "涨价概念" - ] - }, - { - "lv3": "具体品种", - "lv3_id": "lv3_10_1_2", - "concepts": [ - "TMA偏苯三酸酐", - "乙烷", - "光引发剂", - "六氟磷酸锂", - "农药杀虫剂-氯虫苯甲酰胺", - "双季戊四醇", - "己内酰胺", - "有机硅", - "正丙醇", - "烧碱", - "涤纶长丝", - "电解液添加剂", - "甲苯二异氰酸酯-TDI", - "环氧丙烷", - "纯碱", - "磷化工", - "磷化工六氟磷酸锂", - "聚酯产业", - "维生素", - "苯酚丙酮", - "超硬材料", - "除草剂-烯草酮" - ] - }, - { - "lv3": "产业链", - "lv3_id": "lv3_10_1_3", - "concepts": [ - "有机硅产业链", - "电解液产业链" - ] - } - ] - }, - { - "lv2": "有色金属", - "lv2_id": "lv2_10_2", - "concepts": [ - "化工有色元素周期表", - "有色金属", - "电解铝", - "稀土", - "白银", - "铅酸电池", - "铜", - "铜产业", - "钨金属", - "钴", - "钴金属", - "钼金属", - "锡矿" - ] - } - ] - }, - { - "lv1": "大消费", - "lv1_id": "lv1_11", - "children": [ - { - "lv2": "文化传媒", - "lv2_id": "lv2_11_1", - "concepts": [ - "AI游戏", - "乙游", - "传媒出海", - "出版传媒", - "国产游戏黑神话", - "周杰伦概念股", - "幻兽帕鲁", - "影视", - "影视IP", - "影视传媒", - "影视院线", - "春节档重点影片(哪吒2)", - "智象未来", - "漫剧", - "游戏", - "游戏出海", - "疯狂动物城2", - "短剧", - "诡秘之主", - "腾讯短剧重点名单" - ] - }, - { - "lv2": "新消费", - "lv2_id": "lv2_11_2", + "lv2": "潮玩与IP衍生", + "lv2_id": "trendy_play_ip_derivatives", "concepts": [ "上市潮玩盲盒公司", "卡游文创玩具", @@ -1112,8 +670,263 @@ ] }, { - "lv2": "人口与社会", - "lv2_id": "lv2_11_3", + "lv2": "影视与IP内容", + "lv2_id": "film_tv_ip_content", + "concepts": [ + "出版传媒", + "周杰伦概念股", + "影视院线", + "影视IP", + "影视", + "影视传媒", + "春节档重点影片(哪吒2)", + "疯狂动物城2", + "诡秘之主" + ] + }, + { + "lv2": "体育赛事与经济", + "lv2_id": "sports_events_economy", + "concepts": [ + "体育", + "体育产业", + "冰雪经济", + "川超联赛", + "第十五届全运会", + "足球-苏超联赛、体彩", + "足球" + ] + }, + { + "lv2": "游戏与AI赋能", + "lv2_id": "gaming_ai_empowerment", + "concepts": [ + "AI游戏", + "乙游", + "国产游戏黑神话", + "幻兽帕鲁", + "游戏" + ] + }, + { + "lv2": "内容出海与新业态", + "lv2_id": "content_export_new_formats", + "concepts": [ + "传媒出海", + "漫剧", + "游戏出海", + "短剧", + "腾讯短剧重点名单" + ] + }, + { + "lv2": "社交媒体与跨境电商", + "lv2_id": "social_media_cross_border_ecomm", + "concepts": [ + "TikTok", + "小红书概念", + "小红书概念股", + "敦煌网跨境电商" + ] + } + ] + }, + { + "lv1": "宏观经济与政策", + "lv1_id": "macro_policy", + "children": [ + { + "lv2": "国家战略与新经济", + "lv2_id": "national_strategy_new_economy", + "concepts": [ + "2025年政府工作报告利好行业及个股", + "新型经济", + "新质生产力", + "深海数智化", + "深海经济", + "深地经济", + "首发经济" + ] + }, + { + "lv2": "国企改革与市值管理", + "lv2_id": "soe_reform_market_value", + "concepts": [ + "中兵集团并购重组", + "中字头", + "中字头央企", + "中船合并", + "国资高息股", + "央企市值管理", + "央国企", + "地面兵装", + "央国企地产", + "央国企重组", + "安徽国资", + "市值管理16条-破净股", + "整车央企重组", + "河南国资能源集团重组", + "珠海国资", + "湖北三资改革", + "破净央国企", + "破净股合集", + "福建国资" + ] + }, + { + "lv2": "并购重组", + "lv2_id": "mergers_acquisitions", + "concepts": [ + "IPO终止相关企业重组预期", + "上海并购重组", + "券商合并预期", + "宝德计算机", + "并购重组预期", + "并购重组", + "消费医疗重组预期", + "湘财合并大智慧", + "科创板并购重组", + "秦淮数据", + "科技重组", + "超聚变借壳预期", + "证券", + "荣耀股改", + "重组-中科院系&海光系" + ] + }, + { + "lv2": "供给侧改革与周期", + "lv2_id": "supply_side_reform_cycles", + "concepts": [ + "反内卷", + "反内卷造纸", + "反内卷食用盐", + "反内卷快递", + "反内卷合集", + "己内酰胺", + "涤纶长丝", + "生猪", + "牛肉", + "聚酯产业", + "钢铁", + "BOPET膜" + ] + }, + { + "lv2": "区域经济与自贸区", + "lv2_id": "regional_economy_free_trade_zones", + "concepts": [ + "上海自贸区", + "免税离境退税", + "新型离岸贸易", + "海南", + "海南自贸区", + "海南自贸港", + "零售消费免税" + ] + } + ] + }, + { + "lv1": "地缘政治与国际关系", + "lv1_id": "geopolitics_intl", + "children": [ + { + "lv2": "贸易政策与供应链", + "lv2_id": "trade_policy_supply_chain", + "concepts": [ + "中俄贸易", + "乙烷", + "中欧贸易", + "二轮车全地形车", + "关税豁免", + "关税减免出口链", + "后关税战受益", + "反制关税涨价预期", + "墨西哥汽车零部件", + "绒毛浆", + "芬太尼管制", + "转口贸易出口转内销", + "越南工厂" + ] + }, + { + "lv2": "国际冲突与重建", + "lv2_id": "intl_conflicts_reconstruction", + "concepts": [ + "乌克兰战后重建概念", + "乌克兰重建", + "俄乌重建", + "柬泰战争", + "黄岩岛概念股" + ] + }, + { + "lv2": "两岸关系与区域发展", + "lv2_id": "cross_strait_regional_dev", + "concepts": [ + "两岸融合", + "台资企业", + "海峡两岸福建", + "厦门“十五五规划”" + ] + } + ] + }, + { + "lv1": "数字经济与数据要素", + "lv1_id": "digital_economy_data", + "children": [ + { + "lv2": "数字金融与Web3", + "lv2_id": "digital_finance_web3", + "concepts": [ + "上海浦江数链", + "互联网金融", + "复星稳定币", + "数字货币", + "树图链概念", + "稳定币RWA概念股", + "稳定币-蚂蚁国际", + "稳定币一体机", + "蚂蚁金服", + "香港金融牌照" + ] + }, + { + "lv2": "数据要素与基础设施", + "lv2_id": "data_elements_infra", + "concepts": [ + "RWA上链— IoT设备数据采集", + "RDA概念股", + "数据可信", + "数据交易所", + "数据要素", + "跨境数据数据要素" + ] + }, + { + "lv2": "网络与数据安全", + "lv2_id": "network_data_security", + "concepts": [ + "信息安全", + "地理信息", + "安全概念股", + "汽车安全", + "网络安全", + "通信设备", + "通信安全" + ] + } + ] + }, + { + "lv1": "社会民生", + "lv1_id": "social_livelihood", + "children": [ + { + "lv2": "生育与教育", + "lv2_id": "fertility_education", "concepts": [ "三胎", "多胎", @@ -1124,148 +937,274 @@ ] }, { - "lv2": "体育产业", - "lv2_id": "lv2_11_4", + "lv2": "行业监管与安全", + "lv2_id": "industry_regulation_safety", "concepts": [ - "体育", - "体育产业", - "冰雪经济", - "川超联赛", - "第十五届全运会", - "足球", - "足球-苏超联赛、体彩" + "充电宝", + "农药证件厂家", + "食品安全", + "食品安全全链条", + "预制菜" ] } ] }, { - "lv1": "数字经济与金融科技", - "lv1_id": "lv1_12", + "lv1": "医药与生物科技", + "lv1_id": "pharma_biotech", "children": [ { - "lv2": "数据要素", - "lv2_id": "lv2_12_1", - "concepts": [ - "RDA概念股", - "RWA上链— IoT设备数据采集", - "版权", - "地理信息", - "数据可信", - "数据交易所", - "数据要素", - "政务云政务IT", - "跨境数据数据要素" - ] - }, - { - "lv2": "数字金融", - "lv2_id": "lv2_12_2", - "concepts": [ - "上海浦江数链", - "互联网金融", - "复星稳定币", - "数字货币", - "树图链概念", - "稳定币-蚂蚁国际", - "稳定币RWA概念股", - "稳定币一体机", - "蚂蚁金服", - "香港金融牌照" - ] - } - ] - }, - { - "lv1": "全球宏观与贸易", - "lv1_id": "lv1_13", - "children": [ - { - "lv2": "地缘政治与冲突", - "lv2_id": "lv2_13_1", - "concepts": [ - "以伊冲突-天然气", - "以伊冲突-油运仓储", - "以伊冲突-航运", - "以伊冲突-资源化工", - "乌克兰战后重建概念", - "乌克兰重建", - "俄乌重建", - "油气", - "海外港口", - "海事反制", - "石油", - "航运", - "远洋航运", - "柬泰战争", - "黄岩岛概念股" - ] - }, - { - "lv2": "贸易政策与关系", - "lv2_id": "lv2_13_2", - "concepts": [ - "中美关系", - "中俄贸易", - "中欧贸易", - "出口管制", - "反制关税涨价预期", - "后关税战受益", - "关税减免出口链", - "关税豁免", - "绒毛浆", - "芬太尼管制", - "转口贸易出口转内销" - ] - }, - { - "lv2": "供应链重构", - "lv2_id": "lv2_13_3", - "concepts": [ - "二轮车全地形车", - "墨西哥汽车零部件", - "越南工厂" - ] - } - ] - }, - { - "lv1": "医药健康", - "lv1_id": "lv1_14", - "children": [ - { - "lv2": "创新药", - "lv2_id": "lv2_14_1", + "lv2": "创新药与医疗器械", + "lv2_id": "innovative_drugs_medical_devices", "concepts": [ "AI制药", - "创新药", - "创新药双抗", "创新药相关", - "医药" + "创新药双抗", + "医疗器械", + "创新药", + "医药", + "医药外包CXO" + ] + } + ] + }, + { + "lv1": "基础材料与化工", + "lv1_id": "basic_materials_chem", + "children": [ + { + "lv2": "化工产品与周期", + "lv2_id": "chemical_products_cycles", + "concepts": [ + "TMA偏苯三酸酐", + "光引发剂", + "六氟磷酸锂", + "农药杀虫剂-氯虫苯甲酰胺", + "化工品涨价", + "化工概念", + "化工", + "双季戊四醇", + "正丙醇", + "有机硅", + "有机硅产业链", + "烧碱", + "涨价概念", + "电解液添加剂", + "电解液产业链", + "甲苯二异氰酸酯-TDI", + "环氧丙烷", + "电解铝", + "纯碱", + "磷化工六氟磷酸锂", + "磷化工", + "维生素", + "苯酚丙酮", + "铅酸电池", + "除草剂-烯草酮" ] }, { - "lv2": "细胞治疗", - "lv2_id": "lv2_14_2", + "lv2": "有色金属与稀有矿产", + "lv2_id": "non_ferrous_rare_minerals", "concepts": [ - "干细胞", - "干细胞概念股" + "化工有色元素周期表", + "有色金属", + "稀土", + "白银", + "铜产业", + "钨金属", + "钴", + "铜", + "钼金属", + "钴金属", + "锡矿" + ] + } + ] + }, + { + "lv1": "基础设施与工程", + "lv1_id": "infra_engineering", + "children": [ + { + "lv2": "重大基建与水利", + "lv2_id": "major_infra_water", + "concepts": [ + "三峡水运新通道", + "新藏铁路", + "新疆概念", + "水利", + "水利工程", + "混凝土减水剂、砂石设备", + "节水产业240423", + "西部大开发", + "西南水电站", + "西部大开发240424", + "西南水电站-机构测算", + "西南水电", + "隧洞设备盾构机", + "雅下水电对电力设备增量测算-机构", + "雅下水电站", + "雅下水电站大件物流" ] } ] }, { "lv1": "前沿科技", - "lv1_id": "lv1_15", + "lv1_id": "frontier_tech", "children": [ { - "lv2": "量子科技", - "lv2_id": "lv2_15_1", + "lv2": "量子技术与应用", + "lv2_id": "quantum_tech_applications", "concepts": [ "量子材料钛酸锶", "量子科技", "量子科技产业链", - "量子科技参股公司", - "量子计算" + "量子计算", + "量子科技参股公司" + ] + } + ] + }, + { + "lv1": "市场情绪与概念", + "lv1_id": "market_sentiment", + "children": [ + { + "lv2": "叙事与情绪驱动", + "lv2_id": "narrative_emotion_driven", + "concepts": [ + "“马”字辈", + "人造肉", + "长安的荔枝", + "韦神概念股" + ] + } + ] + }, + { + "lv1": "华为生态", + "lv1_id": "huawei_eco", + "children": [ + { + "lv2": "华为终端与芯片", + "lv2_id": "huawei_devices_chips", + "concepts": [ + "华为P70", + "华为Mate80", + "华为Pura70", + "华为Mate70手表", + "华为MATE70", + "华为麒麟芯片" + ] + }, + { + "lv2": "华为通信与软件", + "lv2_id": "huawei_comm_software", + "concepts": [ + "华为5G", + "华为通信大模型", + "华为海思星闪", + "华为鸿蒙", + "华为鸿蒙甄选与支付", + "鸿蒙PC" + ] + }, + { + "lv2": "华为AI与云计算", + "lv2_id": "huawei_ai_cloud", + "concepts": [ + "华为存储OceanStor", + "华为910C", + "华为云", + "华为AI存储", + "华为AI容器", + "华为昇腾", + "华为昇腾超节点", + "昇腾异构计算架构-CANN", + "磁电存储" + ] + }, + { + "lv2": "华为通用概念", + "lv2_id": "huawei_general_concepts", + "concepts": [ + "华为", + "华字辈" + ] + } + ] + }, + { + "lv1": "小米生态", + "lv1_id": "xiaomi_eco", + "children": [ + { + "lv2": "小米生态与智能汽车", + "lv2_id": "xiaomi_eco_smart_vehicles", + "concepts": [ + "小米算力AI互联", + "孙潇雅团队概念股", + "小米YU7供应链弹性测算", + "小米大模型", + "小鹏产业链", + "小米概念", + "小米汽车产业链弹性", + "小米汽车产业链", + "小米汽车产业链弹性测算" + ] + } + ] + }, + { + "lv1": "字节跳动生态", + "lv1_id": "bytedance_eco", + "children": [ + { + "lv2": "字节生态与AI终端", + "lv2_id": "bytedance_eco_ai_devices", + "concepts": [ + "努比亚手机", + "华为抖音支付", + "字节概念豆包AI手机", + "字节豆包概念股", + "抖音概念", + "豆包大模型" + ] + } + ] + }, + { + "lv1": "国防军工", + "lv1_id": "national_defense_military", + "children": [ + { + "lv2": "军工装备与信息化", + "lv2_id": "military_equipment_informatization", + "concepts": [ + "AI军工", + "AI无人机军工信息化", + "信息支援概念整理", + "军工-阅兵", + "军工信息化", + "军工", + "军工水面水下作战", + "军贸", + "军用无人机反无人机", + "军机", + "国防军工", + "国产航母", + "巴印军贸", + "巴黎航展", + "无人机蜂群", + "水下军工", + "海军", + "电磁弹射概念股", + "电磁发射设备", + "航母福建舰240430", + "远程火力", + "福建-军工" ] } ] diff --git a/ml/README.md b/ml/README.md new file mode 100644 index 00000000..7651277c --- /dev/null +++ b/ml/README.md @@ -0,0 +1,112 @@ +# 概念异动检测 ML 模块 + +基于 Transformer Autoencoder 的概念异动检测系统。 + +## 环境要求 + +- Python 3.8+ +- PyTorch 2.0+ (CUDA 12.x for 5090 GPU) +- ClickHouse, MySQL, Elasticsearch + +## 数据库配置 + +当前配置(`prepare_data.py`): +- MySQL: `192.168.1.5:3306` +- Elasticsearch: `127.0.0.1:9200` +- ClickHouse: `127.0.0.1:9000` + +## 快速开始 + +```bash +# 1. 安装依赖 +pip install -r ml/requirements.txt + +# 2. 安装 PyTorch (5090 需要 CUDA 12.4) +pip install torch --index-url https://download.pytorch.org/whl/cu124 + +# 3. 运行训练 +chmod +x ml/run_training.sh +./ml/run_training.sh +``` + +## 文件说明 + +| 文件 | 说明 | +|------|------| +| `model.py` | Transformer Autoencoder 模型定义 | +| `prepare_data.py` | 数据提取和特征计算 | +| `train.py` | 模型训练脚本 | +| `inference.py` | 推理服务 | +| `enhanced_detector.py` | 增强版检测器(融合 Alpha + ML) | + +## 训练参数 + +```bash +# 完整参数 +./ml/run_training.sh --start 2022-01-01 --end 2024-12-01 --epochs 100 --batch_size 256 + +# 只准备数据 +python ml/prepare_data.py --start 2022-01-01 + +# 只训练(数据已准备好) +python ml/train.py --epochs 100 --batch_size 256 --lr 1e-4 +``` + +## 模型架构 + +``` +输入: (batch, 30, 6) # 30分钟序列,6个特征 + ↓ +Positional Encoding + ↓ +Transformer Encoder (4层, 8头, d=128) + ↓ +Bottleneck (压缩到 32 维) + ↓ +Transformer Decoder (4层) + ↓ +输出: (batch, 30, 6) # 重构序列 + +异动判断: reconstruction_error > threshold +``` + +## 6维特征 + +1. `alpha` - 超额收益(概念涨幅 - 大盘涨幅) +2. `alpha_delta` - Alpha 5分钟变化 +3. `amt_ratio` - 成交额 / 20分钟均值 +4. `amt_delta` - 成交额变化率 +5. `rank_pct` - Alpha 排名百分位 +6. `limit_up_ratio` - 涨停股占比 + +## 训练产出 + +训练完成后,`ml/checkpoints/` 包含: +- `best_model.pt` - 最佳模型权重 +- `thresholds.json` - 异动阈值 (P90/P95/P99) +- `normalization_stats.json` - 数据标准化参数 +- `config.json` - 训练配置 + +## 使用示例 + +```python +from ml.inference import ConceptAnomalyDetector + +detector = ConceptAnomalyDetector('ml/checkpoints') + +# 实时检测 +is_anomaly, score = detector.detect( + concept_name="人工智能", + features={ + 'alpha': 2.5, + 'alpha_delta': 0.8, + 'amt_ratio': 1.5, + 'amt_delta': 0.3, + 'rank_pct': 0.95, + 'limit_up_ratio': 0.15, + } +) + +if is_anomaly: + print(f"检测到异动!分数: {score}") +``` diff --git a/ml/__init__.py b/ml/__init__.py new file mode 100644 index 00000000..348015d0 --- /dev/null +++ b/ml/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +概念异动检测 ML 模块 + +提供基于 Transformer Autoencoder 的异动检测功能 +""" + +from .inference import ConceptAnomalyDetector, MLAnomalyService + +__all__ = ['ConceptAnomalyDetector', 'MLAnomalyService'] diff --git a/ml/__pycache__/realtime_detector.cpython-310.pyc b/ml/__pycache__/realtime_detector.cpython-310.pyc new file mode 100644 index 00000000..b926f280 Binary files /dev/null and b/ml/__pycache__/realtime_detector.cpython-310.pyc differ diff --git a/ml/backtest.py b/ml/backtest.py new file mode 100644 index 00000000..57d49e30 --- /dev/null +++ b/ml/backtest.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +历史异动回测脚本 + +使用训练好的模型,对历史数据进行异动检测,生成异动记录 + +使用方法: + # 回测指定日期范围 + python backtest.py --start 2024-01-01 --end 2024-12-01 + + # 回测单天 + python backtest.py --start 2024-11-01 --end 2024-11-01 + + # 只生成结果,不写入数据库 + python backtest.py --start 2024-01-01 --dry-run +""" + +import os +import sys +import argparse +import json +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Tuple, Optional +from collections import defaultdict + +import numpy as np +import pandas as pd +import torch +from tqdm import tqdm +from sqlalchemy import create_engine, text + +# 添加父目录到路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from model import TransformerAutoencoder + + +# ==================== 配置 ==================== + +MYSQL_ENGINE = create_engine( + "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock", + echo=False +) + +# 特征列表(与训练一致) +FEATURES = [ + 'alpha', + 'alpha_delta', + 'amt_ratio', + 'amt_delta', + 'rank_pct', + 'limit_up_ratio', +] + +# 回测配置 +BACKTEST_CONFIG = { + 'seq_len': 30, # 序列长度 + 'threshold_key': 'p95', # 使用的阈值 + 'min_alpha_abs': 0.5, # 最小 Alpha 绝对值(过滤微小波动) + 'cooldown_minutes': 8, # 同一概念冷却时间 + 'max_alerts_per_minute': 15, # 每分钟最多异动数 + 'clip_value': 10.0, # 极端值截断 +} + + +# ==================== 模型加载 ==================== + +class AnomalyDetector: + """异动检测器""" + + def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'): + self.checkpoint_dir = Path(checkpoint_dir) + + # 设备 + if device == 'auto': + self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + else: + self.device = torch.device(device) + + # 加载配置 + self._load_config() + + # 加载模型 + self._load_model() + + # 加载阈值 + self._load_thresholds() + + print(f"AnomalyDetector 初始化完成") + print(f" 设备: {self.device}") + print(f" 阈值 ({BACKTEST_CONFIG['threshold_key']}): {self.threshold:.6f}") + + def _load_config(self): + config_path = self.checkpoint_dir / 'config.json' + with open(config_path, 'r') as f: + self.config = json.load(f) + + def _load_model(self): + model_path = self.checkpoint_dir / 'best_model.pt' + checkpoint = torch.load(model_path, map_location=self.device) + + model_config = self.config['model'].copy() + model_config['use_instance_norm'] = self.config.get('use_instance_norm', True) + + self.model = TransformerAutoencoder(**model_config) + self.model.load_state_dict(checkpoint['model_state_dict']) + self.model.to(self.device) + self.model.eval() + + def _load_thresholds(self): + thresholds_path = self.checkpoint_dir / 'thresholds.json' + with open(thresholds_path, 'r') as f: + thresholds = json.load(f) + + self.threshold = thresholds[BACKTEST_CONFIG['threshold_key']] + + @torch.no_grad() + def compute_anomaly_scores(self, sequences: np.ndarray) -> np.ndarray: + """ + 计算异动分数 + + Args: + sequences: (n_sequences, seq_len, n_features) + Returns: + scores: (n_sequences,) 每个序列最后时刻的异动分数 + """ + # 截断极端值 + sequences = np.clip(sequences, -BACKTEST_CONFIG['clip_value'], BACKTEST_CONFIG['clip_value']) + + # 转为 tensor + x = torch.FloatTensor(sequences).to(self.device) + + # 计算重构误差 + errors = self.model.compute_reconstruction_error(x, reduction='none') + + # 取最后一个时刻的误差 + scores = errors[:, -1].cpu().numpy() + + return scores + + def is_anomaly(self, score: float) -> bool: + """判断是否异动""" + return score > self.threshold + + +# ==================== 数据加载 ==================== + +def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]: + """加载单天的特征数据""" + file_path = Path(data_dir) / f"features_{date}.parquet" + + if not file_path.exists(): + return None + + df = pd.read_parquet(file_path) + return df + + +def get_available_dates(data_dir: str, start_date: str, end_date: str) -> List[str]: + """获取可用的日期列表""" + data_path = Path(data_dir) + all_files = sorted(data_path.glob("features_*.parquet")) + + dates = [] + for f in all_files: + date = f.stem.replace('features_', '') + if start_date <= date <= end_date: + dates.append(date) + + return dates + + +# ==================== 回测逻辑 ==================== + +def backtest_single_day( + detector: AnomalyDetector, + df: pd.DataFrame, + date: str, + seq_len: int = 30 +) -> List[Dict]: + """ + 回测单天数据 + + Args: + detector: 异动检测器 + df: 当天的特征数据 + date: 日期 + seq_len: 序列长度 + + Returns: + alerts: 异动列表 + """ + alerts = [] + + # 按概念分组 + grouped = df.groupby('concept_id', sort=False) + + # 冷却记录 {concept_id: last_alert_timestamp} + cooldown = {} + + # 获取所有时间点 + all_timestamps = sorted(df['timestamp'].unique()) + + if len(all_timestamps) < seq_len: + return alerts + + # 对每个时间点进行检测(从第 seq_len 个开始) + for t_idx in range(seq_len - 1, len(all_timestamps)): + current_time = all_timestamps[t_idx] + window_start_time = all_timestamps[t_idx - seq_len + 1] + + minute_alerts = [] + + # 收集该时刻所有概念的序列 + concept_sequences = [] + concept_infos = [] + + for concept_id, concept_df in grouped: + # 获取该概念在时间窗口内的数据 + mask = (concept_df['timestamp'] >= window_start_time) & (concept_df['timestamp'] <= current_time) + window_df = concept_df[mask].sort_values('timestamp') + + if len(window_df) < seq_len: + continue + + # 取最后 seq_len 个点 + window_df = window_df.tail(seq_len) + + # 提取特征 + features = window_df[FEATURES].values + + # 处理缺失值 + features = np.nan_to_num(features, nan=0.0, posinf=0.0, neginf=0.0) + + # 获取当前时刻的信息 + current_row = window_df.iloc[-1] + + concept_sequences.append(features) + concept_infos.append({ + 'concept_id': concept_id, + 'timestamp': current_time, + 'alpha': current_row.get('alpha', 0), + 'alpha_delta': current_row.get('alpha_delta', 0), + 'amt_ratio': current_row.get('amt_ratio', 1), + 'limit_up_ratio': current_row.get('limit_up_ratio', 0), + 'limit_down_ratio': current_row.get('limit_down_ratio', 0), + 'rank_pct': current_row.get('rank_pct', 0.5), + 'stock_count': current_row.get('stock_count', 0), + 'total_amt': current_row.get('total_amt', 0), + }) + + if not concept_sequences: + continue + + # 批量计算异动分数 + sequences_array = np.array(concept_sequences) + scores = detector.compute_anomaly_scores(sequences_array) + + # 检测异动 + for i, (info, score) in enumerate(zip(concept_infos, scores)): + concept_id = info['concept_id'] + alpha = info['alpha'] + + # 过滤小波动 + if abs(alpha) < BACKTEST_CONFIG['min_alpha_abs']: + continue + + # 检查冷却 + if concept_id in cooldown: + last_alert = cooldown[concept_id] + if isinstance(current_time, datetime): + time_diff = (current_time - last_alert).total_seconds() / 60 + else: + # timestamp 是字符串或其他格式 + time_diff = BACKTEST_CONFIG['cooldown_minutes'] + 1 # 跳过冷却检查 + + if time_diff < BACKTEST_CONFIG['cooldown_minutes']: + continue + + # 判断是否异动 + if not detector.is_anomaly(score): + continue + + # 记录异动 + alert_type = 'surge_up' if alpha > 0 else 'surge_down' + + alert = { + 'concept_id': concept_id, + 'alert_time': current_time, + 'trade_date': date, + 'alert_type': alert_type, + 'anomaly_score': float(score), + 'threshold': detector.threshold, + **info + } + + minute_alerts.append(alert) + cooldown[concept_id] = current_time + + # 按分数排序,限制数量 + minute_alerts.sort(key=lambda x: x['anomaly_score'], reverse=True) + alerts.extend(minute_alerts[:BACKTEST_CONFIG['max_alerts_per_minute']]) + + return alerts + + +# ==================== 数据库写入 ==================== + +def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int: + """保存异动到 MySQL""" + if not alerts: + return 0 + + if dry_run: + print(f" [Dry Run] 将写入 {len(alerts)} 条异动") + return len(alerts) + + saved = 0 + with MYSQL_ENGINE.begin() as conn: + for alert in alerts: + try: + # 检查是否已存在 + check_sql = text(""" + SELECT id FROM concept_minute_alert + WHERE concept_id = :concept_id + AND alert_time = :alert_time + AND trade_date = :trade_date + """) + exists = conn.execute(check_sql, { + 'concept_id': alert['concept_id'], + 'alert_time': alert['alert_time'], + 'trade_date': alert['trade_date'], + }).fetchone() + + if exists: + continue + + # 插入新记录 + insert_sql = text(""" + INSERT INTO concept_minute_alert + (concept_id, concept_name, alert_time, alert_type, trade_date, + change_pct, zscore, importance_score, stock_count, extra_info) + VALUES + (:concept_id, :concept_name, :alert_time, :alert_type, :trade_date, + :change_pct, :zscore, :importance_score, :stock_count, :extra_info) + """) + + conn.execute(insert_sql, { + 'concept_id': alert['concept_id'], + 'concept_name': alert.get('concept_name', ''), + 'alert_time': alert['alert_time'], + 'alert_type': alert['alert_type'], + 'trade_date': alert['trade_date'], + 'change_pct': alert.get('alpha', 0), + 'zscore': alert['anomaly_score'], + 'importance_score': alert['anomaly_score'], + 'stock_count': alert.get('stock_count', 0), + 'extra_info': json.dumps({ + 'detection_method': 'ml_autoencoder', + 'threshold': alert['threshold'], + 'alpha': alert.get('alpha', 0), + 'amt_ratio': alert.get('amt_ratio', 1), + }, ensure_ascii=False) + }) + + saved += 1 + + except Exception as e: + print(f" 保存失败: {alert['concept_id']} - {e}") + + return saved + + +def export_alerts_to_csv(alerts: List[Dict], output_path: str): + """导出异动到 CSV""" + if not alerts: + return + + df = pd.DataFrame(alerts) + df.to_csv(output_path, index=False, encoding='utf-8-sig') + print(f"已导出到: {output_path}") + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description='历史异动回测') + parser.add_argument('--data_dir', type=str, default='ml/data', + help='特征数据目录') + parser.add_argument('--checkpoint_dir', type=str, default='ml/checkpoints', + help='模型检查点目录') + parser.add_argument('--start', type=str, required=True, + help='开始日期 (YYYY-MM-DD)') + parser.add_argument('--end', type=str, required=True, + help='结束日期 (YYYY-MM-DD)') + parser.add_argument('--dry-run', action='store_true', + help='只计算,不写入数据库') + parser.add_argument('--export-csv', type=str, default=None, + help='导出 CSV 文件路径') + parser.add_argument('--device', type=str, default='auto', + help='设备 (auto/cuda/cpu)') + + args = parser.parse_args() + + print("=" * 60) + print("历史异动回测") + print("=" * 60) + print(f"日期范围: {args.start} ~ {args.end}") + print(f"数据目录: {args.data_dir}") + print(f"模型目录: {args.checkpoint_dir}") + print(f"Dry Run: {args.dry_run}") + print("=" * 60) + + # 初始化检测器 + detector = AnomalyDetector(args.checkpoint_dir, args.device) + + # 获取可用日期 + dates = get_available_dates(args.data_dir, args.start, args.end) + + if not dates: + print(f"未找到 {args.start} ~ {args.end} 范围内的数据") + return + + print(f"\n找到 {len(dates)} 天的数据") + + # 回测 + all_alerts = [] + total_saved = 0 + + for date in tqdm(dates, desc="回测进度"): + # 加载数据 + df = load_daily_features(args.data_dir, date) + + if df is None or df.empty: + continue + + # 回测单天 + alerts = backtest_single_day( + detector, df, date, + seq_len=BACKTEST_CONFIG['seq_len'] + ) + + if alerts: + all_alerts.extend(alerts) + + # 写入数据库 + saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run) + total_saved += saved + + if not args.dry_run: + tqdm.write(f" {date}: 检测到 {len(alerts)} 个异动,保存 {saved} 条") + + # 导出 CSV + if args.export_csv and all_alerts: + export_alerts_to_csv(all_alerts, args.export_csv) + + # 汇总 + print("\n" + "=" * 60) + print("回测完成!") + print("=" * 60) + print(f"总计检测到: {len(all_alerts)} 个异动") + print(f"保存到数据库: {total_saved} 条") + + # 统计 + if all_alerts: + df_alerts = pd.DataFrame(all_alerts) + print(f"\n异动类型分布:") + print(df_alerts['alert_type'].value_counts()) + + print(f"\n异动分数统计:") + print(f" Mean: {df_alerts['anomaly_score'].mean():.4f}") + print(f" Max: {df_alerts['anomaly_score'].max():.4f}") + print(f" Min: {df_alerts['anomaly_score'].min():.4f}") + + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/ml/backtest_fast.py b/ml/backtest_fast.py new file mode 100644 index 00000000..e1c06254 --- /dev/null +++ b/ml/backtest_fast.py @@ -0,0 +1,859 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +快速融合异动回测脚本 + +优化策略: +1. 预先构建所有序列(向量化),避免循环内重复切片 +2. 批量 ML 推理(一次推理所有候选) +3. 使用 NumPy 向量化操作替代 Python 循环 + +性能对比: +- 原版:5分钟/天 +- 优化版:预计 10-30秒/天 +""" + +import os +import sys +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from collections import defaultdict + +import numpy as np +import pandas as pd +import torch +from tqdm import tqdm +from sqlalchemy import create_engine, text + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# ==================== 配置 ==================== + +MYSQL_ENGINE = create_engine( + "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock", + echo=False +) + +FEATURES = ['alpha', 'alpha_delta', 'amt_ratio', 'amt_delta', 'rank_pct', 'limit_up_ratio'] + +CONFIG = { + 'seq_len': 15, # 序列长度(支持跨日后可从 9:30 检测) + 'min_alpha_abs': 0.3, # 最小 alpha 过滤 + 'cooldown_minutes': 8, + 'max_alerts_per_minute': 20, + 'clip_value': 10.0, + # === 融合权重:均衡 === + 'rule_weight': 0.5, + 'ml_weight': 0.5, + # === 触发阈值 === + 'rule_trigger': 65, # 60 -> 65,略提高规则门槛 + 'ml_trigger': 70, # 75 -> 70,略降低 ML 门槛 + 'fusion_trigger': 45, +} + + +# ==================== 规则评分(向量化版)==================== + +def get_size_adjusted_thresholds(stock_count: np.ndarray) -> dict: + """ + 根据概念股票数量计算动态阈值 + + 设计思路: + - 小概念(<10 只):波动大是正常的,需要更高阈值 + - 中概念(10-50 只):标准阈值 + - 大概念(>50 只):能有明显波动说明是真异动,降低阈值 + + 返回各指标的调整系数(乘以基准阈值) + """ + n = len(stock_count) + + # 基于股票数量的调整系数 + # 小概念:系数 > 1(提高阈值,更难触发) + # 大概念:系数 < 1(降低阈值,更容易触发) + size_factor = np.ones(n) + + # 微型概念(<5 只):阈值 × 1.8 + tiny = stock_count < 5 + size_factor[tiny] = 1.8 + + # 小概念(5-10 只):阈值 × 1.4 + small = (stock_count >= 5) & (stock_count < 10) + size_factor[small] = 1.4 + + # 中小概念(10-20 只):阈值 × 1.2 + medium_small = (stock_count >= 10) & (stock_count < 20) + size_factor[medium_small] = 1.2 + + # 中概念(20-50 只):标准阈值 × 1.0 + medium = (stock_count >= 20) & (stock_count < 50) + size_factor[medium] = 1.0 + + # 大概念(50-100 只):阈值 × 0.85 + large = (stock_count >= 50) & (stock_count < 100) + size_factor[large] = 0.85 + + # 超大概念(>100 只):阈值 × 0.7 + xlarge = stock_count >= 100 + size_factor[xlarge] = 0.7 + + return size_factor + + +def score_rules_batch(df: pd.DataFrame) -> Tuple[np.ndarray, List[List[str]]]: + """ + 批量计算规则得分(向量化)- 考虑概念规模版 + + 设计原则: + - 规则作为辅助信号,不应单独主导决策 + - 根据概念股票数量动态调整阈值 + - 大概念异动更有价值,小概念需要更大波动才算异动 + + Args: + df: DataFrame,包含所有特征列(必须包含 stock_count) + Returns: + scores: (n,) 规则得分数组 + triggered_rules: 每行触发的规则列表 + """ + n = len(df) + scores = np.zeros(n) + triggered = [[] for _ in range(n)] + + alpha = df['alpha'].values + alpha_delta = df['alpha_delta'].values + amt_ratio = df['amt_ratio'].values + amt_delta = df['amt_delta'].values + rank_pct = df['rank_pct'].values + limit_up_ratio = df['limit_up_ratio'].values + stock_count = df['stock_count'].values if 'stock_count' in df.columns else np.full(n, 20) + + alpha_abs = np.abs(alpha) + alpha_delta_abs = np.abs(alpha_delta) + + # 获取基于规模的调整系数 + size_factor = get_size_adjusted_thresholds(stock_count) + + # ========== Alpha 规则(动态阈值)========== + # 基准阈值:极强 5%,强 4%,中等 3% + # 实际阈值 = 基准 × size_factor + + # 极强信号 + alpha_extreme_thresh = 5.0 * size_factor + mask = alpha_abs >= alpha_extreme_thresh + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('alpha_extreme') + + # 强信号 + alpha_strong_thresh = 4.0 * size_factor + mask = (alpha_abs >= alpha_strong_thresh) & (alpha_abs < alpha_extreme_thresh) + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('alpha_strong') + + # 中等信号 + alpha_medium_thresh = 3.0 * size_factor + mask = (alpha_abs >= alpha_medium_thresh) & (alpha_abs < alpha_strong_thresh) + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('alpha_medium') + + # ========== Alpha 加速度规则(动态阈值)========== + delta_strong_thresh = 2.0 * size_factor + mask = alpha_delta_abs >= delta_strong_thresh + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('alpha_delta_strong') + + delta_medium_thresh = 1.5 * size_factor + mask = (alpha_delta_abs >= delta_medium_thresh) & (alpha_delta_abs < delta_strong_thresh) + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('alpha_delta_medium') + + # ========== 成交额规则(不受规模影响,放量就是放量)========== + mask = amt_ratio >= 10.0 + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('volume_extreme') + + mask = (amt_ratio >= 6.0) & (amt_ratio < 10.0) + scores[mask] += 12 + for i in np.where(mask)[0]: triggered[i].append('volume_strong') + + # ========== 排名规则 ========== + mask = rank_pct >= 0.98 + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('rank_top') + + mask = rank_pct <= 0.02 + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('rank_bottom') + + # ========== 涨停规则(动态阈值)========== + # 大概念有涨停更有意义 + limit_high_thresh = 0.30 * size_factor + mask = limit_up_ratio >= limit_high_thresh + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('limit_up_high') + + limit_medium_thresh = 0.20 * size_factor + mask = (limit_up_ratio >= limit_medium_thresh) & (limit_up_ratio < limit_high_thresh) + scores[mask] += 12 + for i in np.where(mask)[0]: triggered[i].append('limit_up_medium') + + # ========== 概念规模加分(大概念异动更有价值)========== + # 大概念(50+)额外加分 + large_concept = stock_count >= 50 + has_signal = scores > 0 # 至少触发了某个规则 + mask = large_concept & has_signal + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('large_concept_bonus') + + # 超大概念(100+)再加分 + xlarge_concept = stock_count >= 100 + mask = xlarge_concept & has_signal + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('xlarge_concept_bonus') + + # ========== 组合规则(动态阈值)========== + combo_alpha_thresh = 3.0 * size_factor + + # Alpha + 放量 + 排名(三重验证) + mask = (alpha_abs >= combo_alpha_thresh) & (amt_ratio >= 5.0) & ((rank_pct >= 0.95) | (rank_pct <= 0.05)) + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('triple_signal') + + # Alpha + 涨停(强组合) + mask = (alpha_abs >= combo_alpha_thresh) & (limit_up_ratio >= 0.15 * size_factor) + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('alpha_with_limit') + + # ========== 小概念惩罚(过滤噪音)========== + # 微型概念(<5 只)如果只有单一信号,减分 + tiny_concept = stock_count < 5 + single_rule = np.array([len(t) <= 1 for t in triggered]) + mask = tiny_concept & single_rule & (scores > 0) + scores[mask] *= 0.5 # 减半 + for i in np.where(mask)[0]: triggered[i].append('tiny_concept_penalty') + + scores = np.clip(scores, 0, 100) + return scores, triggered + + +# ==================== ML 评分器 ==================== + +class FastMLScorer: + """快速 ML 评分器""" + + def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'): + self.checkpoint_dir = Path(checkpoint_dir) + + if device == 'auto': + self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + elif device == 'cuda' and not torch.cuda.is_available(): + print("警告: CUDA 不可用,使用 CPU") + self.device = torch.device('cpu') + else: + self.device = torch.device(device) + + self.model = None + self.thresholds = None + self._load_model() + + def _load_model(self): + model_path = self.checkpoint_dir / 'best_model.pt' + thresholds_path = self.checkpoint_dir / 'thresholds.json' + config_path = self.checkpoint_dir / 'config.json' + + if not model_path.exists(): + print(f"警告: 模型不存在 {model_path}") + return + + try: + from model import LSTMAutoencoder + + config = {} + if config_path.exists(): + with open(config_path) as f: + config = json.load(f).get('model', {}) + + # 处理旧配置键名 + if 'd_model' in config: + config['hidden_dim'] = config.pop('d_model') // 2 + for key in ['num_encoder_layers', 'num_decoder_layers', 'nhead', 'dim_feedforward', 'max_seq_len', 'use_instance_norm']: + config.pop(key, None) + if 'num_layers' not in config: + config['num_layers'] = 1 + + checkpoint = torch.load(model_path, map_location='cpu') + self.model = LSTMAutoencoder(**config) + self.model.load_state_dict(checkpoint['model_state_dict']) + self.model.to(self.device) + self.model.eval() + + if thresholds_path.exists(): + with open(thresholds_path) as f: + self.thresholds = json.load(f) + + print(f"ML模型加载成功 (设备: {self.device})") + except Exception as e: + print(f"ML模型加载失败: {e}") + self.model = None + + def is_ready(self): + return self.model is not None + + @torch.no_grad() + def score_batch(self, sequences: np.ndarray) -> np.ndarray: + """ + 批量计算 ML 得分 + + Args: + sequences: (batch, seq_len, n_features) + Returns: + scores: (batch,) 0-100 分数 + """ + if not self.is_ready() or len(sequences) == 0: + return np.zeros(len(sequences)) + + x = torch.FloatTensor(sequences).to(self.device) + output, _ = self.model(x) + mse = ((output - x) ** 2).mean(dim=-1) + errors = mse[:, -1].cpu().numpy() + + p95 = self.thresholds.get('p95', 0.1) if self.thresholds else 0.1 + scores = np.clip(errors / p95 * 50, 0, 100) + return scores + + +# ==================== 快速回测 ==================== + +def build_sequences_fast( + df: pd.DataFrame, + seq_len: int = 30, + prev_df: pd.DataFrame = None +) -> Tuple[np.ndarray, pd.DataFrame]: + """ + 快速构建所有有效序列 + + 支持跨日序列:用前一天收盘数据 + 当天开盘数据拼接,实现 9:30 就能检测 + + Args: + df: 当天数据 + seq_len: 序列长度 + prev_df: 前一天数据(可选,用于构建开盘时的序列) + + 返回: + sequences: (n_valid, seq_len, n_features) 所有有效序列 + info_df: 对应的元信息 DataFrame + """ + # 确保按概念和时间排序 + df = df.sort_values(['concept_id', 'timestamp']).reset_index(drop=True) + + # 如果有前一天数据,按概念构建尾部缓存(取每个概念最后 seq_len-1 条) + prev_cache = {} + if prev_df is not None and len(prev_df) > 0: + prev_df = prev_df.sort_values(['concept_id', 'timestamp']) + for concept_id, gdf in prev_df.groupby('concept_id'): + tail_data = gdf.tail(seq_len - 1) + if len(tail_data) > 0: + feat_matrix = tail_data[FEATURES].values + feat_matrix = np.nan_to_num(feat_matrix, nan=0.0, posinf=0.0, neginf=0.0) + feat_matrix = np.clip(feat_matrix, -CONFIG['clip_value'], CONFIG['clip_value']) + prev_cache[concept_id] = feat_matrix + + # 按概念分组 + groups = df.groupby('concept_id') + + sequences = [] + infos = [] + + for concept_id, gdf in groups: + gdf = gdf.reset_index(drop=True) + + # 获取特征矩阵 + feat_matrix = gdf[FEATURES].values + feat_matrix = np.nan_to_num(feat_matrix, nan=0.0, posinf=0.0, neginf=0.0) + feat_matrix = np.clip(feat_matrix, -CONFIG['clip_value'], CONFIG['clip_value']) + + # 如果有前一天缓存,拼接到当天数据前面 + if concept_id in prev_cache: + prev_data = prev_cache[concept_id] + combined_matrix = np.vstack([prev_data, feat_matrix]) + # 计算偏移量:前一天数据的长度 + offset = len(prev_data) + else: + combined_matrix = feat_matrix + offset = 0 + + # 滑动窗口构建序列 + n_total = len(combined_matrix) + if n_total < seq_len: + continue + + for i in range(n_total - seq_len + 1): + seq = combined_matrix[i:i + seq_len] + + # 计算对应当天数据的索引 + # 序列最后一个点的位置 = i + seq_len - 1 + # 对应当天数据的索引 = (i + seq_len - 1) - offset + today_idx = i + seq_len - 1 - offset + + # 只要序列的最后一个点是当天的数据,就记录 + if today_idx < 0 or today_idx >= len(gdf): + continue + + sequences.append(seq) + + # 记录最后一个时间步的信息(当天的) + row = gdf.iloc[today_idx] + infos.append({ + 'concept_id': concept_id, + 'timestamp': row['timestamp'], + 'alpha': row['alpha'], + 'alpha_delta': row.get('alpha_delta', 0), + 'amt_ratio': row.get('amt_ratio', 1), + 'amt_delta': row.get('amt_delta', 0), + 'rank_pct': row.get('rank_pct', 0.5), + 'limit_up_ratio': row.get('limit_up_ratio', 0), + 'stock_count': row.get('stock_count', 0), + 'total_amt': row.get('total_amt', 0), + }) + + if not sequences: + return np.array([]), pd.DataFrame() + + return np.array(sequences), pd.DataFrame(infos) + + +def backtest_single_day_fast( + ml_scorer: FastMLScorer, + df: pd.DataFrame, + date: str, + config: Dict, + prev_df: pd.DataFrame = None +) -> List[Dict]: + """ + 快速回测单天(向量化版本) + + Args: + ml_scorer: ML 评分器 + df: 当天数据 + date: 日期 + config: 配置 + prev_df: 前一天数据(用于 9:30 开始检测) + """ + seq_len = config.get('seq_len', 30) + + # 1. 构建所有序列(支持跨日) + sequences, info_df = build_sequences_fast(df, seq_len, prev_df) + + if len(sequences) == 0: + return [] + + # 2. 过滤小波动 + alpha_abs = np.abs(info_df['alpha'].values) + valid_mask = alpha_abs >= config['min_alpha_abs'] + + sequences = sequences[valid_mask] + info_df = info_df[valid_mask].reset_index(drop=True) + + if len(sequences) == 0: + return [] + + # 3. 批量规则评分 + rule_scores, triggered_rules = score_rules_batch(info_df) + + # 4. 批量 ML 评分(分批处理避免显存溢出) + batch_size = 2048 + ml_scores = [] + for i in range(0, len(sequences), batch_size): + batch_seq = sequences[i:i+batch_size] + batch_scores = ml_scorer.score_batch(batch_seq) + ml_scores.append(batch_scores) + ml_scores = np.concatenate(ml_scores) if ml_scores else np.zeros(len(sequences)) + + # 5. 融合得分 + w1, w2 = config['rule_weight'], config['ml_weight'] + final_scores = w1 * rule_scores + w2 * ml_scores + + # 6. 判断异动 + is_anomaly = ( + (rule_scores >= config['rule_trigger']) | + (ml_scores >= config['ml_trigger']) | + (final_scores >= config['fusion_trigger']) + ) + + # 7. 应用冷却期(按概念+时间排序后处理) + info_df['rule_score'] = rule_scores + info_df['ml_score'] = ml_scores + info_df['final_score'] = final_scores + info_df['is_anomaly'] = is_anomaly + info_df['triggered_rules'] = triggered_rules + + # 只保留异动 + anomaly_df = info_df[info_df['is_anomaly']].copy() + + if len(anomaly_df) == 0: + return [] + + # 应用冷却期 + anomaly_df = anomaly_df.sort_values(['concept_id', 'timestamp']) + cooldown = {} + keep_mask = [] + + for _, row in anomaly_df.iterrows(): + cid = row['concept_id'] + ts = row['timestamp'] + + if cid in cooldown: + try: + diff = (ts - cooldown[cid]).total_seconds() / 60 + except: + diff = config['cooldown_minutes'] + 1 + + if diff < config['cooldown_minutes']: + keep_mask.append(False) + continue + + cooldown[cid] = ts + keep_mask.append(True) + + anomaly_df = anomaly_df[keep_mask] + + # 8. 按时间分组,每分钟最多 max_alerts_per_minute 个 + alerts = [] + for ts, group in anomaly_df.groupby('timestamp'): + group = group.nlargest(config['max_alerts_per_minute'], 'final_score') + + for _, row in group.iterrows(): + alpha = row['alpha'] + if alpha >= 1.5: + atype = 'surge_up' + elif alpha <= -1.5: + atype = 'surge_down' + elif row['amt_ratio'] >= 3.0: + atype = 'volume_spike' + else: + atype = 'unknown' + + rule_score = row['rule_score'] + ml_score = row['ml_score'] + final_score = row['final_score'] + + if rule_score >= config['rule_trigger']: + trigger = f'规则强信号({rule_score:.0f}分)' + elif ml_score >= config['ml_trigger']: + trigger = f'ML强信号({ml_score:.0f}分)' + else: + trigger = f'融合触发({final_score:.0f}分)' + + alerts.append({ + 'concept_id': row['concept_id'], + 'alert_time': row['timestamp'], + 'trade_date': date, + 'alert_type': atype, + 'final_score': final_score, + 'rule_score': rule_score, + 'ml_score': ml_score, + 'trigger_reason': trigger, + 'triggered_rules': row['triggered_rules'], + 'alpha': alpha, + 'alpha_delta': row['alpha_delta'], + 'amt_ratio': row['amt_ratio'], + 'amt_delta': row['amt_delta'], + 'rank_pct': row['rank_pct'], + 'limit_up_ratio': row['limit_up_ratio'], + 'stock_count': row['stock_count'], + 'total_amt': row['total_amt'], + }) + + return alerts + + +# ==================== 数据加载 ==================== + +def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]: + file_path = Path(data_dir) / f"features_{date}.parquet" + if not file_path.exists(): + return None + return pd.read_parquet(file_path) + + +def get_available_dates(data_dir: str, start: str, end: str) -> List[str]: + data_path = Path(data_dir) + dates = [] + for f in sorted(data_path.glob("features_*.parquet")): + d = f.stem.replace('features_', '') + if start <= d <= end: + dates.append(d) + return dates + + +def get_prev_trading_day(data_dir: str, date: str) -> Optional[str]: + """获取给定日期之前最近的有数据的交易日""" + data_path = Path(data_dir) + all_dates = sorted([f.stem.replace('features_', '') for f in data_path.glob("features_*.parquet")]) + + for i, d in enumerate(all_dates): + if d == date and i > 0: + return all_dates[i - 1] + return None + + +def export_to_csv(alerts: List[Dict], path: str): + if alerts: + pd.DataFrame(alerts).to_csv(path, index=False, encoding='utf-8-sig') + print(f"已导出: {path}") + + +# ==================== 数据库写入 ==================== + +def init_db_table(): + """ + 初始化数据库表(如果不存在则创建) + + 表结构说明: + - concept_id: 概念ID + - alert_time: 异动时间(精确到分钟) + - trade_date: 交易日期 + - alert_type: 异动类型(surge_up/surge_down/volume_spike/unknown) + - final_score: 最终得分(0-100) + - rule_score: 规则得分(0-100) + - ml_score: ML得分(0-100) + - trigger_reason: 触发原因 + - alpha: 超额收益率 + - alpha_delta: alpha变化速度 + - amt_ratio: 成交额放大倍数 + - rank_pct: 排名百分位 + - stock_count: 概念股票数量 + - triggered_rules: 触发的规则列表(JSON) + """ + create_sql = text(""" + CREATE TABLE IF NOT EXISTS concept_anomaly_hybrid ( + id INT AUTO_INCREMENT PRIMARY KEY, + concept_id VARCHAR(64) NOT NULL, + alert_time DATETIME NOT NULL, + trade_date DATE NOT NULL, + alert_type VARCHAR(32) NOT NULL, + final_score FLOAT NOT NULL, + rule_score FLOAT NOT NULL, + ml_score FLOAT NOT NULL, + trigger_reason VARCHAR(64), + alpha FLOAT, + alpha_delta FLOAT, + amt_ratio FLOAT, + amt_delta FLOAT, + rank_pct FLOAT, + limit_up_ratio FLOAT, + stock_count INT, + total_amt FLOAT, + triggered_rules JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_concept_time (concept_id, alert_time, trade_date), + INDEX idx_trade_date (trade_date), + INDEX idx_concept_id (concept_id), + INDEX idx_final_score (final_score), + INDEX idx_alert_type (alert_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念异动检测结果(融合版)' + """) + + with MYSQL_ENGINE.begin() as conn: + conn.execute(create_sql) + print("数据库表已就绪: concept_anomaly_hybrid") + + +def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int: + """ + 保存异动到 MySQL + + Args: + alerts: 异动列表 + dry_run: 是否只模拟,不实际写入 + + Returns: + 实际保存的记录数 + """ + if not alerts: + return 0 + + if dry_run: + print(f" [Dry Run] 将写入 {len(alerts)} 条异动") + return len(alerts) + + saved = 0 + skipped = 0 + + with MYSQL_ENGINE.begin() as conn: + for alert in alerts: + try: + # 检查是否已存在(使用 INSERT IGNORE 更高效) + insert_sql = text(""" + INSERT IGNORE INTO concept_anomaly_hybrid + (concept_id, alert_time, trade_date, alert_type, + final_score, rule_score, ml_score, trigger_reason, + alpha, alpha_delta, amt_ratio, amt_delta, + rank_pct, limit_up_ratio, stock_count, total_amt, + triggered_rules) + VALUES + (:concept_id, :alert_time, :trade_date, :alert_type, + :final_score, :rule_score, :ml_score, :trigger_reason, + :alpha, :alpha_delta, :amt_ratio, :amt_delta, + :rank_pct, :limit_up_ratio, :stock_count, :total_amt, + :triggered_rules) + """) + + result = conn.execute(insert_sql, { + 'concept_id': alert['concept_id'], + 'alert_time': alert['alert_time'], + 'trade_date': alert['trade_date'], + 'alert_type': alert['alert_type'], + 'final_score': alert['final_score'], + 'rule_score': alert['rule_score'], + 'ml_score': alert['ml_score'], + 'trigger_reason': alert['trigger_reason'], + 'alpha': alert.get('alpha', 0), + 'alpha_delta': alert.get('alpha_delta', 0), + 'amt_ratio': alert.get('amt_ratio', 1), + 'amt_delta': alert.get('amt_delta', 0), + 'rank_pct': alert.get('rank_pct', 0.5), + 'limit_up_ratio': alert.get('limit_up_ratio', 0), + 'stock_count': alert.get('stock_count', 0), + 'total_amt': alert.get('total_amt', 0), + 'triggered_rules': json.dumps(alert.get('triggered_rules', []), ensure_ascii=False), + }) + + if result.rowcount > 0: + saved += 1 + else: + skipped += 1 + + except Exception as e: + print(f" 保存失败: {alert['concept_id']} @ {alert['alert_time']} - {e}") + + if skipped > 0: + print(f" 跳过 {skipped} 条重复记录") + + return saved + + +def clear_alerts_by_date(trade_date: str) -> int: + """清除指定日期的异动记录(用于重新回测)""" + with MYSQL_ENGINE.begin() as conn: + result = conn.execute( + text("DELETE FROM concept_anomaly_hybrid WHERE trade_date = :trade_date"), + {'trade_date': trade_date} + ) + return result.rowcount + + +def analyze_alerts(alerts: List[Dict]): + if not alerts: + print("无异动") + return + + df = pd.DataFrame(alerts) + print(f"\n总异动: {len(alerts)}") + print(f"\n类型分布:\n{df['alert_type'].value_counts()}") + print(f"\n得分统计:") + print(f" 最终: {df['final_score'].mean():.1f} (max: {df['final_score'].max():.1f})") + print(f" 规则: {df['rule_score'].mean():.1f} (max: {df['rule_score'].max():.1f})") + print(f" ML: {df['ml_score'].mean():.1f} (max: {df['ml_score'].max():.1f})") + + trigger_type = df['trigger_reason'].apply( + lambda x: '规则' if '规则' in x else ('ML' if 'ML' in x else '融合') + ) + print(f"\n触发来源:\n{trigger_type.value_counts()}") + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description='快速融合异动回测') + parser.add_argument('--data_dir', default='ml/data') + parser.add_argument('--checkpoint_dir', default='ml/checkpoints') + parser.add_argument('--start', required=True) + parser.add_argument('--end', default=None) + parser.add_argument('--dry-run', action='store_true', help='模拟运行,不写入数据库') + parser.add_argument('--export-csv', default=None, help='导出 CSV 文件路径') + parser.add_argument('--save-db', action='store_true', help='保存结果到数据库') + parser.add_argument('--clear-first', action='store_true', help='写入前先清除该日期的旧数据') + parser.add_argument('--device', default='auto') + + args = parser.parse_args() + if args.end is None: + args.end = args.start + + print("=" * 60) + print("快速融合异动回测") + print("=" * 60) + print(f"日期: {args.start} ~ {args.end}") + print(f"设备: {args.device}") + print(f"保存数据库: {args.save_db}") + print("=" * 60) + + # 初始化数据库表(如果需要保存) + if args.save_db and not args.dry_run: + init_db_table() + + # 初始化 ML 评分器 + ml_scorer = FastMLScorer(args.checkpoint_dir, args.device) + + # 获取日期 + dates = get_available_dates(args.data_dir, args.start, args.end) + if not dates: + print("无数据") + return + + print(f"找到 {len(dates)} 天数据\n") + + # 回测(支持跨日序列) + all_alerts = [] + total_saved = 0 + prev_df = None # 缓存前一天数据 + + for i, date in enumerate(tqdm(dates, desc="回测")): + df = load_daily_features(args.data_dir, date) + if df is None or df.empty: + prev_df = None # 当天无数据,清空缓存 + continue + + # 第一天需要加载前一天数据(如果存在) + if i == 0 and prev_df is None: + prev_date = get_prev_trading_day(args.data_dir, date) + if prev_date: + prev_df = load_daily_features(args.data_dir, prev_date) + if prev_df is not None: + tqdm.write(f" 加载前一天数据: {prev_date}") + + alerts = backtest_single_day_fast(ml_scorer, df, date, CONFIG, prev_df) + all_alerts.extend(alerts) + + # 保存到数据库 + if args.save_db and alerts: + if args.clear_first and not args.dry_run: + cleared = clear_alerts_by_date(date) + if cleared > 0: + tqdm.write(f" 清除 {date} 旧数据: {cleared} 条") + + saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run) + total_saved += saved + tqdm.write(f" {date}: {len(alerts)} 个异动, 保存 {saved} 条") + elif alerts: + tqdm.write(f" {date}: {len(alerts)} 个异动") + + # 当天数据成为下一天的 prev_df + prev_df = df + + # 导出 CSV + if args.export_csv: + export_to_csv(all_alerts, args.export_csv) + + # 分析 + analyze_alerts(all_alerts) + + print(f"\n总计: {len(all_alerts)} 个异动") + if args.save_db: + print(f"已保存到数据库: {total_saved} 条") + + +if __name__ == "__main__": + main() diff --git a/ml/backtest_hybrid.py b/ml/backtest_hybrid.py new file mode 100644 index 00000000..6913f204 --- /dev/null +++ b/ml/backtest_hybrid.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +融合异动回测脚本 + +使用 HybridAnomalyDetector 进行回测: +- 规则评分 + LSTM Autoencoder 融合判断 +- 输出更丰富的异动信息 + +使用方法: + python backtest_hybrid.py --start 2024-01-01 --end 2024-12-01 + python backtest_hybrid.py --start 2024-11-01 --dry-run +""" + +import os +import sys +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional +from collections import defaultdict + +import numpy as np +import pandas as pd +from tqdm import tqdm +from sqlalchemy import create_engine, text + +# 添加父目录到路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from detector import HybridAnomalyDetector, create_detector + + +# ==================== 配置 ==================== + +MYSQL_ENGINE = create_engine( + "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock", + echo=False +) + +FEATURES = [ + 'alpha', + 'alpha_delta', + 'amt_ratio', + 'amt_delta', + 'rank_pct', + 'limit_up_ratio', +] + +BACKTEST_CONFIG = { + 'seq_len': 30, + 'min_alpha_abs': 0.3, # 降低阈值,让规则也能发挥作用 + 'cooldown_minutes': 8, + 'max_alerts_per_minute': 20, + 'clip_value': 10.0, +} + + +# ==================== 数据加载 ==================== + +def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]: + """加载单天的特征数据""" + file_path = Path(data_dir) / f"features_{date}.parquet" + + if not file_path.exists(): + return None + + df = pd.read_parquet(file_path) + return df + + +def get_available_dates(data_dir: str, start_date: str, end_date: str) -> List[str]: + """获取可用的日期列表""" + data_path = Path(data_dir) + all_files = sorted(data_path.glob("features_*.parquet")) + + dates = [] + for f in all_files: + date = f.stem.replace('features_', '') + if start_date <= date <= end_date: + dates.append(date) + + return dates + + +# ==================== 融合回测 ==================== + +def backtest_single_day_hybrid( + detector: HybridAnomalyDetector, + df: pd.DataFrame, + date: str, + seq_len: int = 30 +) -> List[Dict]: + """ + 使用融合检测器回测单天数据(批量优化版) + """ + alerts = [] + + # 按概念分组,预先构建字典 + grouped_dict = {cid: cdf for cid, cdf in df.groupby('concept_id', sort=False)} + + # 冷却记录 + cooldown = {} + + # 获取所有时间点 + all_timestamps = sorted(df['timestamp'].unique()) + + if len(all_timestamps) < seq_len: + return alerts + + # 对每个时间点进行检测 + for t_idx in range(seq_len - 1, len(all_timestamps)): + current_time = all_timestamps[t_idx] + window_start_time = all_timestamps[t_idx - seq_len + 1] + + # 批量收集该时刻所有候选概念 + batch_sequences = [] + batch_features = [] + batch_infos = [] + + for concept_id, concept_df in grouped_dict.items(): + # 检查冷却(提前过滤) + if concept_id in cooldown: + last_alert = cooldown[concept_id] + if isinstance(current_time, datetime): + time_diff = (current_time - last_alert).total_seconds() / 60 + else: + time_diff = BACKTEST_CONFIG['cooldown_minutes'] + 1 + if time_diff < BACKTEST_CONFIG['cooldown_minutes']: + continue + + # 获取时间窗口内的数据 + mask = (concept_df['timestamp'] >= window_start_time) & (concept_df['timestamp'] <= current_time) + window_df = concept_df.loc[mask] + + if len(window_df) < seq_len: + continue + + window_df = window_df.sort_values('timestamp').tail(seq_len) + + # 当前时刻特征 + current_row = window_df.iloc[-1] + alpha = current_row.get('alpha', 0) + + # 过滤微小波动(提前过滤) + if abs(alpha) < BACKTEST_CONFIG['min_alpha_abs']: + continue + + # 提取特征序列 + sequence = window_df[FEATURES].values + sequence = np.nan_to_num(sequence, nan=0.0, posinf=0.0, neginf=0.0) + sequence = np.clip(sequence, -BACKTEST_CONFIG['clip_value'], BACKTEST_CONFIG['clip_value']) + + current_features = { + 'alpha': alpha, + 'alpha_delta': current_row.get('alpha_delta', 0), + 'amt_ratio': current_row.get('amt_ratio', 1), + 'amt_delta': current_row.get('amt_delta', 0), + 'rank_pct': current_row.get('rank_pct', 0.5), + 'limit_up_ratio': current_row.get('limit_up_ratio', 0), + } + + batch_sequences.append(sequence) + batch_features.append(current_features) + batch_infos.append({ + 'concept_id': concept_id, + 'stock_count': current_row.get('stock_count', 0), + 'total_amt': current_row.get('total_amt', 0), + }) + + if not batch_sequences: + continue + + # 批量 ML 推理 + sequences_array = np.array(batch_sequences) + ml_scores = detector.ml_scorer.score(sequences_array) if detector.ml_scorer.is_ready() else [0.0] * len(batch_sequences) + if isinstance(ml_scores, float): + ml_scores = [ml_scores] + + # 批量规则评分 + 融合 + minute_alerts = [] + for i, (features, info) in enumerate(zip(batch_features, batch_infos)): + concept_id = info['concept_id'] + + # 规则评分 + rule_score, rule_details = detector.rule_scorer.score(features) + + # ML 评分 + ml_score = ml_scores[i] if i < len(ml_scores) else 0.0 + + # 融合 + w1 = detector.config['rule_weight'] + w2 = detector.config['ml_weight'] + final_score = w1 * rule_score + w2 * ml_score + + # 判断是否异动 + is_anomaly = False + trigger_reason = '' + + if rule_score >= detector.config['rule_trigger']: + is_anomaly = True + trigger_reason = f'规则强信号({rule_score:.0f}分)' + elif ml_score >= detector.config['ml_trigger']: + is_anomaly = True + trigger_reason = f'ML强信号({ml_score:.0f}分)' + elif final_score >= detector.config['fusion_trigger']: + is_anomaly = True + trigger_reason = f'融合触发({final_score:.0f}分)' + + if not is_anomaly: + continue + + # 异动类型 + alpha = features.get('alpha', 0) + if alpha >= 1.5: + anomaly_type = 'surge_up' + elif alpha <= -1.5: + anomaly_type = 'surge_down' + elif features.get('amt_ratio', 1) >= 3.0: + anomaly_type = 'volume_spike' + else: + anomaly_type = 'unknown' + + alert = { + 'concept_id': concept_id, + 'alert_time': current_time, + 'trade_date': date, + 'alert_type': anomaly_type, + 'final_score': final_score, + 'rule_score': rule_score, + 'ml_score': ml_score, + 'trigger_reason': trigger_reason, + 'triggered_rules': list(rule_details.keys()), + **features, + **info, + } + + minute_alerts.append(alert) + cooldown[concept_id] = current_time + + # 按最终得分排序 + minute_alerts.sort(key=lambda x: x['final_score'], reverse=True) + alerts.extend(minute_alerts[:BACKTEST_CONFIG['max_alerts_per_minute']]) + + return alerts + + +# ==================== 数据库写入 ==================== + +def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int: + """保存异动到 MySQL(增强版字段)""" + if not alerts: + return 0 + + if dry_run: + print(f" [Dry Run] 将写入 {len(alerts)} 条异动") + return len(alerts) + + saved = 0 + with MYSQL_ENGINE.begin() as conn: + for alert in alerts: + try: + # 检查是否已存在 + check_sql = text(""" + SELECT id FROM concept_minute_alert + WHERE concept_id = :concept_id + AND alert_time = :alert_time + AND trade_date = :trade_date + """) + exists = conn.execute(check_sql, { + 'concept_id': alert['concept_id'], + 'alert_time': alert['alert_time'], + 'trade_date': alert['trade_date'], + }).fetchone() + + if exists: + continue + + # 插入新记录 + insert_sql = text(""" + INSERT INTO concept_minute_alert + (concept_id, concept_name, alert_time, alert_type, trade_date, + change_pct, zscore, importance_score, stock_count, extra_info) + VALUES + (:concept_id, :concept_name, :alert_time, :alert_type, :trade_date, + :change_pct, :zscore, :importance_score, :stock_count, :extra_info) + """) + + extra_info = { + 'detection_method': 'hybrid', + 'final_score': alert['final_score'], + 'rule_score': alert['rule_score'], + 'ml_score': alert['ml_score'], + 'trigger_reason': alert['trigger_reason'], + 'triggered_rules': alert['triggered_rules'], + 'alpha': alert.get('alpha', 0), + 'alpha_delta': alert.get('alpha_delta', 0), + 'amt_ratio': alert.get('amt_ratio', 1), + } + + conn.execute(insert_sql, { + 'concept_id': alert['concept_id'], + 'concept_name': alert.get('concept_name', ''), + 'alert_time': alert['alert_time'], + 'alert_type': alert['alert_type'], + 'trade_date': alert['trade_date'], + 'change_pct': alert.get('alpha', 0), + 'zscore': alert['final_score'], # 用最终得分作为 zscore + 'importance_score': alert['final_score'], + 'stock_count': alert.get('stock_count', 0), + 'extra_info': json.dumps(extra_info, ensure_ascii=False) + }) + + saved += 1 + + except Exception as e: + print(f" 保存失败: {alert['concept_id']} - {e}") + + return saved + + +def export_alerts_to_csv(alerts: List[Dict], output_path: str): + """导出异动到 CSV""" + if not alerts: + return + + df = pd.DataFrame(alerts) + df.to_csv(output_path, index=False, encoding='utf-8-sig') + print(f"已导出到: {output_path}") + + +# ==================== 统计分析 ==================== + +def analyze_alerts(alerts: List[Dict]): + """分析异动结果""" + if not alerts: + print("无异动数据") + return + + df = pd.DataFrame(alerts) + + print("\n" + "=" * 60) + print("异动统计分析") + print("=" * 60) + + # 1. 基本统计 + print(f"\n总异动数: {len(alerts)}") + + # 2. 按类型统计 + print(f"\n异动类型分布:") + print(df['alert_type'].value_counts()) + + # 3. 得分统计 + print(f"\n得分统计:") + print(f" 最终得分 - Mean: {df['final_score'].mean():.1f}, Max: {df['final_score'].max():.1f}") + print(f" 规则得分 - Mean: {df['rule_score'].mean():.1f}, Max: {df['rule_score'].max():.1f}") + print(f" ML得分 - Mean: {df['ml_score'].mean():.1f}, Max: {df['ml_score'].max():.1f}") + + # 4. 触发来源分析 + print(f"\n触发来源分析:") + trigger_counts = df['trigger_reason'].apply( + lambda x: '规则' if '规则' in x else ('ML' if 'ML' in x else '融合') + ).value_counts() + print(trigger_counts) + + # 5. 规则触发频率 + all_rules = [] + for rules in df['triggered_rules']: + if isinstance(rules, list): + all_rules.extend(rules) + + if all_rules: + print(f"\n最常触发的规则 (Top 10):") + from collections import Counter + rule_counts = Counter(all_rules) + for rule, count in rule_counts.most_common(10): + print(f" {rule}: {count}") + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description='融合异动回测') + parser.add_argument('--data_dir', type=str, default='ml/data', + help='特征数据目录') + parser.add_argument('--checkpoint_dir', type=str, default='ml/checkpoints', + help='模型检查点目录') + parser.add_argument('--start', type=str, required=True, + help='开始日期 (YYYY-MM-DD)') + parser.add_argument('--end', type=str, default=None, + help='结束日期 (YYYY-MM-DD),默认=start') + parser.add_argument('--dry-run', action='store_true', + help='只计算,不写入数据库') + parser.add_argument('--export-csv', type=str, default=None, + help='导出 CSV 文件路径') + parser.add_argument('--rule-weight', type=float, default=0.6, + help='规则权重 (0-1)') + parser.add_argument('--ml-weight', type=float, default=0.4, + help='ML权重 (0-1)') + parser.add_argument('--device', type=str, default='cuda', + help='设备 (cuda/cpu),默认 cuda') + + args = parser.parse_args() + + if args.end is None: + args.end = args.start + + print("=" * 60) + print("融合异动回测 (规则 + LSTM)") + print("=" * 60) + print(f"日期范围: {args.start} ~ {args.end}") + print(f"数据目录: {args.data_dir}") + print(f"模型目录: {args.checkpoint_dir}") + print(f"规则权重: {args.rule_weight}") + print(f"ML权重: {args.ml_weight}") + print(f"设备: {args.device}") + print(f"Dry Run: {args.dry_run}") + print("=" * 60) + + # 初始化融合检测器(使用 GPU) + config = { + 'rule_weight': args.rule_weight, + 'ml_weight': args.ml_weight, + } + + # 修改 detector.py 中 MLScorer 的设备 + from detector import HybridAnomalyDetector + detector = HybridAnomalyDetector(config, args.checkpoint_dir, device=args.device) + + # 获取可用日期 + dates = get_available_dates(args.data_dir, args.start, args.end) + + if not dates: + print(f"未找到 {args.start} ~ {args.end} 范围内的数据") + return + + print(f"\n找到 {len(dates)} 天的数据") + + # 回测 + all_alerts = [] + total_saved = 0 + + for date in tqdm(dates, desc="回测进度"): + df = load_daily_features(args.data_dir, date) + + if df is None or df.empty: + continue + + alerts = backtest_single_day_hybrid( + detector, df, date, + seq_len=BACKTEST_CONFIG['seq_len'] + ) + + if alerts: + all_alerts.extend(alerts) + + saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run) + total_saved += saved + + if not args.dry_run: + tqdm.write(f" {date}: 检测到 {len(alerts)} 个异动,保存 {saved} 条") + + # 导出 CSV + if args.export_csv and all_alerts: + export_alerts_to_csv(all_alerts, args.export_csv) + + # 统计分析 + analyze_alerts(all_alerts) + + # 汇总 + print("\n" + "=" * 60) + print("回测完成!") + print("=" * 60) + print(f"总计检测到: {len(all_alerts)} 个异动") + print(f"保存到数据库: {total_saved} 条") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/ml/backtest_v2.py b/ml/backtest_v2.py new file mode 100644 index 00000000..84524fd9 --- /dev/null +++ b/ml/backtest_v2.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +V2 回测脚本 - 验证时间片对齐 + 持续性确认的效果 + +回测指标: +1. 准确率:异动后 N 分钟内 alpha 是否继续上涨/下跌 +2. 虚警率:多少异动是噪音 +3. 持续性:平均异动持续时长 +""" + +import os +import sys +import json +import argparse +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Tuple +from collections import defaultdict + +import numpy as np +import pandas as pd +from tqdm import tqdm +from sqlalchemy import create_engine, text + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ml.detector_v2 import AnomalyDetectorV2, CONFIG + + +# ==================== 配置 ==================== + +MYSQL_ENGINE = create_engine( + "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock", + echo=False +) + + +# ==================== 回测评估 ==================== + +def evaluate_alerts( + alerts: List[Dict], + raw_data: pd.DataFrame, + lookahead_minutes: int = 10 +) -> Dict: + """ + 评估异动质量 + + 指标: + 1. 方向正确率:异动后 N 分钟 alpha 方向是否一致 + 2. 持续率:异动后 N 分钟内有多少时刻 alpha 保持同向 + 3. 峰值收益:异动后 N 分钟内的最大 alpha + """ + if not alerts: + return {'accuracy': 0, 'sustained_rate': 0, 'avg_peak': 0, 'total_alerts': 0} + + results = [] + + for alert in alerts: + concept_id = alert['concept_id'] + alert_time = alert['alert_time'] + alert_alpha = alert['alpha'] + is_up = alert_alpha > 0 + + # 获取该概念在异动后的数据 + concept_data = raw_data[ + (raw_data['concept_id'] == concept_id) & + (raw_data['timestamp'] > alert_time) + ].head(lookahead_minutes) + + if len(concept_data) < 3: + continue + + future_alphas = concept_data['alpha'].values + + # 方向正确:未来 alpha 平均值与当前同向 + avg_future_alpha = np.mean(future_alphas) + direction_correct = (is_up and avg_future_alpha > 0) or (not is_up and avg_future_alpha < 0) + + # 持续率:有多少时刻保持同向 + if is_up: + sustained_count = sum(1 for a in future_alphas if a > 0) + else: + sustained_count = sum(1 for a in future_alphas if a < 0) + sustained_rate = sustained_count / len(future_alphas) + + # 峰值收益 + if is_up: + peak = max(future_alphas) + else: + peak = min(future_alphas) + + results.append({ + 'direction_correct': direction_correct, + 'sustained_rate': sustained_rate, + 'peak': peak, + 'alert_alpha': alert_alpha, + }) + + if not results: + return {'accuracy': 0, 'sustained_rate': 0, 'avg_peak': 0, 'total_alerts': 0} + + return { + 'accuracy': np.mean([r['direction_correct'] for r in results]), + 'sustained_rate': np.mean([r['sustained_rate'] for r in results]), + 'avg_peak': np.mean([abs(r['peak']) for r in results]), + 'total_alerts': len(alerts), + 'evaluated_alerts': len(results), + } + + +def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int: + """保存异动到 MySQL""" + if not alerts or dry_run: + return 0 + + # 确保表存在 + with MYSQL_ENGINE.begin() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS concept_anomaly_v2 ( + id INT AUTO_INCREMENT PRIMARY KEY, + concept_id VARCHAR(64) NOT NULL, + alert_time DATETIME NOT NULL, + trade_date DATE NOT NULL, + alert_type VARCHAR(32) NOT NULL, + final_score FLOAT NOT NULL, + rule_score FLOAT NOT NULL, + ml_score FLOAT NOT NULL, + trigger_reason VARCHAR(128), + confirm_ratio FLOAT, + alpha FLOAT, + alpha_zscore FLOAT, + amt_zscore FLOAT, + rank_zscore FLOAT, + momentum_3m FLOAT, + momentum_5m FLOAT, + limit_up_ratio FLOAT, + triggered_rules JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_concept_time (concept_id, alert_time, trade_date), + INDEX idx_trade_date (trade_date), + INDEX idx_final_score (final_score) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念异动 V2(时间片对齐+持续确认)' + """)) + + # 插入数据 + saved = 0 + with MYSQL_ENGINE.begin() as conn: + for alert in alerts: + try: + conn.execute(text(""" + INSERT IGNORE INTO concept_anomaly_v2 + (concept_id, alert_time, trade_date, alert_type, + final_score, rule_score, ml_score, trigger_reason, confirm_ratio, + alpha, alpha_zscore, amt_zscore, rank_zscore, + momentum_3m, momentum_5m, limit_up_ratio, triggered_rules) + VALUES + (:concept_id, :alert_time, :trade_date, :alert_type, + :final_score, :rule_score, :ml_score, :trigger_reason, :confirm_ratio, + :alpha, :alpha_zscore, :amt_zscore, :rank_zscore, + :momentum_3m, :momentum_5m, :limit_up_ratio, :triggered_rules) + """), { + 'concept_id': alert['concept_id'], + 'alert_time': alert['alert_time'], + 'trade_date': alert['trade_date'], + 'alert_type': alert['alert_type'], + 'final_score': alert['final_score'], + 'rule_score': alert['rule_score'], + 'ml_score': alert['ml_score'], + 'trigger_reason': alert['trigger_reason'], + 'confirm_ratio': alert.get('confirm_ratio', 0), + 'alpha': alert['alpha'], + 'alpha_zscore': alert.get('alpha_zscore', 0), + 'amt_zscore': alert.get('amt_zscore', 0), + 'rank_zscore': alert.get('rank_zscore', 0), + 'momentum_3m': alert.get('momentum_3m', 0), + 'momentum_5m': alert.get('momentum_5m', 0), + 'limit_up_ratio': alert.get('limit_up_ratio', 0), + 'triggered_rules': json.dumps(alert.get('triggered_rules', [])), + }) + saved += 1 + except Exception as e: + print(f"保存失败: {e}") + + return saved + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description='V2 回测') + parser.add_argument('--start', type=str, required=True, help='开始日期') + parser.add_argument('--end', type=str, default=None, help='结束日期') + parser.add_argument('--model_dir', type=str, default='ml/checkpoints_v2') + parser.add_argument('--baseline_dir', type=str, default='ml/data_v2/baselines') + parser.add_argument('--save', action='store_true', help='保存到数据库') + parser.add_argument('--lookahead', type=int, default=10, help='评估前瞻时间(分钟)') + + args = parser.parse_args() + + end_date = args.end or args.start + + print("=" * 60) + print("V2 回测 - 时间片对齐 + 持续性确认") + print("=" * 60) + print(f"日期范围: {args.start} ~ {end_date}") + print(f"模型目录: {args.model_dir}") + print(f"评估前瞻: {args.lookahead} 分钟") + + # 初始化检测器 + detector = AnomalyDetectorV2( + model_dir=args.model_dir, + baseline_dir=args.baseline_dir + ) + + # 获取交易日 + from prepare_data_v2 import get_trading_days + trading_days = get_trading_days(args.start, end_date) + + if not trading_days: + print("无交易日") + return + + print(f"交易日数: {len(trading_days)}") + + # 回测统计 + total_stats = { + 'total_alerts': 0, + 'accuracy_sum': 0, + 'sustained_sum': 0, + 'peak_sum': 0, + 'day_count': 0, + } + + all_alerts = [] + + for trade_date in tqdm(trading_days, desc="回测进度"): + # 检测异动 + alerts = detector.detect(trade_date) + + if not alerts: + continue + + all_alerts.extend(alerts) + + # 评估 + raw_data = detector._compute_raw_features(trade_date) + if raw_data.empty: + continue + + stats = evaluate_alerts(alerts, raw_data, args.lookahead) + + if stats['evaluated_alerts'] > 0: + total_stats['total_alerts'] += stats['total_alerts'] + total_stats['accuracy_sum'] += stats['accuracy'] * stats['evaluated_alerts'] + total_stats['sustained_sum'] += stats['sustained_rate'] * stats['evaluated_alerts'] + total_stats['peak_sum'] += stats['avg_peak'] * stats['evaluated_alerts'] + total_stats['day_count'] += 1 + + print(f"\n[{trade_date}] 异动: {stats['total_alerts']}, " + f"准确率: {stats['accuracy']:.1%}, " + f"持续率: {stats['sustained_rate']:.1%}, " + f"峰值: {stats['avg_peak']:.2f}%") + + # 汇总 + print("\n" + "=" * 60) + print("回测汇总") + print("=" * 60) + + if total_stats['total_alerts'] > 0: + avg_accuracy = total_stats['accuracy_sum'] / total_stats['total_alerts'] + avg_sustained = total_stats['sustained_sum'] / total_stats['total_alerts'] + avg_peak = total_stats['peak_sum'] / total_stats['total_alerts'] + + print(f"总异动数: {total_stats['total_alerts']}") + print(f"回测天数: {total_stats['day_count']}") + print(f"平均每天: {total_stats['total_alerts'] / max(1, total_stats['day_count']):.1f} 个") + print(f"方向准确率: {avg_accuracy:.1%}") + print(f"持续率: {avg_sustained:.1%}") + print(f"平均峰值: {avg_peak:.2f}%") + else: + print("无异动检测结果") + + # 保存 + if args.save and all_alerts: + print(f"\n保存 {len(all_alerts)} 条异动到数据库...") + saved = save_alerts_to_mysql(all_alerts) + print(f"保存完成: {saved} 条") + + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/ml/checkpoints_v2/config.json b/ml/checkpoints_v2/config.json new file mode 100644 index 00000000..007997f5 --- /dev/null +++ b/ml/checkpoints_v2/config.json @@ -0,0 +1,31 @@ +{ + "seq_len": 10, + "stride": 2, + "train_end_date": "2025-06-30", + "val_end_date": "2025-09-30", + "features": [ + "alpha_zscore", + "amt_zscore", + "rank_zscore", + "momentum_3m", + "momentum_5m", + "limit_up_ratio" + ], + "batch_size": 32768, + "epochs": 150, + "learning_rate": 0.0006, + "weight_decay": 1e-05, + "gradient_clip": 1.0, + "patience": 15, + "min_delta": 1e-06, + "model": { + "n_features": 6, + "hidden_dim": 32, + "latent_dim": 4, + "num_layers": 1, + "dropout": 0.2, + "bidirectional": true + }, + "clip_value": 5.0, + "threshold_percentiles": [90, 95, 99] +} diff --git a/ml/checkpoints_v2/thresholds.json b/ml/checkpoints_v2/thresholds.json new file mode 100644 index 00000000..47a6fe63 --- /dev/null +++ b/ml/checkpoints_v2/thresholds.json @@ -0,0 +1,8 @@ +{ + "p90": 0.15, + "p95": 0.25, + "p99": 0.50, + "mean": 0.08, + "std": 0.12, + "median": 0.06 +} diff --git a/ml/detector.py b/ml/detector.py new file mode 100644 index 00000000..c5184771 --- /dev/null +++ b/ml/detector.py @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +概念异动检测器 - 融合版 + +结合两种方法的优势: +1. 规则评分系统:可解释、稳定、覆盖已知模式 +2. LSTM Autoencoder:发现未知的异常模式 + +融合策略: +┌─────────────────────────────────────────────────────────┐ +│ 输入特征 │ +│ (alpha, alpha_delta, amt_ratio, amt_delta, rank_pct, │ +│ limit_up_ratio) │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 规则评分系统 │ │ LSTM Autoencoder │ │ +│ │ (0-100分) │ │ (重构误差) │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ rule_score (0-100) ml_score (标准化后 0-100) │ +│ │ +├─────────────────────────────────────────────────────────┤ +│ 融合策略 │ +│ │ +│ final_score = w1 * rule_score + w2 * ml_score │ +│ │ +│ 异动判定: │ +│ - rule_score >= 60 → 直接触发(规则强信号) │ +│ - ml_score >= 80 → 直接触发(ML强信号) │ +│ - final_score >= 50 → 融合触发 │ +│ │ +└─────────────────────────────────────────────────────────┘ + +优势: +- 规则系统保证已知模式的检出率 +- ML模型捕捉规则未覆盖的异常 +- 两者互相验证,减少误报 +""" + +import json +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass + +import numpy as np +import torch + +# 尝试导入模型(可能不存在) +try: + from model import LSTMAutoencoder, create_model + HAS_MODEL = True +except ImportError: + HAS_MODEL = False + + +@dataclass +class AnomalyResult: + """异动检测结果""" + is_anomaly: bool + final_score: float # 最终得分 (0-100) + rule_score: float # 规则得分 (0-100) + ml_score: float # ML得分 (0-100) + trigger_reason: str # 触发原因 + rule_details: Dict # 规则明细 + anomaly_type: str # 异动类型: surge_up / surge_down / volume_spike / unknown + + +class RuleBasedScorer: + """ + 基于规则的评分系统 + + 设计原则: + - 每个规则独立打分 + - 分数可叠加 + - 阈值可配置 + """ + + # 默认规则配置 + DEFAULT_RULES = { + # Alpha 相关(超额收益) + 'alpha_strong': { + 'condition': lambda r: abs(r.get('alpha', 0)) >= 3.0, + 'score': 35, + 'description': 'Alpha强信号(|α|≥3%)' + }, + 'alpha_medium': { + 'condition': lambda r: 2.0 <= abs(r.get('alpha', 0)) < 3.0, + 'score': 25, + 'description': 'Alpha中等(2%≤|α|<3%)' + }, + 'alpha_weak': { + 'condition': lambda r: 1.5 <= abs(r.get('alpha', 0)) < 2.0, + 'score': 15, + 'description': 'Alpha轻微(1.5%≤|α|<2%)' + }, + + # Alpha 变化率(加速度) + 'alpha_delta_strong': { + 'condition': lambda r: abs(r.get('alpha_delta', 0)) >= 1.0, + 'score': 30, + 'description': 'Alpha加速强(|Δα|≥1%)' + }, + 'alpha_delta_medium': { + 'condition': lambda r: 0.5 <= abs(r.get('alpha_delta', 0)) < 1.0, + 'score': 20, + 'description': 'Alpha加速中(0.5%≤|Δα|<1%)' + }, + + # 成交额比率(放量) + 'volume_spike_strong': { + 'condition': lambda r: r.get('amt_ratio', 1) >= 5.0, + 'score': 30, + 'description': '极度放量(≥5倍)' + }, + 'volume_spike_medium': { + 'condition': lambda r: 3.0 <= r.get('amt_ratio', 1) < 5.0, + 'score': 20, + 'description': '显著放量(3-5倍)' + }, + 'volume_spike_weak': { + 'condition': lambda r: 2.0 <= r.get('amt_ratio', 1) < 3.0, + 'score': 10, + 'description': '轻微放量(2-3倍)' + }, + + # 成交额变化率 + 'amt_delta_strong': { + 'condition': lambda r: abs(r.get('amt_delta', 0)) >= 1.0, + 'score': 15, + 'description': '成交额急变(|Δamt|≥100%)' + }, + + # 排名跳变 + 'rank_top': { + 'condition': lambda r: r.get('rank_pct', 0.5) >= 0.95, + 'score': 25, + 'description': '排名前5%' + }, + 'rank_bottom': { + 'condition': lambda r: r.get('rank_pct', 0.5) <= 0.05, + 'score': 25, + 'description': '排名后5%' + }, + 'rank_high': { + 'condition': lambda r: 0.9 <= r.get('rank_pct', 0.5) < 0.95, + 'score': 15, + 'description': '排名前10%' + }, + + # 涨停比例 + 'limit_up_high': { + 'condition': lambda r: r.get('limit_up_ratio', 0) >= 0.2, + 'score': 25, + 'description': '涨停比例≥20%' + }, + 'limit_up_medium': { + 'condition': lambda r: 0.1 <= r.get('limit_up_ratio', 0) < 0.2, + 'score': 15, + 'description': '涨停比例10-20%' + }, + + # 组合条件(更可靠的信号) + 'alpha_with_volume': { + 'condition': lambda r: abs(r.get('alpha', 0)) >= 1.5 and r.get('amt_ratio', 1) >= 2.0, + 'score': 20, # 额外加分 + 'description': 'Alpha+放量组合' + }, + 'acceleration_with_rank': { + 'condition': lambda r: abs(r.get('alpha_delta', 0)) >= 0.5 and (r.get('rank_pct', 0.5) >= 0.9 or r.get('rank_pct', 0.5) <= 0.1), + 'score': 15, # 额外加分 + 'description': '加速+排名异常组合' + }, + } + + def __init__(self, rules: Dict = None): + """ + 初始化规则评分器 + + Args: + rules: 自定义规则,格式同 DEFAULT_RULES + """ + self.rules = rules or self.DEFAULT_RULES + + def score(self, features: Dict) -> Tuple[float, Dict]: + """ + 计算规则得分 + + Args: + features: 特征字典,包含 alpha, alpha_delta, amt_ratio 等 + Returns: + score: 总分 (0-100) + details: 触发的规则明细 + """ + total_score = 0 + triggered_rules = {} + + for rule_name, rule_config in self.rules.items(): + try: + if rule_config['condition'](features): + total_score += rule_config['score'] + triggered_rules[rule_name] = { + 'score': rule_config['score'], + 'description': rule_config['description'] + } + except Exception: + # 忽略规则计算错误 + pass + + # 限制在 0-100 + total_score = min(100, max(0, total_score)) + + return total_score, triggered_rules + + def get_anomaly_type(self, features: Dict) -> str: + """判断异动类型""" + alpha = features.get('alpha', 0) + amt_ratio = features.get('amt_ratio', 1) + + if alpha >= 1.5: + return 'surge_up' + elif alpha <= -1.5: + return 'surge_down' + elif amt_ratio >= 3.0: + return 'volume_spike' + else: + return 'unknown' + + +class MLScorer: + """ + 基于 LSTM Autoencoder 的评分器 + + 将重构误差转换为 0-100 的分数 + """ + + def __init__( + self, + checkpoint_dir: str = 'ml/checkpoints', + device: str = 'auto' + ): + self.checkpoint_dir = Path(checkpoint_dir) + + # 设备检测 + if device == 'auto': + self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + elif device == 'cuda' and not torch.cuda.is_available(): + print("警告: CUDA 不可用,使用 CPU") + self.device = torch.device('cpu') + else: + self.device = torch.device(device) + + self.model = None + self.thresholds = None + self.config = None + + # 尝试加载模型 + self._load_model() + + def _load_model(self): + """加载模型和阈值""" + if not HAS_MODEL: + print("警告: 无法导入模型模块") + return + + model_path = self.checkpoint_dir / 'best_model.pt' + thresholds_path = self.checkpoint_dir / 'thresholds.json' + config_path = self.checkpoint_dir / 'config.json' + + if not model_path.exists(): + print(f"警告: 模型文件不存在 {model_path}") + return + + try: + # 加载配置 + if config_path.exists(): + with open(config_path, 'r') as f: + self.config = json.load(f) + + # 先用 CPU 加载模型(避免 CUDA 不可用问题),再移动到目标设备 + checkpoint = torch.load(model_path, map_location='cpu') + + model_config = self.config.get('model', {}) if self.config else {} + self.model = create_model(model_config) + self.model.load_state_dict(checkpoint['model_state_dict']) + self.model.to(self.device) + self.model.eval() + + # 加载阈值 + if thresholds_path.exists(): + with open(thresholds_path, 'r') as f: + self.thresholds = json.load(f) + + print(f"MLScorer 加载成功 (设备: {self.device})") + + except Exception as e: + print(f"警告: 模型加载失败 - {e}") + import traceback + traceback.print_exc() + self.model = None + + def is_ready(self) -> bool: + """检查模型是否就绪""" + return self.model is not None + + @torch.no_grad() + def score(self, sequence: np.ndarray) -> float: + """ + 计算 ML 得分 + + Args: + sequence: (seq_len, n_features) 或 (batch, seq_len, n_features) + Returns: + score: 0-100 的分数,越高越异常 + """ + if not self.is_ready(): + return 0.0 + + # 确保是 3D + if sequence.ndim == 2: + sequence = sequence[np.newaxis, ...] + + # 转为 tensor + x = torch.FloatTensor(sequence).to(self.device) + + # 计算重构误差 + output, _ = self.model(x) + mse = ((output - x) ** 2).mean(dim=-1) # (batch, seq_len) + + # 取最后时刻的误差 + error = mse[:, -1].cpu().numpy() + + # 转换为 0-100 分数 + # 使用 p95 阈值作为参考 + if self.thresholds: + p95 = self.thresholds.get('p95', 0.1) + p99 = self.thresholds.get('p99', 0.2) + else: + p95, p99 = 0.1, 0.2 + + # 线性映射:p95 -> 50分, p99 -> 80分 + # error=0 -> 0分, error>=p99*1.5 -> 100分 + score = np.clip(error / p95 * 50, 0, 100) + + return float(score[0]) if len(score) == 1 else score.tolist() + + +class HybridAnomalyDetector: + """ + 融合异动检测器 + + 结合规则系统和 ML 模型 + """ + + # 默认配置 + DEFAULT_CONFIG = { + # 权重配置 + 'rule_weight': 0.6, # 规则权重 + 'ml_weight': 0.4, # ML权重 + + # 触发阈值 + 'rule_trigger': 60, # 规则直接触发阈值 + 'ml_trigger': 80, # ML直接触发阈值 + 'fusion_trigger': 50, # 融合触发阈值 + + # 特征列表 + 'features': [ + 'alpha', 'alpha_delta', 'amt_ratio', + 'amt_delta', 'rank_pct', 'limit_up_ratio' + ], + + # 序列长度(ML模型需要) + 'seq_len': 30, + } + + def __init__( + self, + config: Dict = None, + checkpoint_dir: str = 'ml/checkpoints', + device: str = 'auto' + ): + self.config = {**self.DEFAULT_CONFIG, **(config or {})} + + # 初始化评分器 + self.rule_scorer = RuleBasedScorer() + self.ml_scorer = MLScorer(checkpoint_dir, device) + + print(f"HybridAnomalyDetector 初始化完成") + print(f" 规则权重: {self.config['rule_weight']}") + print(f" ML权重: {self.config['ml_weight']}") + print(f" ML模型: {'就绪' if self.ml_scorer.is_ready() else '未加载'}") + + def detect( + self, + features: Dict, + sequence: np.ndarray = None + ) -> AnomalyResult: + """ + 检测异动 + + Args: + features: 当前时刻的特征字典 + sequence: 历史序列 (seq_len, n_features),ML模型需要 + Returns: + AnomalyResult: 检测结果 + """ + # 1. 规则评分 + rule_score, rule_details = self.rule_scorer.score(features) + + # 2. ML评分 + ml_score = 0.0 + if sequence is not None and self.ml_scorer.is_ready(): + ml_score = self.ml_scorer.score(sequence) + + # 3. 融合得分 + w1 = self.config['rule_weight'] + w2 = self.config['ml_weight'] + + # 如果ML不可用,全部权重给规则 + if not self.ml_scorer.is_ready(): + w1, w2 = 1.0, 0.0 + + final_score = w1 * rule_score + w2 * ml_score + + # 4. 判断是否异动 + is_anomaly = False + trigger_reason = '' + + if rule_score >= self.config['rule_trigger']: + is_anomaly = True + trigger_reason = f'规则强信号({rule_score:.0f}分)' + elif ml_score >= self.config['ml_trigger']: + is_anomaly = True + trigger_reason = f'ML强信号({ml_score:.0f}分)' + elif final_score >= self.config['fusion_trigger']: + is_anomaly = True + trigger_reason = f'融合触发({final_score:.0f}分)' + + # 5. 判断异动类型 + anomaly_type = self.rule_scorer.get_anomaly_type(features) if is_anomaly else '' + + return AnomalyResult( + is_anomaly=is_anomaly, + final_score=final_score, + rule_score=rule_score, + ml_score=ml_score, + trigger_reason=trigger_reason, + rule_details=rule_details, + anomaly_type=anomaly_type + ) + + def detect_batch( + self, + features_list: List[Dict], + sequences: np.ndarray = None + ) -> List[AnomalyResult]: + """ + 批量检测 + + Args: + features_list: 特征字典列表 + sequences: (batch, seq_len, n_features) + Returns: + List[AnomalyResult] + """ + results = [] + + for i, features in enumerate(features_list): + seq = sequences[i] if sequences is not None else None + result = self.detect(features, seq) + results.append(result) + + return results + + +# ==================== 便捷函数 ==================== + +def create_detector( + checkpoint_dir: str = 'ml/checkpoints', + config: Dict = None +) -> HybridAnomalyDetector: + """创建融合检测器""" + return HybridAnomalyDetector(config, checkpoint_dir) + + +def quick_detect(features: Dict) -> bool: + """ + 快速检测(只用规则,不需要ML模型) + + 适用于: + - 实时检测 + - ML模型未训练完成时 + """ + scorer = RuleBasedScorer() + score, _ = scorer.score(features) + return score >= 50 + + +# ==================== 测试 ==================== + +if __name__ == "__main__": + print("=" * 60) + print("融合异动检测器测试") + print("=" * 60) + + # 创建检测器 + detector = create_detector() + + # 测试用例 + test_cases = [ + { + 'name': '正常情况', + 'features': { + 'alpha': 0.5, + 'alpha_delta': 0.1, + 'amt_ratio': 1.2, + 'amt_delta': 0.1, + 'rank_pct': 0.5, + 'limit_up_ratio': 0.02 + } + }, + { + 'name': 'Alpha异动', + 'features': { + 'alpha': 3.5, + 'alpha_delta': 0.8, + 'amt_ratio': 2.5, + 'amt_delta': 0.5, + 'rank_pct': 0.92, + 'limit_up_ratio': 0.05 + } + }, + { + 'name': '放量异动', + 'features': { + 'alpha': 1.2, + 'alpha_delta': 0.3, + 'amt_ratio': 6.0, + 'amt_delta': 1.5, + 'rank_pct': 0.85, + 'limit_up_ratio': 0.08 + } + }, + { + 'name': '涨停潮', + 'features': { + 'alpha': 2.5, + 'alpha_delta': 0.6, + 'amt_ratio': 3.5, + 'amt_delta': 0.8, + 'rank_pct': 0.98, + 'limit_up_ratio': 0.25 + } + }, + ] + + print("\n" + "-" * 60) + print("测试1: 只用规则(无序列数据)") + print("-" * 60) + + for case in test_cases: + result = detector.detect(case['features']) + + print(f"\n{case['name']}:") + print(f" 异动: {'是' if result.is_anomaly else '否'}") + print(f" 最终得分: {result.final_score:.1f}") + print(f" 规则得分: {result.rule_score:.1f}") + print(f" ML得分: {result.ml_score:.1f}") + if result.is_anomaly: + print(f" 触发原因: {result.trigger_reason}") + print(f" 异动类型: {result.anomaly_type}") + print(f" 触发规则: {list(result.rule_details.keys())}") + + # 测试2: 带序列数据的融合检测 + print("\n" + "-" * 60) + print("测试2: 融合检测(规则 + ML)") + print("-" * 60) + + # 生成模拟序列数据 + seq_len = 30 + n_features = 6 + + # 正常序列:小幅波动 + normal_sequence = np.random.randn(seq_len, n_features) * 0.3 + normal_sequence[:, 0] = np.linspace(0, 0.5, seq_len) # alpha 缓慢上升 + normal_sequence[:, 2] = np.abs(normal_sequence[:, 2]) + 1 # amt_ratio > 0 + + # 异常序列:最后几个时间步突然变化 + anomaly_sequence = np.random.randn(seq_len, n_features) * 0.3 + anomaly_sequence[-5:, 0] = np.linspace(1, 4, 5) # alpha 突然飙升 + anomaly_sequence[-5:, 1] = np.linspace(0.2, 1.5, 5) # alpha_delta 加速 + anomaly_sequence[-5:, 2] = np.linspace(2, 6, 5) # amt_ratio 放量 + anomaly_sequence[:, 2] = np.abs(anomaly_sequence[:, 2]) + 1 + + # 测试正常序列 + normal_features = { + 'alpha': float(normal_sequence[-1, 0]), + 'alpha_delta': float(normal_sequence[-1, 1]), + 'amt_ratio': float(normal_sequence[-1, 2]), + 'amt_delta': float(normal_sequence[-1, 3]), + 'rank_pct': 0.5, + 'limit_up_ratio': 0.02 + } + + result = detector.detect(normal_features, normal_sequence) + print(f"\n正常序列:") + print(f" 异动: {'是' if result.is_anomaly else '否'}") + print(f" 最终得分: {result.final_score:.1f}") + print(f" 规则得分: {result.rule_score:.1f}") + print(f" ML得分: {result.ml_score:.1f}") + + # 测试异常序列 + anomaly_features = { + 'alpha': float(anomaly_sequence[-1, 0]), + 'alpha_delta': float(anomaly_sequence[-1, 1]), + 'amt_ratio': float(anomaly_sequence[-1, 2]), + 'amt_delta': float(anomaly_sequence[-1, 3]), + 'rank_pct': 0.95, + 'limit_up_ratio': 0.15 + } + + result = detector.detect(anomaly_features, anomaly_sequence) + print(f"\n异常序列:") + print(f" 异动: {'是' if result.is_anomaly else '否'}") + print(f" 最终得分: {result.final_score:.1f}") + print(f" 规则得分: {result.rule_score:.1f}") + print(f" ML得分: {result.ml_score:.1f}") + if result.is_anomaly: + print(f" 触发原因: {result.trigger_reason}") + print(f" 异动类型: {result.anomaly_type}") + + print("\n" + "=" * 60) + print("测试完成!") diff --git a/ml/detector_v2.py b/ml/detector_v2.py new file mode 100644 index 00000000..e4e6f1ae --- /dev/null +++ b/ml/detector_v2.py @@ -0,0 +1,716 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +异动检测器 V2 - 基于时间片对齐 + 持续性确认 + +核心改进: +1. Z-Score 特征:相对于同时间片历史的偏离 +2. 短序列 LSTM:10分钟序列,开盘即可用 +3. 持续性确认:5分钟窗口内60%时刻超标才确认为异动 + +检测流程: +1. 计算当前时刻的 Z-Score(对比同时间片历史基线) +2. 构建最近10分钟的 Z-Score 序列 +3. LSTM 计算重构误差(ML分数) +4. 规则评分(基于 Z-Score 的规则) +5. 滑动窗口确认:最近5分钟内是否有足够多的时刻超标 +6. 只有通过持续性确认的才输出为异动 +""" + +import os +import sys +import json +import pickle +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple +from collections import defaultdict, deque + +import numpy as np +import pandas as pd +import torch +from sqlalchemy import create_engine, text +from elasticsearch import Elasticsearch +from clickhouse_driver import Client + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ml.model import TransformerAutoencoder + +# ==================== 配置 ==================== + +MYSQL_ENGINE = create_engine( + "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock", + echo=False +) + +ES_CLIENT = Elasticsearch(['http://127.0.0.1:9200']) +ES_INDEX = 'concept_library_v3' + +CLICKHOUSE_CONFIG = { + 'host': '127.0.0.1', + 'port': 9000, + 'user': 'default', + 'password': 'Zzl33818!', + 'database': 'stock' +} + +REFERENCE_INDEX = '000001.SH' + +# 检测配置 +CONFIG = { + # 序列配置 + 'seq_len': 10, # LSTM 序列长度(分钟) + + # 持续性确认配置(核心!) + 'confirm_window': 5, # 确认窗口(分钟) + 'confirm_ratio': 0.6, # 确认比例(60%时刻需要超标) + + # Z-Score 阈值 + 'alpha_zscore_threshold': 2.0, # Alpha Z-Score 阈值 + 'amt_zscore_threshold': 2.5, # 成交额 Z-Score 阈值 + + # 融合权重 + 'rule_weight': 0.5, + 'ml_weight': 0.5, + + # 触发阈值 + 'rule_trigger': 60, + 'ml_trigger': 70, + 'fusion_trigger': 50, + + # 冷却期 + 'cooldown_minutes': 10, + 'max_alerts_per_minute': 15, + + # Z-Score 截断 + 'zscore_clip': 5.0, +} + +# V2 特征列表 +FEATURES_V2 = [ + 'alpha_zscore', 'amt_zscore', 'rank_zscore', + 'momentum_3m', 'momentum_5m', 'limit_up_ratio' +] + + +# ==================== 工具函数 ==================== + +def get_ch_client(): + return Client(**CLICKHOUSE_CONFIG) + + +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 time_to_slot(ts) -> str: + """时间戳转时间片(HH:MM)""" + if isinstance(ts, str): + return ts + return ts.strftime('%H:%M') + + +# ==================== 基线加载 ==================== + +def load_baselines(baseline_dir: str = 'ml/data_v2/baselines') -> Dict[str, pd.DataFrame]: + """加载时间片基线""" + baseline_file = os.path.join(baseline_dir, 'baselines.pkl') + if os.path.exists(baseline_file): + with open(baseline_file, 'rb') as f: + return pickle.load(f) + return {} + + +# ==================== 规则评分(基于 Z-Score)==================== + +def score_rules_zscore(row: Dict) -> Tuple[float, List[str]]: + """ + 基于 Z-Score 的规则评分 + + 设计思路:Z-Score 已经标准化,直接用阈值判断 + """ + score = 0.0 + triggered = [] + + alpha_zscore = row.get('alpha_zscore', 0) + amt_zscore = row.get('amt_zscore', 0) + rank_zscore = row.get('rank_zscore', 0) + momentum_3m = row.get('momentum_3m', 0) + momentum_5m = row.get('momentum_5m', 0) + limit_up_ratio = row.get('limit_up_ratio', 0) + + alpha_zscore_abs = abs(alpha_zscore) + amt_zscore_abs = abs(amt_zscore) + + # ========== Alpha Z-Score 规则 ========== + if alpha_zscore_abs >= 4.0: + score += 25 + triggered.append('alpha_zscore_extreme') + elif alpha_zscore_abs >= 3.0: + score += 18 + triggered.append('alpha_zscore_strong') + elif alpha_zscore_abs >= 2.0: + score += 10 + triggered.append('alpha_zscore_moderate') + + # ========== 成交额 Z-Score 规则 ========== + if amt_zscore >= 4.0: + score += 20 + triggered.append('amt_zscore_extreme') + elif amt_zscore >= 3.0: + score += 12 + triggered.append('amt_zscore_strong') + elif amt_zscore >= 2.0: + score += 6 + triggered.append('amt_zscore_moderate') + + # ========== 排名 Z-Score 规则 ========== + if abs(rank_zscore) >= 3.0: + score += 15 + triggered.append('rank_zscore_extreme') + elif abs(rank_zscore) >= 2.0: + score += 8 + triggered.append('rank_zscore_strong') + + # ========== 动量规则 ========== + if momentum_3m >= 1.0: + score += 12 + triggered.append('momentum_3m_strong') + elif momentum_3m >= 0.5: + score += 6 + triggered.append('momentum_3m_moderate') + + if momentum_5m >= 1.5: + score += 10 + triggered.append('momentum_5m_strong') + + # ========== 涨停比例规则 ========== + if limit_up_ratio >= 0.3: + score += 20 + triggered.append('limit_up_extreme') + elif limit_up_ratio >= 0.15: + score += 12 + triggered.append('limit_up_strong') + elif limit_up_ratio >= 0.08: + score += 5 + triggered.append('limit_up_moderate') + + # ========== 组合规则 ========== + # Alpha Z-Score + 成交额放大 + if alpha_zscore_abs >= 2.0 and amt_zscore >= 2.0: + score += 15 + triggered.append('combo_alpha_amt') + + # Alpha Z-Score + 涨停 + if alpha_zscore_abs >= 2.0 and limit_up_ratio >= 0.1: + score += 12 + triggered.append('combo_alpha_limitup') + + return min(score, 100), triggered + + +# ==================== ML 评分器 ==================== + +class MLScorerV2: + """V2 ML 评分器""" + + def __init__(self, model_dir: str = 'ml/checkpoints_v2'): + self.model_dir = model_dir + self.model = None + self.thresholds = None + self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self._load_model() + + def _load_model(self): + """加载模型和阈值""" + model_path = os.path.join(self.model_dir, 'best_model.pt') + threshold_path = os.path.join(self.model_dir, 'thresholds.json') + config_path = os.path.join(self.model_dir, 'config.json') + + if not os.path.exists(model_path): + print(f"警告: 模型文件不存在: {model_path}") + return + + # 加载配置 + with open(config_path, 'r') as f: + config = json.load(f) + + # 创建模型 + model_config = config.get('model', {}) + self.model = TransformerAutoencoder(**model_config) + + # 加载权重 + checkpoint = torch.load(model_path, map_location=self.device) + self.model.load_state_dict(checkpoint['model_state_dict']) + self.model.to(self.device) + self.model.eval() + + # 加载阈值 + if os.path.exists(threshold_path): + with open(threshold_path, 'r') as f: + self.thresholds = json.load(f) + + print(f"V2 模型加载完成: {model_path}") + + @torch.no_grad() + def score_batch(self, sequences: np.ndarray) -> np.ndarray: + """ + 批量计算 ML 分数 + + 返回 0-100 的分数,越高越异常 + """ + if self.model is None: + return np.zeros(len(sequences)) + + # 转换为 tensor + x = torch.FloatTensor(sequences).to(self.device) + + # 计算重构误差 + errors = self.model.compute_reconstruction_error(x, reduction='none') + # 取最后一个时刻的误差 + last_errors = errors[:, -1].cpu().numpy() + + # 转换为 0-100 分数 + if self.thresholds: + p50 = self.thresholds.get('median', 0.1) + p99 = self.thresholds.get('p99', 1.0) + + # 线性映射:p50 -> 50分,p99 -> 99分 + scores = 50 + (last_errors - p50) / (p99 - p50) * 49 + scores = np.clip(scores, 0, 100) + else: + # 没有阈值时,简单归一化 + scores = last_errors * 100 + scores = np.clip(scores, 0, 100) + + return scores + + +# ==================== 实时数据管理器 ==================== + +class RealtimeDataManagerV2: + """ + V2 实时数据管理器 + + 维护: + 1. 每个概念的历史 Z-Score 序列(用于 LSTM 输入) + 2. 每个概念的异动候选队列(用于持续性确认) + """ + + def __init__(self, concepts: List[dict], baselines: Dict[str, pd.DataFrame]): + self.concepts = {c['concept_id']: c for c in concepts} + self.baselines = baselines + + # 概念到股票的映射 + self.concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts} + + # 历史 Z-Score 序列(每个概念) + # {concept_id: deque([(timestamp, features_dict), ...], maxlen=seq_len)} + self.zscore_history = defaultdict(lambda: deque(maxlen=CONFIG['seq_len'])) + + # 异动候选队列(用于持续性确认) + # {concept_id: deque([(timestamp, score), ...], maxlen=confirm_window)} + self.anomaly_candidates = defaultdict(lambda: deque(maxlen=CONFIG['confirm_window'])) + + # 冷却期记录 + self.cooldown = {} + + # 上一次更新的时间戳 + self.last_timestamp = None + + def compute_zscore_features( + self, + concept_id: str, + timestamp, + alpha: float, + total_amt: float, + rank_pct: float, + limit_up_ratio: float + ) -> Optional[Dict]: + """计算单个概念单个时刻的 Z-Score 特征""" + if concept_id not in self.baselines: + return None + + baseline = self.baselines[concept_id] + time_slot = time_to_slot(timestamp) + + # 查找对应时间片的基线 + bl_row = baseline[baseline['time_slot'] == time_slot] + if bl_row.empty: + return None + + bl = bl_row.iloc[0] + + # 检查样本量 + if bl.get('sample_count', 0) < 10: + return None + + # 计算 Z-Score + alpha_zscore = (alpha - bl['alpha_mean']) / bl['alpha_std'] + amt_zscore = (total_amt - bl['amt_mean']) / bl['amt_std'] + rank_zscore = (rank_pct - bl['rank_mean']) / bl['rank_std'] + + # 截断 + clip = CONFIG['zscore_clip'] + alpha_zscore = np.clip(alpha_zscore, -clip, clip) + amt_zscore = np.clip(amt_zscore, -clip, clip) + rank_zscore = np.clip(rank_zscore, -clip, clip) + + # 计算动量(需要历史) + history = self.zscore_history[concept_id] + momentum_3m = 0 + momentum_5m = 0 + + if len(history) >= 3: + recent_alphas = [h[1]['alpha'] for h in list(history)[-3:]] + older_alphas = [h[1]['alpha'] for h in list(history)[-6:-3]] if len(history) >= 6 else [alpha] + momentum_3m = np.mean(recent_alphas) - np.mean(older_alphas) + + if len(history) >= 5: + recent_alphas = [h[1]['alpha'] for h in list(history)[-5:]] + older_alphas = [h[1]['alpha'] for h in list(history)[-10:-5]] if len(history) >= 10 else [alpha] + momentum_5m = np.mean(recent_alphas) - np.mean(older_alphas) + + return { + 'alpha': alpha, + 'alpha_zscore': alpha_zscore, + 'amt_zscore': amt_zscore, + 'rank_zscore': rank_zscore, + 'momentum_3m': momentum_3m, + 'momentum_5m': momentum_5m, + 'limit_up_ratio': limit_up_ratio, + 'total_amt': total_amt, + 'rank_pct': rank_pct, + } + + def update(self, concept_id: str, timestamp, features: Dict): + """更新概念的历史数据""" + self.zscore_history[concept_id].append((timestamp, features)) + + def get_sequence(self, concept_id: str) -> Optional[np.ndarray]: + """获取用于 LSTM 的序列""" + history = self.zscore_history[concept_id] + + if len(history) < CONFIG['seq_len']: + return None + + # 提取特征 + feature_list = [] + for _, features in history: + feature_list.append([ + features['alpha_zscore'], + features['amt_zscore'], + features['rank_zscore'], + features['momentum_3m'], + features['momentum_5m'], + features['limit_up_ratio'], + ]) + + return np.array(feature_list) + + def add_anomaly_candidate(self, concept_id: str, timestamp, score: float): + """添加异动候选""" + self.anomaly_candidates[concept_id].append((timestamp, score)) + + def check_sustained_anomaly(self, concept_id: str, threshold: float) -> Tuple[bool, float]: + """ + 检查是否为持续性异动 + + 返回:(是否确认, 确认比例) + """ + candidates = self.anomaly_candidates[concept_id] + + if len(candidates) < CONFIG['confirm_window']: + return False, 0.0 + + # 统计超过阈值的时刻数量 + exceed_count = sum(1 for _, score in candidates if score >= threshold) + ratio = exceed_count / len(candidates) + + return ratio >= CONFIG['confirm_ratio'], ratio + + def check_cooldown(self, concept_id: str, timestamp) -> bool: + """检查是否在冷却期""" + if concept_id not in self.cooldown: + return False + + last_alert = self.cooldown[concept_id] + try: + diff = (timestamp - last_alert).total_seconds() / 60 + return diff < CONFIG['cooldown_minutes'] + except: + return False + + def set_cooldown(self, concept_id: str, timestamp): + """设置冷却期""" + self.cooldown[concept_id] = timestamp + + +# ==================== 异动检测器 V2 ==================== + +class AnomalyDetectorV2: + """ + V2 异动检测器 + + 核心流程: + 1. 获取实时数据 + 2. 计算 Z-Score 特征 + 3. 规则评分 + ML 评分 + 4. 持续性确认 + 5. 输出异动 + """ + + def __init__( + self, + model_dir: str = 'ml/checkpoints_v2', + baseline_dir: str = 'ml/data_v2/baselines' + ): + # 加载概念 + self.concepts = self._load_concepts() + + # 加载基线 + self.baselines = load_baselines(baseline_dir) + print(f"加载了 {len(self.baselines)} 个概念的基线") + + # 初始化 ML 评分器 + self.ml_scorer = MLScorerV2(model_dir) + + # 初始化数据管理器 + self.data_manager = RealtimeDataManagerV2(self.concepts, self.baselines) + + # 收集所有股票 + self.all_stocks = list(set(s for c in self.concepts for s in c['stocks'])) + + def _load_concepts(self) -> List[dict]: + """从 ES 加载概念""" + concepts = [] + query = {"query": {"match_all": {}}, "size": 100, "_source": ["concept_id", "concept", "stocks"]} + + resp = ES_CLIENT.search(index=ES_INDEX, body=query, scroll='2m') + scroll_id = resp['_scroll_id'] + hits = resp['hits']['hits'] + + while len(hits) > 0: + for hit in hits: + source = hit['_source'] + stocks = [] + 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']: + stocks.append(stock['code']) + if stocks: + concepts.append({ + 'concept_id': source.get('concept_id'), + 'concept_name': source.get('concept'), + 'stocks': stocks + }) + + 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) + print(f"加载了 {len(concepts)} 个概念") + return concepts + + def detect(self, trade_date: str) -> List[Dict]: + """ + 检测指定日期的异动 + + 返回异动列表 + """ + print(f"\n检测 {trade_date} 的异动...") + + # 获取原始数据 + raw_features = self._compute_raw_features(trade_date) + if raw_features.empty: + print("无数据") + return [] + + # 按时间排序 + timestamps = sorted(raw_features['timestamp'].unique()) + print(f"时间点数: {len(timestamps)}") + + all_alerts = [] + + for ts in timestamps: + ts_data = raw_features[raw_features['timestamp'] == ts] + ts_alerts = self._process_timestamp(ts, ts_data, trade_date) + all_alerts.extend(ts_alerts) + + print(f"共检测到 {len(all_alerts)} 个异动") + return all_alerts + + def _compute_raw_features(self, trade_date: str) -> pd.DataFrame: + """计算原始特征(同 prepare_data_v2)""" + # 这里简化处理,直接调用数据准备逻辑 + from prepare_data_v2 import compute_raw_concept_features + return compute_raw_concept_features(trade_date, self.concepts, self.all_stocks) + + def _process_timestamp(self, timestamp, ts_data: pd.DataFrame, trade_date: str) -> List[Dict]: + """处理单个时间戳""" + alerts = [] + candidates = [] # (concept_id, features, rule_score, triggered_rules) + + for _, row in ts_data.iterrows(): + concept_id = row['concept_id'] + + # 计算 Z-Score 特征 + features = self.data_manager.compute_zscore_features( + concept_id, timestamp, + row['alpha'], row['total_amt'], row['rank_pct'], row['limit_up_ratio'] + ) + + if features is None: + continue + + # 更新历史 + self.data_manager.update(concept_id, timestamp, features) + + # 规则评分 + rule_score, triggered_rules = score_rules_zscore(features) + + # 收集候选 + candidates.append((concept_id, features, rule_score, triggered_rules)) + + if not candidates: + return [] + + # 批量 ML 评分 + sequences = [] + valid_candidates = [] + + for concept_id, features, rule_score, triggered_rules in candidates: + seq = self.data_manager.get_sequence(concept_id) + if seq is not None: + sequences.append(seq) + valid_candidates.append((concept_id, features, rule_score, triggered_rules)) + + if not sequences: + return [] + + sequences = np.array(sequences) + ml_scores = self.ml_scorer.score_batch(sequences) + + # 融合评分 + 持续性确认 + for i, (concept_id, features, rule_score, triggered_rules) in enumerate(valid_candidates): + ml_score = ml_scores[i] + final_score = CONFIG['rule_weight'] * rule_score + CONFIG['ml_weight'] * ml_score + + # 判断是否触发 + is_triggered = ( + rule_score >= CONFIG['rule_trigger'] or + ml_score >= CONFIG['ml_trigger'] or + final_score >= CONFIG['fusion_trigger'] + ) + + # 添加到候选队列 + self.data_manager.add_anomaly_candidate(concept_id, timestamp, final_score) + + if not is_triggered: + continue + + # 检查冷却期 + if self.data_manager.check_cooldown(concept_id, timestamp): + continue + + # 持续性确认 + is_sustained, confirm_ratio = self.data_manager.check_sustained_anomaly( + concept_id, CONFIG['fusion_trigger'] + ) + + if not is_sustained: + continue + + # 确认为异动! + self.data_manager.set_cooldown(concept_id, timestamp) + + # 确定异动类型 + alpha = features['alpha'] + if alpha >= 1.5: + alert_type = 'surge_up' + elif alpha <= -1.5: + alert_type = 'surge_down' + elif features['amt_zscore'] >= 3.0: + alert_type = 'volume_spike' + else: + alert_type = 'surge' + + # 确定触发原因 + if rule_score >= CONFIG['rule_trigger']: + trigger_reason = f'规则({rule_score:.0f})+持续确认({confirm_ratio:.0%})' + elif ml_score >= CONFIG['ml_trigger']: + trigger_reason = f'ML({ml_score:.0f})+持续确认({confirm_ratio:.0%})' + else: + trigger_reason = f'融合({final_score:.0f})+持续确认({confirm_ratio:.0%})' + + alerts.append({ + 'concept_id': concept_id, + 'concept_name': self.data_manager.concepts.get(concept_id, {}).get('concept_name', concept_id), + 'alert_time': timestamp, + 'trade_date': trade_date, + 'alert_type': alert_type, + 'final_score': final_score, + 'rule_score': rule_score, + 'ml_score': ml_score, + 'trigger_reason': trigger_reason, + 'confirm_ratio': confirm_ratio, + 'alpha': alpha, + 'alpha_zscore': features['alpha_zscore'], + 'amt_zscore': features['amt_zscore'], + 'rank_zscore': features['rank_zscore'], + 'momentum_3m': features['momentum_3m'], + 'momentum_5m': features['momentum_5m'], + 'limit_up_ratio': features['limit_up_ratio'], + 'triggered_rules': triggered_rules, + }) + + # 每分钟最多 N 个 + if len(alerts) > CONFIG['max_alerts_per_minute']: + alerts = sorted(alerts, key=lambda x: x['final_score'], reverse=True) + alerts = alerts[:CONFIG['max_alerts_per_minute']] + + return alerts + + +# ==================== 主函数 ==================== + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='V2 异动检测器') + parser.add_argument('--date', type=str, default=None, help='检测日期(默认今天)') + parser.add_argument('--model_dir', type=str, default='ml/checkpoints_v2') + parser.add_argument('--baseline_dir', type=str, default='ml/data_v2/baselines') + + args = parser.parse_args() + + trade_date = args.date or datetime.now().strftime('%Y-%m-%d') + + detector = AnomalyDetectorV2( + model_dir=args.model_dir, + baseline_dir=args.baseline_dir + ) + + alerts = detector.detect(trade_date) + + print(f"\n检测结果:") + for alert in alerts[:20]: + print(f" [{alert['alert_time'].strftime('%H:%M') if hasattr(alert['alert_time'], 'strftime') else alert['alert_time']}] " + f"{alert['concept_name']} ({alert['alert_type']}) " + f"分数={alert['final_score']:.0f} " + f"确认率={alert['confirm_ratio']:.0%}") + + if len(alerts) > 20: + print(f" ... 共 {len(alerts)} 个异动") + + +if __name__ == "__main__": + main() diff --git a/ml/enhanced_detector.py b/ml/enhanced_detector.py new file mode 100644 index 00000000..b64c63a8 --- /dev/null +++ b/ml/enhanced_detector.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +增强版概念异动检测器 + +融合两种检测方法: +1. Alpha-based Z-Score(规则方法,实时性好) +2. Transformer Autoencoder(ML方法,更准确) + +使用策略: +- 当 ML 模型可用且历史数据足够时,优先使用 ML 方法 +- 否则回退到 Alpha-based 方法 +- 可以配置两种方法的融合权重 +""" + +import os +import sys +import logging +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Tuple, Optional +from dataclasses import dataclass, field +from collections import deque +import numpy as np + +# 添加父目录到路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +logger = logging.getLogger(__name__) + + +# ==================== 配置 ==================== + +ENHANCED_CONFIG = { + # 融合策略 + 'fusion_mode': 'adaptive', # 'ml_only', 'alpha_only', 'adaptive', 'ensemble' + + # ML 权重(在 ensemble 模式下) + 'ml_weight': 0.6, + 'alpha_weight': 0.4, + + # ML 模型配置 + 'ml_checkpoint_dir': 'ml/checkpoints', + 'ml_threshold_key': 'p95', # p90, p95, p99 + + # Alpha 配置(与 concept_alert_alpha.py 一致) + 'alpha_zscore_threshold': 2.0, + 'alpha_absolute_threshold': 1.5, + 'alpha_history_window': 60, + 'alpha_min_history': 5, + + # 共享配置 + 'cooldown_minutes': 8, + 'max_alerts_per_minute': 15, + 'min_alpha_abs': 0.5, +} + +# 特征配置(与训练一致) +FEATURE_NAMES = [ + 'alpha', + 'alpha_delta', + 'amt_ratio', + 'amt_delta', + 'rank_pct', + 'limit_up_ratio', +] + + +# ==================== 数据结构 ==================== + +@dataclass +class AlphaStats: + """概念的Alpha统计信息""" + history: deque = field(default_factory=lambda: deque(maxlen=ENHANCED_CONFIG['alpha_history_window'])) + mean: float = 0.0 + std: float = 1.0 + + def update(self, alpha: float): + self.history.append(alpha) + 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: + if len(self.history) < ENHANCED_CONFIG['alpha_min_history']: + return 0.0 + return (alpha - self.mean) / self.std + + def is_ready(self) -> bool: + return len(self.history) >= ENHANCED_CONFIG['alpha_min_history'] + + +@dataclass +class ConceptFeatures: + """概念的实时特征""" + alpha: float = 0.0 + alpha_delta: float = 0.0 + amt_ratio: float = 1.0 + amt_delta: float = 0.0 + rank_pct: float = 0.5 + limit_up_ratio: float = 0.0 + + def to_dict(self) -> Dict[str, float]: + return { + 'alpha': self.alpha, + 'alpha_delta': self.alpha_delta, + 'amt_ratio': self.amt_ratio, + 'amt_delta': self.amt_delta, + 'rank_pct': self.rank_pct, + 'limit_up_ratio': self.limit_up_ratio, + } + + +# ==================== 增强检测器 ==================== + +class EnhancedAnomalyDetector: + """ + 增强版异动检测器 + + 融合 Alpha-based 和 ML 两种方法 + """ + + def __init__( + self, + config: Dict = None, + ml_enabled: bool = True + ): + self.config = config or ENHANCED_CONFIG + self.ml_enabled = ml_enabled + self.ml_detector = None + + # Alpha 统计 + self.alpha_stats: Dict[str, AlphaStats] = {} + + # 特征历史(用于计算 delta) + self.feature_history: Dict[str, deque] = {} + + # 冷却记录 + self.cooldown_cache: Dict[str, datetime] = {} + + # 尝试加载 ML 模型 + if ml_enabled: + self._load_ml_model() + + logger.info(f"EnhancedAnomalyDetector 初始化完成") + logger.info(f" 融合模式: {self.config['fusion_mode']}") + logger.info(f" ML 可用: {self.ml_detector is not None}") + + def _load_ml_model(self): + """加载 ML 模型""" + try: + from inference import ConceptAnomalyDetector + checkpoint_dir = Path(__file__).parent / 'checkpoints' + + if (checkpoint_dir / 'best_model.pt').exists(): + self.ml_detector = ConceptAnomalyDetector( + checkpoint_dir=str(checkpoint_dir), + threshold_key=self.config['ml_threshold_key'] + ) + logger.info("ML 模型加载成功") + else: + logger.warning(f"ML 模型不存在: {checkpoint_dir / 'best_model.pt'}") + except Exception as e: + logger.warning(f"ML 模型加载失败: {e}") + self.ml_detector = None + + def _get_alpha_stats(self, concept_id: str) -> AlphaStats: + """获取或创建 Alpha 统计""" + if concept_id not in self.alpha_stats: + self.alpha_stats[concept_id] = AlphaStats() + return self.alpha_stats[concept_id] + + def _get_feature_history(self, concept_id: str) -> deque: + """获取特征历史""" + if concept_id not in self.feature_history: + self.feature_history[concept_id] = deque(maxlen=10) + return self.feature_history[concept_id] + + def _check_cooldown(self, concept_id: str, current_time: datetime) -> bool: + """检查冷却""" + if concept_id not in self.cooldown_cache: + return False + + last_alert = self.cooldown_cache[concept_id] + cooldown_td = (current_time - last_alert).total_seconds() / 60 + + return cooldown_td < self.config['cooldown_minutes'] + + def _set_cooldown(self, concept_id: str, current_time: datetime): + """设置冷却""" + self.cooldown_cache[concept_id] = current_time + + def compute_features( + self, + concept_id: str, + alpha: float, + amt_ratio: float, + rank_pct: float, + limit_up_ratio: float + ) -> ConceptFeatures: + """ + 计算概念的完整特征 + + Args: + concept_id: 概念ID + alpha: 当前超额收益 + amt_ratio: 成交额比率 + rank_pct: 排名百分位 + limit_up_ratio: 涨停股占比 + + Returns: + 完整特征 + """ + history = self._get_feature_history(concept_id) + + # 计算变化率 + alpha_delta = 0.0 + amt_delta = 0.0 + + if len(history) > 0: + last_features = history[-1] + alpha_delta = alpha - last_features.alpha + if last_features.amt_ratio > 0: + amt_delta = (amt_ratio - last_features.amt_ratio) / last_features.amt_ratio + + features = ConceptFeatures( + alpha=alpha, + alpha_delta=alpha_delta, + amt_ratio=amt_ratio, + amt_delta=amt_delta, + rank_pct=rank_pct, + limit_up_ratio=limit_up_ratio, + ) + + # 更新历史 + history.append(features) + + return features + + def detect_alpha_anomaly( + self, + concept_id: str, + alpha: float + ) -> Tuple[bool, float, str]: + """ + Alpha-based 异动检测 + + Returns: + is_anomaly: 是否异动 + score: 异动分数(Z-Score 绝对值) + reason: 触发原因 + """ + stats = self._get_alpha_stats(concept_id) + + # 计算 Z-Score(在更新前) + zscore = stats.get_zscore(alpha) + + # 更新统计 + stats.update(alpha) + + # 判断 + if stats.is_ready(): + if abs(zscore) >= self.config['alpha_zscore_threshold']: + return True, abs(zscore), f"Z={zscore:.2f}" + else: + if abs(alpha) >= self.config['alpha_absolute_threshold']: + fake_zscore = alpha / 0.5 + return True, abs(fake_zscore), f"Alpha={alpha:+.2f}%" + + return False, abs(zscore) if zscore else 0.0, "" + + def detect_ml_anomaly( + self, + concept_id: str, + features: ConceptFeatures + ) -> Tuple[bool, float]: + """ + ML-based 异动检测 + + Returns: + is_anomaly: 是否异动 + score: 异动分数(重构误差) + """ + if self.ml_detector is None: + return False, 0.0 + + try: + is_anomaly, score = self.ml_detector.detect( + concept_id, + features.to_dict() + ) + return is_anomaly, score or 0.0 + except Exception as e: + logger.warning(f"ML 检测失败: {e}") + return False, 0.0 + + def detect( + self, + concept_id: str, + concept_name: str, + alpha: float, + amt_ratio: float, + rank_pct: float, + limit_up_ratio: float, + change_pct: float, + index_change: float, + current_time: datetime, + **extra_data + ) -> Optional[Dict]: + """ + 融合检测 + + Args: + concept_id: 概念ID + concept_name: 概念名称 + alpha: 超额收益 + amt_ratio: 成交额比率 + rank_pct: 排名百分位 + limit_up_ratio: 涨停股占比 + change_pct: 概念涨跌幅 + index_change: 大盘涨跌幅 + current_time: 当前时间 + **extra_data: 其他数据(limit_up_count, stock_count 等) + + Returns: + 异动信息(如果触发),否则 None + """ + # Alpha 太小,不关注 + if abs(alpha) < self.config['min_alpha_abs']: + return None + + # 检查冷却 + if self._check_cooldown(concept_id, current_time): + return None + + # 计算特征 + features = self.compute_features( + concept_id, alpha, amt_ratio, rank_pct, limit_up_ratio + ) + + # 执行检测 + fusion_mode = self.config['fusion_mode'] + + alpha_anomaly, alpha_score, alpha_reason = self.detect_alpha_anomaly(concept_id, alpha) + ml_anomaly, ml_score = False, 0.0 + + if fusion_mode in ('ml_only', 'adaptive', 'ensemble'): + ml_anomaly, ml_score = self.detect_ml_anomaly(concept_id, features) + + # 根据融合模式判断 + is_anomaly = False + final_score = 0.0 + detection_method = '' + + if fusion_mode == 'alpha_only': + is_anomaly = alpha_anomaly + final_score = alpha_score + detection_method = 'alpha' + + elif fusion_mode == 'ml_only': + is_anomaly = ml_anomaly + final_score = ml_score + detection_method = 'ml' + + elif fusion_mode == 'adaptive': + # 优先 ML,回退 Alpha + if self.ml_detector and ml_score > 0: + is_anomaly = ml_anomaly + final_score = ml_score + detection_method = 'ml' + else: + is_anomaly = alpha_anomaly + final_score = alpha_score + detection_method = 'alpha' + + elif fusion_mode == 'ensemble': + # 加权融合 + # 归一化分数 + norm_alpha = min(alpha_score / 5.0, 1.0) # Z > 5 视为 1.0 + norm_ml = min(ml_score / (self.ml_detector.threshold if self.ml_detector else 1.0), 1.0) + + final_score = ( + self.config['alpha_weight'] * norm_alpha + + self.config['ml_weight'] * norm_ml + ) + is_anomaly = final_score > 0.5 or alpha_anomaly or ml_anomaly + detection_method = 'ensemble' + + if not is_anomaly: + return None + + # 构建异动记录 + self._set_cooldown(concept_id, current_time) + + alert_type = 'surge_up' if alpha > 0 else 'surge_down' + + alert = { + 'concept_id': concept_id, + 'concept_name': concept_name, + 'alert_type': alert_type, + 'alert_time': current_time, + 'change_pct': change_pct, + 'alpha': alpha, + 'alpha_zscore': alpha_score, + 'index_change_pct': index_change, + 'detection_method': detection_method, + 'alpha_score': alpha_score, + 'ml_score': ml_score, + 'final_score': final_score, + **extra_data + } + + return alert + + def batch_detect( + self, + concepts_data: List[Dict], + current_time: datetime + ) -> List[Dict]: + """ + 批量检测 + + Args: + concepts_data: 概念数据列表 + current_time: 当前时间 + + Returns: + 异动列表(按分数排序,限制数量) + """ + alerts = [] + + for data in concepts_data: + alert = self.detect( + concept_id=data['concept_id'], + concept_name=data['concept_name'], + alpha=data.get('alpha', 0), + amt_ratio=data.get('amt_ratio', 1.0), + rank_pct=data.get('rank_pct', 0.5), + limit_up_ratio=data.get('limit_up_ratio', 0), + change_pct=data.get('change_pct', 0), + index_change=data.get('index_change', 0), + current_time=current_time, + limit_up_count=data.get('limit_up_count', 0), + limit_down_count=data.get('limit_down_count', 0), + stock_count=data.get('stock_count', 0), + concept_type=data.get('concept_type', 'leaf'), + ) + + if alert: + alerts.append(alert) + + # 排序并限制数量 + alerts.sort(key=lambda x: x['final_score'], reverse=True) + return alerts[:self.config['max_alerts_per_minute']] + + def reset(self): + """重置所有状态(新交易日)""" + self.alpha_stats.clear() + self.feature_history.clear() + self.cooldown_cache.clear() + + if self.ml_detector: + self.ml_detector.clear_history() + + logger.info("检测器状态已重置") + + +# ==================== 测试 ==================== + +if __name__ == "__main__": + import random + + print("测试 EnhancedAnomalyDetector...") + + # 初始化 + detector = EnhancedAnomalyDetector(ml_enabled=False) # 不加载 ML(可能不存在) + + # 模拟数据 + concepts = [ + {'concept_id': 'ai_001', 'concept_name': '人工智能'}, + {'concept_id': 'chip_002', 'concept_name': '芯片半导体'}, + {'concept_id': 'car_003', 'concept_name': '新能源汽车'}, + ] + + print("\n模拟实时检测...") + current_time = datetime.now() + + for minute in range(50): + concepts_data = [] + + for c in concepts: + # 生成随机数据 + alpha = random.gauss(0, 0.8) + amt_ratio = max(0.3, random.gauss(1, 0.3)) + rank_pct = random.random() + limit_up_ratio = random.random() * 0.1 + + # 模拟异动(第30分钟人工智能暴涨) + if minute == 30 and c['concept_id'] == 'ai_001': + alpha = 4.5 + amt_ratio = 2.5 + limit_up_ratio = 0.3 + + concepts_data.append({ + **c, + 'alpha': alpha, + 'amt_ratio': amt_ratio, + 'rank_pct': rank_pct, + 'limit_up_ratio': limit_up_ratio, + 'change_pct': alpha + 0.5, + 'index_change': 0.5, + }) + + # 检测 + alerts = detector.batch_detect(concepts_data, current_time) + + if alerts: + for alert in alerts: + print(f" t={minute:02d} 🔥 {alert['concept_name']} " + f"Alpha={alert['alpha']:+.2f}% " + f"Score={alert['final_score']:.2f} " + f"Method={alert['detection_method']}") + + current_time = current_time.replace(minute=current_time.minute + 1 if current_time.minute < 59 else 0) + + print("\n测试完成!") diff --git a/ml/inference.py b/ml/inference.py new file mode 100644 index 00000000..4e704f4c --- /dev/null +++ b/ml/inference.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +概念异动检测推理服务 + +在实时场景中使用训练好的 Transformer Autoencoder 进行异动检测 + +使用方法: + from ml.inference import ConceptAnomalyDetector + + detector = ConceptAnomalyDetector('ml/checkpoints') + + # 检测异动 + features = {...} # 实时特征数据 + is_anomaly, score = detector.detect(features) +""" + +import os +import json +from pathlib import Path +from typing import Dict, List, Tuple, Optional +from collections import deque + +import numpy as np +import torch + +from model import TransformerAutoencoder + + +class ConceptAnomalyDetector: + """ + 概念异动检测器 + + 使用训练好的 Transformer Autoencoder 进行实时异动检测 + """ + + def __init__( + self, + checkpoint_dir: str = 'ml/checkpoints', + device: str = 'auto', + threshold_key: str = 'p95' + ): + """ + 初始化检测器 + + Args: + checkpoint_dir: 模型检查点目录 + device: 设备 (auto/cuda/cpu) + threshold_key: 使用的阈值键 (p90/p95/p99) + """ + self.checkpoint_dir = Path(checkpoint_dir) + self.threshold_key = threshold_key + + # 设备选择 + if device == 'auto': + self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + else: + self.device = torch.device(device) + + # 加载配置 + self._load_config() + + # 加载模型 + self._load_model() + + # 加载阈值 + self._load_thresholds() + + # 加载标准化统计量 + self._load_normalization_stats() + + # 概念历史数据缓存 + # {concept_name: deque(maxlen=seq_len)} + self.history_cache: Dict[str, deque] = {} + + print(f"ConceptAnomalyDetector 初始化完成") + print(f" 设备: {self.device}") + print(f" 阈值: {self.threshold_key} = {self.threshold:.6f}") + print(f" 序列长度: {self.seq_len}") + + def _load_config(self): + """加载配置""" + config_path = self.checkpoint_dir / 'config.json' + if not config_path.exists(): + raise FileNotFoundError(f"配置文件不存在: {config_path}") + + with open(config_path, 'r') as f: + self.config = json.load(f) + + self.features = self.config['features'] + self.seq_len = self.config['seq_len'] + self.model_config = self.config['model'] + + def _load_model(self): + """加载模型""" + model_path = self.checkpoint_dir / 'best_model.pt' + if not model_path.exists(): + raise FileNotFoundError(f"模型文件不存在: {model_path}") + + # 创建模型 + self.model = TransformerAutoencoder(**self.model_config) + + # 加载权重 + checkpoint = torch.load(model_path, map_location=self.device) + self.model.load_state_dict(checkpoint['model_state_dict']) + self.model.to(self.device) + self.model.eval() + + print(f"模型已加载: {model_path}") + + def _load_thresholds(self): + """加载阈值""" + thresholds_path = self.checkpoint_dir / 'thresholds.json' + if not thresholds_path.exists(): + raise FileNotFoundError(f"阈值文件不存在: {thresholds_path}") + + with open(thresholds_path, 'r') as f: + self.thresholds = json.load(f) + + if self.threshold_key not in self.thresholds: + available_keys = list(self.thresholds.keys()) + raise KeyError(f"阈值键 '{self.threshold_key}' 不存在,可用: {available_keys}") + + self.threshold = self.thresholds[self.threshold_key] + + def _load_normalization_stats(self): + """加载标准化统计量""" + stats_path = self.checkpoint_dir / 'normalization_stats.json' + if not stats_path.exists(): + raise FileNotFoundError(f"标准化统计量文件不存在: {stats_path}") + + with open(stats_path, 'r') as f: + stats = json.load(f) + + self.norm_mean = np.array(stats['mean']) + self.norm_std = np.array(stats['std']) + + def normalize(self, features: np.ndarray) -> np.ndarray: + """标准化特征""" + return (features - self.norm_mean) / self.norm_std + + def update_history( + self, + concept_name: str, + features: Dict[str, float] + ): + """ + 更新概念历史数据 + + Args: + concept_name: 概念名称 + features: 当前时刻的特征字典 + """ + # 初始化历史缓存 + if concept_name not in self.history_cache: + self.history_cache[concept_name] = deque(maxlen=self.seq_len) + + # 提取特征向量 + feature_vector = np.array([ + features.get(f, 0.0) for f in self.features + ]) + + # 处理异常值 + feature_vector = np.nan_to_num(feature_vector, nan=0.0, posinf=0.0, neginf=0.0) + + # 添加到历史 + self.history_cache[concept_name].append(feature_vector) + + def get_history_length(self, concept_name: str) -> int: + """获取概念的历史数据长度""" + if concept_name not in self.history_cache: + return 0 + return len(self.history_cache[concept_name]) + + @torch.no_grad() + def detect( + self, + concept_name: str, + features: Dict[str, float] = None, + return_score: bool = True + ) -> Tuple[bool, Optional[float]]: + """ + 检测概念是否异动 + + Args: + concept_name: 概念名称 + features: 当前时刻的特征(如果提供,会先更新历史) + return_score: 是否返回异动分数 + + Returns: + is_anomaly: 是否异动 + score: 异动分数(如果 return_score=True) + """ + # 更新历史 + if features is not None: + self.update_history(concept_name, features) + + # 检查历史数据是否足够 + if concept_name not in self.history_cache: + return False, None + + history = self.history_cache[concept_name] + if len(history) < self.seq_len: + return False, None + + # 构建输入序列 + sequence = np.array(list(history)) # (seq_len, n_features) + + # 标准化 + sequence = self.normalize(sequence) + + # 转为 tensor + x = torch.FloatTensor(sequence).unsqueeze(0) # (1, seq_len, n_features) + x = x.to(self.device) + + # 计算重构误差 + error = self.model.compute_reconstruction_error(x, reduction='none') + + # 取最后一个时刻的误差作为当前分数 + score = error[0, -1].item() + + # 判断是否异动 + is_anomaly = score > self.threshold + + if return_score: + return is_anomaly, score + else: + return is_anomaly, None + + @torch.no_grad() + def batch_detect( + self, + concept_features: Dict[str, Dict[str, float]] + ) -> Dict[str, Tuple[bool, float]]: + """ + 批量检测多个概念 + + Args: + concept_features: {concept_name: {feature_name: value}} + + Returns: + results: {concept_name: (is_anomaly, score)} + """ + results = {} + + for concept_name, features in concept_features.items(): + is_anomaly, score = self.detect(concept_name, features) + results[concept_name] = (is_anomaly, score) + + return results + + def get_anomaly_type( + self, + concept_name: str, + features: Dict[str, float] + ) -> str: + """ + 判断异动类型 + + Args: + concept_name: 概念名称 + features: 当前特征 + + Returns: + anomaly_type: 'surge_up' / 'surge_down' / 'normal' + """ + is_anomaly, score = self.detect(concept_name, features) + + if not is_anomaly: + return 'normal' + + # 根据 alpha 判断涨跌 + alpha = features.get('alpha', 0.0) + + if alpha > 0: + return 'surge_up' + else: + return 'surge_down' + + def get_top_anomalies( + self, + concept_features: Dict[str, Dict[str, float]], + top_k: int = 10 + ) -> List[Tuple[str, float, str]]: + """ + 获取异动分数最高的 top_k 个概念 + + Args: + concept_features: {concept_name: {feature_name: value}} + top_k: 返回数量 + + Returns: + anomalies: [(concept_name, score, anomaly_type), ...] + """ + results = self.batch_detect(concept_features) + + # 按分数排序 + sorted_results = sorted( + [(name, is_anomaly, score) for name, (is_anomaly, score) in results.items() if score is not None], + key=lambda x: x[2], + reverse=True + ) + + # 取 top_k + top_anomalies = [] + for name, is_anomaly, score in sorted_results[:top_k]: + if is_anomaly: + alpha = concept_features[name].get('alpha', 0.0) + anomaly_type = 'surge_up' if alpha > 0 else 'surge_down' + top_anomalies.append((name, score, anomaly_type)) + + return top_anomalies + + def clear_history(self, concept_name: str = None): + """ + 清除历史缓存 + + Args: + concept_name: 概念名称(如果为 None,清除所有) + """ + if concept_name is None: + self.history_cache.clear() + elif concept_name in self.history_cache: + del self.history_cache[concept_name] + + +# ==================== 集成到现有系统 ==================== + +class MLAnomalyService: + """ + ML 异动检测服务 + + 用于替换或增强现有的 Alpha-based 检测 + """ + + def __init__( + self, + checkpoint_dir: str = 'ml/checkpoints', + fallback_to_alpha: bool = True + ): + """ + Args: + checkpoint_dir: 模型检查点目录 + fallback_to_alpha: 当 ML 模型不可用时是否回退到 Alpha 方法 + """ + self.fallback_to_alpha = fallback_to_alpha + self.ml_detector = None + + try: + self.ml_detector = ConceptAnomalyDetector(checkpoint_dir) + print("ML 异动检测服务初始化成功") + except Exception as e: + print(f"ML 模型加载失败: {e}") + if not fallback_to_alpha: + raise + print("将回退到 Alpha-based 检测") + + def is_ml_available(self) -> bool: + """检查 ML 模型是否可用""" + return self.ml_detector is not None + + def detect_anomaly( + self, + concept_name: str, + features: Dict[str, float], + alpha_threshold: float = 2.0 + ) -> Tuple[bool, float, str]: + """ + 检测异动 + + Args: + concept_name: 概念名称 + features: 特征字典(需包含 alpha, amt_ratio 等) + alpha_threshold: Alpha Z-Score 阈值(用于回退) + + Returns: + is_anomaly: 是否异动 + score: 异动分数 + method: 检测方法 ('ml' / 'alpha') + """ + # 优先使用 ML 检测 + if self.ml_detector is not None: + history_len = self.ml_detector.get_history_length(concept_name) + + # 历史数据足够时使用 ML + if history_len >= self.ml_detector.seq_len - 1: + is_anomaly, score = self.ml_detector.detect(concept_name, features) + if score is not None: + return is_anomaly, score, 'ml' + else: + # 更新历史但使用 Alpha 方法 + self.ml_detector.update_history(concept_name, features) + + # 回退到 Alpha 方法 + if self.fallback_to_alpha: + alpha = features.get('alpha', 0.0) + alpha_zscore = features.get('alpha_zscore', 0.0) + + is_anomaly = abs(alpha_zscore) > alpha_threshold + score = abs(alpha_zscore) + + return is_anomaly, score, 'alpha' + + return False, 0.0, 'none' + + +# ==================== 测试 ==================== + +if __name__ == "__main__": + import random + + print("测试 ConceptAnomalyDetector...") + + # 检查模型是否存在 + checkpoint_dir = Path('ml/checkpoints') + if not (checkpoint_dir / 'best_model.pt').exists(): + print("模型文件不存在,跳过测试") + print("请先运行 train.py 训练模型") + exit(0) + + # 初始化检测器 + detector = ConceptAnomalyDetector('ml/checkpoints') + + # 模拟数据 + print("\n模拟实时检测...") + concept_name = "人工智能" + + for i in range(40): + # 生成随机特征 + features = { + 'alpha': random.gauss(0, 1), + 'alpha_delta': random.gauss(0, 0.5), + 'amt_ratio': random.gauss(1, 0.3), + 'amt_delta': random.gauss(0, 0.2), + 'rank_pct': random.random(), + 'limit_up_ratio': random.random() * 0.1, + } + + # 在第 35 分钟模拟异动 + if i == 35: + features['alpha'] = 5.0 + features['alpha_delta'] = 2.0 + features['amt_ratio'] = 3.0 + + is_anomaly, score = detector.detect(concept_name, features) + + history_len = detector.get_history_length(concept_name) + + if score is not None: + status = "🔥 异动!" if is_anomaly else "正常" + print(f" t={i:02d} | 历史={history_len} | 分数={score:.4f} | {status}") + else: + print(f" t={i:02d} | 历史={history_len} | 数据不足") + + print("\n测试完成!") diff --git a/ml/model.py b/ml/model.py new file mode 100644 index 00000000..90c0d61b --- /dev/null +++ b/ml/model.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LSTM Autoencoder 模型定义 + +用于概念异动检测: +- 学习"正常"市场模式 +- 重构误差大的时刻 = 异动 + +模型结构(简洁有效): +┌─────────────────────────────────────┐ +│ 输入: (batch, seq_len, n_features) │ +│ 过去30分钟的特征序列 │ +├─────────────────────────────────────┤ +│ LSTM Encoder │ +│ - 双向 LSTM │ +│ - 输出最后隐藏状态 │ +├─────────────────────────────────────┤ +│ Bottleneck (压缩层) │ +│ 降维到 latent_dim(关键!) │ +├─────────────────────────────────────┤ +│ LSTM Decoder │ +│ - 单向 LSTM │ +│ - 重构序列 │ +├─────────────────────────────────────┤ +│ 输出: (batch, seq_len, n_features) │ +│ 重构的特征序列 │ +└─────────────────────────────────────┘ + +为什么用 LSTM 而不是 Transformer: +1. 参数更少,不容易过拟合 +2. 对于 6 维特征足够用 +3. 训练更稳定 +4. 瓶颈约束更容易控制 +""" + +import torch +import torch.nn as nn +import torch.nn.functional as F +from typing import Optional, Tuple + + +class LSTMAutoencoder(nn.Module): + """ + LSTM Autoencoder for Anomaly Detection + + 设计原则: + - 足够简单,避免过拟合 + - 瓶颈层严格限制,迫使模型只学习主要模式 + - 异常难以通过狭窄瓶颈,重构误差大 + """ + + def __init__( + self, + n_features: int = 6, + hidden_dim: int = 32, # LSTM 隐藏维度(小!) + latent_dim: int = 4, # 瓶颈维度(非常小!关键参数) + num_layers: int = 1, # LSTM 层数 + dropout: float = 0.2, + bidirectional: bool = True, # 双向编码器 + ): + super().__init__() + + self.n_features = n_features + self.hidden_dim = hidden_dim + self.latent_dim = latent_dim + self.num_layers = num_layers + self.bidirectional = bidirectional + self.num_directions = 2 if bidirectional else 1 + + # Encoder: 双向 LSTM + self.encoder = nn.LSTM( + input_size=n_features, + hidden_size=hidden_dim, + num_layers=num_layers, + batch_first=True, + dropout=dropout if num_layers > 1 else 0, + bidirectional=bidirectional + ) + + # Bottleneck: 压缩到极小的 latent space + encoder_output_dim = hidden_dim * self.num_directions + self.bottleneck_down = nn.Sequential( + nn.Linear(encoder_output_dim, latent_dim), + nn.Tanh(), # 限制范围,增加约束 + ) + + # 使用 LeakyReLU 替代 ReLU + # 原因:Z-Score 数据范围是 [-5, +5],ReLU 会截断负值,丢失跌幅信息 + # LeakyReLU 保留负值信号(乘以 0.1) + self.bottleneck_up = nn.Sequential( + nn.Linear(latent_dim, hidden_dim), + nn.LeakyReLU(negative_slope=0.1), + ) + + # Decoder: 单向 LSTM + self.decoder = nn.LSTM( + input_size=hidden_dim, + hidden_size=hidden_dim, + num_layers=num_layers, + batch_first=True, + dropout=dropout if num_layers > 1 else 0, + bidirectional=False # 解码器用单向 + ) + + # 输出层 + self.output_layer = nn.Linear(hidden_dim, n_features) + + # Dropout + self.dropout = nn.Dropout(dropout) + + # 初始化 + self._init_weights() + + def _init_weights(self): + """初始化权重""" + for name, param in self.named_parameters(): + if 'weight_ih' in name: + nn.init.xavier_uniform_(param) + elif 'weight_hh' in name: + nn.init.orthogonal_(param) + elif 'bias' in name: + nn.init.zeros_(param) + + def encode(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + """ + 编码器 + + Args: + x: (batch, seq_len, n_features) + Returns: + latent: (batch, seq_len, latent_dim) 每个时间步的压缩表示 + encoder_outputs: (batch, seq_len, hidden_dim * num_directions) + """ + # LSTM 编码 + encoder_outputs, (h_n, c_n) = self.encoder(x) + # encoder_outputs: (batch, seq_len, hidden_dim * num_directions) + + encoder_outputs = self.dropout(encoder_outputs) + + # 压缩到 latent space(对每个时间步) + latent = self.bottleneck_down(encoder_outputs) + # latent: (batch, seq_len, latent_dim) + + return latent, encoder_outputs + + def decode(self, latent: torch.Tensor, seq_len: int) -> torch.Tensor: + """ + 解码器 + + Args: + latent: (batch, seq_len, latent_dim) + seq_len: 序列长度 + Returns: + output: (batch, seq_len, n_features) + """ + # 从 latent space 恢复 + decoder_input = self.bottleneck_up(latent) + # decoder_input: (batch, seq_len, hidden_dim) + + # LSTM 解码 + decoder_outputs, _ = self.decoder(decoder_input) + # decoder_outputs: (batch, seq_len, hidden_dim) + + decoder_outputs = self.dropout(decoder_outputs) + + # 投影到原始特征空间 + output = self.output_layer(decoder_outputs) + # output: (batch, seq_len, n_features) + + return output + + def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + """ + 前向传播 + + Args: + x: (batch, seq_len, n_features) + Returns: + output: (batch, seq_len, n_features) 重构结果 + latent: (batch, seq_len, latent_dim) 隐向量 + """ + batch_size, seq_len, _ = x.shape + + # 编码 + latent, _ = self.encode(x) + + # 解码 + output = self.decode(latent, seq_len) + + return output, latent + + def compute_reconstruction_error( + self, + x: torch.Tensor, + reduction: str = 'none' + ) -> torch.Tensor: + """ + 计算重构误差 + + Args: + x: (batch, seq_len, n_features) + reduction: 'none' | 'mean' | 'sum' + Returns: + error: 重构误差 + """ + output, _ = self.forward(x) + + # MSE per feature per timestep + error = F.mse_loss(output, x, reduction='none') + + if reduction == 'none': + # (batch, seq_len, n_features) -> (batch, seq_len) + return error.mean(dim=-1) + elif reduction == 'mean': + return error.mean() + elif reduction == 'sum': + return error.sum() + else: + raise ValueError(f"Unknown reduction: {reduction}") + + def detect_anomaly( + self, + x: torch.Tensor, + threshold: float = None, + return_scores: bool = True + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """ + 检测异动 + + Args: + x: (batch, seq_len, n_features) + threshold: 异动阈值(如果为 None,只返回分数) + return_scores: 是否返回异动分数 + Returns: + is_anomaly: (batch, seq_len) bool tensor (if threshold is not None) + scores: (batch, seq_len) 异动分数 (if return_scores) + """ + scores = self.compute_reconstruction_error(x, reduction='none') + + is_anomaly = None + if threshold is not None: + is_anomaly = scores > threshold + + if return_scores: + return is_anomaly, scores + else: + return is_anomaly, None + + +# 为了兼容性,创建别名 +TransformerAutoencoder = LSTMAutoencoder + + +# ==================== 损失函数 ==================== + +class AnomalyDetectionLoss(nn.Module): + """ + 异动检测损失函数 + + 简单的 MSE 重构损失 + """ + + def __init__( + self, + feature_weights: torch.Tensor = None, + ): + super().__init__() + self.feature_weights = feature_weights + + def forward( + self, + output: torch.Tensor, + target: torch.Tensor, + latent: torch.Tensor = None + ) -> Tuple[torch.Tensor, dict]: + """ + Args: + output: (batch, seq_len, n_features) 重构结果 + target: (batch, seq_len, n_features) 原始输入 + latent: (batch, seq_len, latent_dim) 隐向量(未使用) + Returns: + loss: 总损失 + loss_dict: 各项损失详情 + """ + # 重构损失 (MSE) + mse = F.mse_loss(output, target, reduction='none') + + # 特征加权(可选) + if self.feature_weights is not None: + weights = self.feature_weights.to(mse.device) + mse = mse * weights + + reconstruction_loss = mse.mean() + + loss_dict = { + 'total': reconstruction_loss.item(), + 'reconstruction': reconstruction_loss.item(), + } + + return reconstruction_loss, loss_dict + + +# ==================== 工具函数 ==================== + +def count_parameters(model: nn.Module) -> int: + """统计模型参数量""" + return sum(p.numel() for p in model.parameters() if p.requires_grad) + + +def create_model(config: dict = None) -> LSTMAutoencoder: + """ + 创建模型 + + 默认使用小型 LSTM 配置,适合异动检测 + """ + default_config = { + 'n_features': 6, + 'hidden_dim': 32, # 小! + 'latent_dim': 4, # 非常小!关键 + 'num_layers': 1, + 'dropout': 0.2, + 'bidirectional': True, + } + + if config: + # 兼容旧的 Transformer 配置键名 + if 'd_model' in config: + config['hidden_dim'] = config.pop('d_model') // 2 + if 'num_encoder_layers' in config: + config['num_layers'] = config.pop('num_encoder_layers') + if 'num_decoder_layers' in config: + config.pop('num_decoder_layers') + if 'nhead' in config: + config.pop('nhead') + if 'dim_feedforward' in config: + config.pop('dim_feedforward') + if 'max_seq_len' in config: + config.pop('max_seq_len') + if 'use_instance_norm' in config: + config.pop('use_instance_norm') + + default_config.update(config) + + model = LSTMAutoencoder(**default_config) + param_count = count_parameters(model) + print(f"模型参数量: {param_count:,}") + + if param_count > 100000: + print(f"⚠️ 警告: 参数量较大({param_count:,}),可能过拟合") + else: + print(f"✓ 参数量适中(LSTM Autoencoder)") + + return model + + +if __name__ == "__main__": + # 测试模型 + print("测试 LSTM Autoencoder...") + + # 创建模型 + model = create_model() + + # 测试输入 + batch_size = 32 + seq_len = 30 + n_features = 6 + + x = torch.randn(batch_size, seq_len, n_features) + + # 前向传播 + output, latent = model(x) + + print(f"输入形状: {x.shape}") + print(f"输出形状: {output.shape}") + print(f"隐向量形状: {latent.shape}") + + # 计算重构误差 + error = model.compute_reconstruction_error(x) + print(f"重构误差形状: {error.shape}") + print(f"平均重构误差: {error.mean().item():.4f}") + + # 测试异动检测 + is_anomaly, scores = model.detect_anomaly(x, threshold=0.5) + print(f"异动检测结果形状: {is_anomaly.shape if is_anomaly is not None else 'None'}") + print(f"异动分数形状: {scores.shape}") + + # 测试损失函数 + criterion = AnomalyDetectionLoss() + loss, loss_dict = criterion(output, x, latent) + print(f"损失: {loss.item():.4f}") + + print("\n测试通过!") diff --git a/ml/prepare_data.py b/ml/prepare_data.py new file mode 100644 index 00000000..cb905d42 --- /dev/null +++ b/ml/prepare_data.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +数据准备脚本 - 为 Transformer Autoencoder 准备训练数据 + +从 ClickHouse 提取历史分钟数据,计算以下特征: +1. alpha - 超额收益(概念涨幅 - 大盘涨幅) +2. alpha_delta - Alpha 变化率(5分钟) +3. amt_ratio - 成交额相对均值(当前/过去20分钟均值) +4. amt_delta - 成交额变化率 +5. rank_pct - Alpha 排名百分位 +6. limit_up_ratio - 涨停股占比 + +输出:按交易日存储的特征文件(parquet格式) +""" + +import os +import sys +import numpy as np +import pandas as pd +from datetime import datetime, timedelta, date +from sqlalchemy import create_engine, text +from elasticsearch import Elasticsearch +from clickhouse_driver import Client +import hashlib +import json +import logging +from typing import Dict, List, Set, Tuple +from concurrent.futures import ProcessPoolExecutor, as_completed +from multiprocessing import Manager +import multiprocessing +import warnings +warnings.filterwarnings('ignore') + +# ==================== 配置 ==================== + +MYSQL_ENGINE = create_engine( + "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock", + echo=False +) + +ES_CLIENT = Elasticsearch(['http://127.0.0.1:9200']) +ES_INDEX = 'concept_library_v3' + +CLICKHOUSE_CONFIG = { + 'host': '127.0.0.1', + 'port': 9000, + 'user': 'default', + 'password': 'Zzl33818!', + 'database': 'stock' +} + +# 输出目录 +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'data') +os.makedirs(OUTPUT_DIR, exist_ok=True) + +# 特征计算参数 +FEATURE_CONFIG = { + 'alpha_delta_window': 5, # Alpha变化窗口(分钟) + 'amt_ma_window': 20, # 成交额均值窗口(分钟) + 'limit_up_threshold': 9.8, # 涨停阈值(%) + 'limit_down_threshold': -9.8, # 跌停阈值(%) +} + +REFERENCE_INDEX = '000001.SH' + +# ==================== 日志 ==================== + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# ==================== 工具函数 ==================== + +def get_ch_client(): + return Client(**CLICKHOUSE_CONFIG) + + +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() -> List[dict]: + """从ES获取所有叶子概念""" + concepts = [] + + query = { + "query": {"match_all": {}}, + "size": 100, + "_source": ["concept_id", "concept", "stocks"] + } + + resp = ES_CLIENT.search(index=ES_INDEX, body=query, scroll='2m') + scroll_id = resp['_scroll_id'] + hits = resp['hits']['hits'] + + while len(hits) > 0: + for hit in hits: + source = hit['_source'] + stocks = [] + 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']: + stocks.append(stock['code']) + + if stocks: + concepts.append({ + 'concept_id': source.get('concept_id'), + 'concept_name': source.get('concept'), + 'stocks': stocks + }) + + 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) + print(f"获取到 {len(concepts)} 个概念") + return concepts + + +# ==================== 获取交易日列表 ==================== + +def get_trading_days(start_date: str, end_date: str) -> List[str]: + """获取交易日列表""" + client = get_ch_client() + + query = f""" + SELECT DISTINCT toDate(timestamp) as trade_date + FROM stock_minute + WHERE toDate(timestamp) >= '{start_date}' + AND toDate(timestamp) <= '{end_date}' + ORDER BY trade_date + """ + + result = client.execute(query) + days = [row[0].strftime('%Y-%m-%d') for row in result] + print(f"找到 {len(days)} 个交易日: {days[0]} ~ {days[-1]}") + return days + + +# ==================== 获取单日数据 ==================== + +def get_daily_stock_data(trade_date: str, stock_codes: List[str]) -> pd.DataFrame: + """获取单日所有股票的分钟数据""" + client = get_ch_client() + + # 转换代码格式 + ch_codes = [] + code_map = {} + for code in stock_codes: + ch_code = code_to_ch_format(code) + if ch_code: + ch_codes.append(ch_code) + code_map[ch_code] = code + + if not ch_codes: + return pd.DataFrame() + + ch_codes_str = "','".join(ch_codes) + + query = f""" + SELECT + code, + timestamp, + close, + volume, + amt + FROM stock_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code IN ('{ch_codes_str}') + ORDER BY code, timestamp + """ + + result = client.execute(query) + + if not result: + return pd.DataFrame() + + df = pd.DataFrame(result, columns=['ch_code', 'timestamp', 'close', 'volume', 'amt']) + df['code'] = df['ch_code'].map(code_map) + df = df.dropna(subset=['code']) + + return df[['code', 'timestamp', 'close', 'volume', 'amt']] + + +def get_daily_index_data(trade_date: str, index_code: str = REFERENCE_INDEX) -> pd.DataFrame: + """获取单日指数分钟数据""" + client = get_ch_client() + + query = f""" + SELECT + timestamp, + close, + volume, + amt + FROM index_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code = '{index_code}' + ORDER BY timestamp + """ + + result = client.execute(query) + + if not result: + return pd.DataFrame() + + df = pd.DataFrame(result, columns=['timestamp', 'close', 'volume', 'amt']) + return df + + +def get_prev_close(stock_codes: List[str], trade_date: str) -> Dict[str, float]: + """获取昨收价(上一交易日的收盘价 F007N)""" + valid_codes = [c for c in stock_codes if c and len(c) == 6 and c.isdigit()] + if not valid_codes: + return {} + + codes_str = "','".join(valid_codes) + + # 注意:F007N 是"最近成交价"即当日收盘价,F002N 是"昨日收盘价" + # 我们需要查上一交易日的 F007N(那天的收盘价)作为今天的昨收 + query = f""" + SELECT SECCODE, F007N + FROM ea_trade + WHERE SECCODE IN ('{codes_str}') + AND TRADEDATE = ( + SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}' + ) + AND F007N IS NOT NULL AND F007N > 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]} + except Exception as e: + print(f"获取昨收价失败: {e}") + return {} + + +def get_index_prev_close(trade_date: str, index_code: str = REFERENCE_INDEX) -> float: + """获取指数昨收价""" + code_no_suffix = index_code.split('.')[0] + + try: + with MYSQL_ENGINE.connect() as conn: + 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 result and result[0]: + return float(result[0]) + except Exception as e: + print(f"获取指数昨收失败: {e}") + + return None + + +# ==================== 计算特征 ==================== + +def compute_daily_features( + trade_date: str, + concepts: List[dict], + all_stocks: List[str] +) -> pd.DataFrame: + """ + 计算单日所有概念的特征 + + 返回 DataFrame: + - index: (timestamp, concept_id) + - columns: alpha, alpha_delta, amt_ratio, amt_delta, rank_pct, limit_up_ratio + """ + + # 1. 获取数据 + stock_df = get_daily_stock_data(trade_date, all_stocks) + if stock_df.empty: + return pd.DataFrame() + + index_df = get_daily_index_data(trade_date) + if index_df.empty: + return pd.DataFrame() + + # 2. 获取昨收价 + prev_close = get_prev_close(all_stocks, trade_date) + index_prev_close = get_index_prev_close(trade_date) + + if not prev_close or not index_prev_close: + return pd.DataFrame() + + # 3. 计算股票涨跌幅和成交额 + stock_df['prev_close'] = stock_df['code'].map(prev_close) + stock_df = stock_df.dropna(subset=['prev_close']) + stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100 + + # 4. 计算指数涨跌幅 + index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100 + index_change_map = dict(zip(index_df['timestamp'], index_df['change_pct'])) + + # 5. 获取所有时间点 + timestamps = sorted(stock_df['timestamp'].unique()) + + # 6. 按时间点计算概念特征 + results = [] + + # 概念到股票的映射 + concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts} + concept_names = {c['concept_id']: c['concept_name'] for c in concepts} + + # 历史数据缓存(用于计算变化率) + concept_history = {cid: {'alpha': [], 'amt': []} for cid in concept_stocks} + + for ts in timestamps: + ts_stock_data = stock_df[stock_df['timestamp'] == ts] + index_change = index_change_map.get(ts, 0) + + # 股票涨跌幅和成交额字典 + stock_change = dict(zip(ts_stock_data['code'], ts_stock_data['change_pct'])) + stock_amt = dict(zip(ts_stock_data['code'], ts_stock_data['amt'])) + + concept_features = [] + + for concept_id, stocks in concept_stocks.items(): + # 该概念的股票数据 + concept_changes = [stock_change[s] for s in stocks if s in stock_change] + concept_amts = [stock_amt.get(s, 0) for s in stocks if s in stock_change] + + if not concept_changes: + continue + + # 基础统计 + avg_change = np.mean(concept_changes) + total_amt = sum(concept_amts) + + # Alpha = 概念涨幅 - 指数涨幅 + alpha = avg_change - index_change + + # 涨停/跌停股占比 + limit_up_count = sum(1 for c in concept_changes if c >= FEATURE_CONFIG['limit_up_threshold']) + limit_down_count = sum(1 for c in concept_changes if c <= FEATURE_CONFIG['limit_down_threshold']) + limit_up_ratio = limit_up_count / len(concept_changes) + limit_down_ratio = limit_down_count / len(concept_changes) + + # 更新历史 + history = concept_history[concept_id] + history['alpha'].append(alpha) + history['amt'].append(total_amt) + + # 计算变化率 + alpha_delta = 0 + if len(history['alpha']) > FEATURE_CONFIG['alpha_delta_window']: + alpha_delta = alpha - history['alpha'][-FEATURE_CONFIG['alpha_delta_window']-1] + + # 成交额相对均值 + amt_ratio = 1.0 + amt_delta = 0 + if len(history['amt']) > FEATURE_CONFIG['amt_ma_window']: + amt_ma = np.mean(history['amt'][-FEATURE_CONFIG['amt_ma_window']-1:-1]) + if amt_ma > 0: + amt_ratio = total_amt / amt_ma + amt_delta = total_amt - history['amt'][-2] if len(history['amt']) > 1 else 0 + + concept_features.append({ + 'concept_id': concept_id, + 'alpha': alpha, + 'alpha_delta': alpha_delta, + 'amt_ratio': amt_ratio, + 'amt_delta': amt_delta, + 'limit_up_ratio': limit_up_ratio, + 'limit_down_ratio': limit_down_ratio, + 'total_amt': total_amt, + 'stock_count': len(concept_changes), + }) + + if not concept_features: + continue + + # 计算排名百分位 + concept_df = pd.DataFrame(concept_features) + concept_df['rank_pct'] = concept_df['alpha'].rank(pct=True) + + # 添加时间戳 + concept_df['timestamp'] = ts + results.append(concept_df) + + if not results: + return pd.DataFrame() + + # 合并所有时间点 + final_df = pd.concat(results, ignore_index=True) + + # 标准化成交额变化率 + if 'amt_delta' in final_df.columns: + amt_delta_std = final_df['amt_delta'].std() + if amt_delta_std > 0: + final_df['amt_delta'] = final_df['amt_delta'] / amt_delta_std + + return final_df + + +# ==================== 主流程 ==================== + +def process_single_day(args) -> Tuple[str, bool]: + """ + 处理单个交易日(多进程版本) + + Args: + args: (trade_date, concepts, all_stocks) 元组 + + Returns: + (trade_date, success) 元组 + """ + trade_date, concepts, all_stocks = args + output_file = os.path.join(OUTPUT_DIR, f'features_{trade_date}.parquet') + + # 检查是否已处理 + if os.path.exists(output_file): + print(f"[{trade_date}] 已存在,跳过") + return (trade_date, True) + + print(f"[{trade_date}] 开始处理...") + + try: + df = compute_daily_features(trade_date, concepts, all_stocks) + + if df.empty: + print(f"[{trade_date}] 无数据") + return (trade_date, False) + + # 保存 + df.to_parquet(output_file, index=False) + print(f"[{trade_date}] 保存完成") + return (trade_date, True) + + except Exception as e: + print(f"[{trade_date}] 处理失败: {e}") + import traceback + traceback.print_exc() + return (trade_date, False) + + +def main(): + import argparse + from tqdm import tqdm + + parser = argparse.ArgumentParser(description='准备训练数据') + parser.add_argument('--start', type=str, default='2022-01-01', help='开始日期') + parser.add_argument('--end', type=str, default=None, help='结束日期(默认今天)') + parser.add_argument('--workers', type=int, default=18, help='并行进程数(默认18)') + parser.add_argument('--force', action='store_true', help='强制重新处理已存在的文件') + + args = parser.parse_args() + + end_date = args.end or datetime.now().strftime('%Y-%m-%d') + + print("=" * 60) + print("数据准备 - Transformer Autoencoder 训练数据") + print("=" * 60) + print(f"日期范围: {args.start} ~ {end_date}") + print(f"并行进程数: {args.workers}") + + # 1. 获取概念列表 + concepts = get_all_concepts() + + # 收集所有股票 + all_stocks = list(set(s for c in concepts for s in c['stocks'])) + print(f"股票总数: {len(all_stocks)}") + + # 2. 获取交易日列表 + trading_days = get_trading_days(args.start, end_date) + + if not trading_days: + print("无交易日数据") + return + + # 如果强制模式,删除已有文件 + if args.force: + for trade_date in trading_days: + output_file = os.path.join(OUTPUT_DIR, f'features_{trade_date}.parquet') + if os.path.exists(output_file): + os.remove(output_file) + print(f"删除已有文件: {output_file}") + + # 3. 准备任务参数 + tasks = [(trade_date, concepts, all_stocks) for trade_date in trading_days] + + print(f"\n开始处理 {len(trading_days)} 个交易日({args.workers} 进程并行)...") + + # 4. 多进程处理 + success_count = 0 + failed_dates = [] + + with ProcessPoolExecutor(max_workers=args.workers) as executor: + # 提交所有任务 + futures = {executor.submit(process_single_day, task): task[0] for task in tasks} + + # 使用 tqdm 显示进度 + with tqdm(total=len(futures), desc="处理进度", unit="天") as pbar: + for future in as_completed(futures): + trade_date = futures[future] + try: + result_date, success = future.result() + if success: + success_count += 1 + else: + failed_dates.append(result_date) + except Exception as e: + print(f"\n[{trade_date}] 进程异常: {e}") + failed_dates.append(trade_date) + pbar.update(1) + + print("\n" + "=" * 60) + print(f"处理完成: {success_count}/{len(trading_days)} 个交易日") + if failed_dates: + print(f"失败日期: {failed_dates[:10]}{'...' if len(failed_dates) > 10 else ''}") + print(f"数据保存在: {OUTPUT_DIR}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/ml/prepare_data_v2.py b/ml/prepare_data_v2.py new file mode 100644 index 00000000..5a3ad02c --- /dev/null +++ b/ml/prepare_data_v2.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +数据准备 V2 - 基于时间片对齐的特征计算(修复版) + +核心改进: +1. 时间片对齐:9:35 和历史的 9:35 比,而不是和前30分钟比 +2. Z-Score 特征:相对于同时间片历史分布的偏离程度 +3. 滚动窗口基线:每个日期使用它之前 N 天的数据作为基线(不是固定的最后 N 天!) +4. 基于 Z-Score 的动量:消除一天内波动率异构性 + +修复: +- 滚动窗口基线:避免未来数据泄露 +- Z-Score 动量:消除早盘/尾盘波动率差异 +- 进程级数据库单例:避免连接池爆炸 +""" + +import os +import sys +import numpy as np +import pandas as pd +from datetime import datetime, timedelta +from sqlalchemy import create_engine, text +from elasticsearch import Elasticsearch +from clickhouse_driver import Client +from concurrent.futures import ProcessPoolExecutor, as_completed +from typing import Dict, List, Tuple, Optional +from tqdm import tqdm +from collections import defaultdict +import warnings +import pickle + +warnings.filterwarnings('ignore') + +# ==================== 配置 ==================== + +MYSQL_URL = "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock" +ES_HOST = 'http://127.0.0.1:9200' +ES_INDEX = 'concept_library_v3' + +CLICKHOUSE_CONFIG = { + 'host': '127.0.0.1', + 'port': 9000, + 'user': 'default', + 'password': 'Zzl33818!', + 'database': 'stock' +} + +REFERENCE_INDEX = '000001.SH' + +# 输出目录 +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'data_v2') +BASELINE_DIR = os.path.join(OUTPUT_DIR, 'baselines') +RAW_CACHE_DIR = os.path.join(OUTPUT_DIR, 'raw_cache') +os.makedirs(OUTPUT_DIR, exist_ok=True) +os.makedirs(BASELINE_DIR, exist_ok=True) +os.makedirs(RAW_CACHE_DIR, exist_ok=True) + +# 特征配置 +CONFIG = { + 'baseline_days': 20, # 滚动窗口大小 + 'min_baseline_samples': 10, # 最少需要10个样本才算有效基线 + 'limit_up_threshold': 9.8, + 'limit_down_threshold': -9.8, + 'zscore_clip': 5.0, +} + +# 特征列表 +FEATURES_V2 = [ + 'alpha', 'alpha_zscore', 'amt_zscore', 'rank_zscore', + 'momentum_3m', 'momentum_5m', 'limit_up_ratio', +] + +# ==================== 进程级单例(避免连接池爆炸)==================== + +# 进程级全局变量 +_process_mysql_engine = None +_process_es_client = None +_process_ch_client = None + + +def init_process_connections(): + """进程初始化时调用,创建连接(单例)""" + global _process_mysql_engine, _process_es_client, _process_ch_client + _process_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True, pool_size=5) + _process_es_client = Elasticsearch([ES_HOST]) + _process_ch_client = Client(**CLICKHOUSE_CONFIG) + + +def get_mysql_engine(): + """获取进程级 MySQL Engine(单例)""" + global _process_mysql_engine + if _process_mysql_engine is None: + _process_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True, pool_size=5) + return _process_mysql_engine + + +def get_es_client(): + """获取进程级 ES 客户端(单例)""" + global _process_es_client + if _process_es_client is None: + _process_es_client = Elasticsearch([ES_HOST]) + return _process_es_client + + +def get_ch_client(): + """获取进程级 ClickHouse 客户端(单例)""" + global _process_ch_client + if _process_ch_client is None: + _process_ch_client = Client(**CLICKHOUSE_CONFIG) + return _process_ch_client + + +# ==================== 工具函数 ==================== + +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 time_to_slot(ts) -> str: + """将时间戳转换为时间片(HH:MM格式)""" + if isinstance(ts, str): + return ts + return ts.strftime('%H:%M') + + +# ==================== 获取概念列表 ==================== + +def get_all_concepts() -> List[dict]: + """从ES获取所有叶子概念""" + es_client = get_es_client() + concepts = [] + + query = { + "query": {"match_all": {}}, + "size": 100, + "_source": ["concept_id", "concept", "stocks"] + } + + resp = es_client.search(index=ES_INDEX, body=query, scroll='2m') + scroll_id = resp['_scroll_id'] + hits = resp['hits']['hits'] + + while len(hits) > 0: + for hit in hits: + source = hit['_source'] + stocks = [] + 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']: + stocks.append(stock['code']) + + if stocks: + concepts.append({ + 'concept_id': source.get('concept_id'), + 'concept_name': source.get('concept'), + 'stocks': stocks + }) + + 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) + print(f"获取到 {len(concepts)} 个概念") + return concepts + + +# ==================== 获取交易日列表 ==================== + +def get_trading_days(start_date: str, end_date: str) -> List[str]: + """获取交易日列表""" + client = get_ch_client() + + query = f""" + SELECT DISTINCT toDate(timestamp) as trade_date + FROM stock_minute + WHERE toDate(timestamp) >= '{start_date}' + AND toDate(timestamp) <= '{end_date}' + ORDER BY trade_date + """ + + result = client.execute(query) + days = [row[0].strftime('%Y-%m-%d') for row in result] + if days: + print(f"找到 {len(days)} 个交易日: {days[0]} ~ {days[-1]}") + return days + + +# ==================== 获取昨收价 ==================== + +def get_prev_close(stock_codes: List[str], trade_date: str) -> Dict[str, float]: + """获取昨收价(上一交易日的收盘价 F007N)""" + valid_codes = [c for c in stock_codes if c and len(c) == 6 and c.isdigit()] + if not valid_codes: + return {} + + codes_str = "','".join(valid_codes) + query = f""" + SELECT SECCODE, F007N + FROM ea_trade + WHERE SECCODE IN ('{codes_str}') + AND TRADEDATE = ( + SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}' + ) + AND F007N IS NOT NULL AND F007N > 0 + """ + + try: + engine = get_mysql_engine() + with engine.connect() as conn: + result = conn.execute(text(query)) + return {row[0]: float(row[1]) for row in result if row[1]} + except Exception as e: + print(f"获取昨收价失败: {e}") + return {} + + +def get_index_prev_close(trade_date: str, index_code: str = REFERENCE_INDEX) -> float: + """获取指数昨收价""" + code_no_suffix = index_code.split('.')[0] + + try: + engine = get_mysql_engine() + with engine.connect() as conn: + 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 result and result[0]: + return float(result[0]) + except Exception as e: + print(f"获取指数昨收失败: {e}") + + return None + + +# ==================== 获取分钟数据 ==================== + +def get_daily_stock_data(trade_date: str, stock_codes: List[str]) -> pd.DataFrame: + """获取单日所有股票的分钟数据""" + client = get_ch_client() + + ch_codes = [] + code_map = {} + for code in stock_codes: + ch_code = code_to_ch_format(code) + if ch_code: + ch_codes.append(ch_code) + code_map[ch_code] = code + + if not ch_codes: + return pd.DataFrame() + + ch_codes_str = "','".join(ch_codes) + + query = f""" + SELECT code, timestamp, close, volume, amt + FROM stock_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code IN ('{ch_codes_str}') + ORDER BY code, timestamp + """ + + result = client.execute(query) + if not result: + return pd.DataFrame() + + df = pd.DataFrame(result, columns=['ch_code', 'timestamp', 'close', 'volume', 'amt']) + df['code'] = df['ch_code'].map(code_map) + df = df.dropna(subset=['code']) + + return df[['code', 'timestamp', 'close', 'volume', 'amt']] + + +def get_daily_index_data(trade_date: str, index_code: str = REFERENCE_INDEX) -> pd.DataFrame: + """获取单日指数分钟数据""" + client = get_ch_client() + + query = f""" + SELECT timestamp, close, volume, amt + FROM index_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code = '{index_code}' + ORDER BY timestamp + """ + + result = client.execute(query) + if not result: + return pd.DataFrame() + + df = pd.DataFrame(result, columns=['timestamp', 'close', 'volume', 'amt']) + return df + + +# ==================== 计算原始概念特征(单日)==================== + +def compute_raw_concept_features( + trade_date: str, + concepts: List[dict], + all_stocks: List[str] +) -> pd.DataFrame: + """计算单日概念的原始特征(alpha, amt, rank_pct, limit_up_ratio)""" + # 检查缓存 + cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet') + if os.path.exists(cache_file): + return pd.read_parquet(cache_file) + + # 获取数据 + stock_df = get_daily_stock_data(trade_date, all_stocks) + if stock_df.empty: + return pd.DataFrame() + + index_df = get_daily_index_data(trade_date) + if index_df.empty: + return pd.DataFrame() + + # 获取昨收价 + prev_close = get_prev_close(all_stocks, trade_date) + index_prev_close = get_index_prev_close(trade_date) + + if not prev_close or not index_prev_close: + return pd.DataFrame() + + # 计算涨跌幅 + stock_df['prev_close'] = stock_df['code'].map(prev_close) + stock_df = stock_df.dropna(subset=['prev_close']) + stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100 + + index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100 + index_change_map = dict(zip(index_df['timestamp'], index_df['change_pct'])) + + # 获取所有时间点 + timestamps = sorted(stock_df['timestamp'].unique()) + + # 概念到股票的映射 + concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts} + + results = [] + + for ts in timestamps: + ts_stock_data = stock_df[stock_df['timestamp'] == ts] + index_change = index_change_map.get(ts, 0) + + stock_change = dict(zip(ts_stock_data['code'], ts_stock_data['change_pct'])) + stock_amt = dict(zip(ts_stock_data['code'], ts_stock_data['amt'])) + + concept_features = [] + + for concept_id, stocks in concept_stocks.items(): + concept_changes = [stock_change[s] for s in stocks if s in stock_change] + concept_amts = [stock_amt.get(s, 0) for s in stocks if s in stock_change] + + if not concept_changes: + continue + + avg_change = np.mean(concept_changes) + total_amt = sum(concept_amts) + alpha = avg_change - index_change + + limit_up_count = sum(1 for c in concept_changes if c >= CONFIG['limit_up_threshold']) + limit_up_ratio = limit_up_count / len(concept_changes) + + concept_features.append({ + 'concept_id': concept_id, + 'alpha': alpha, + 'total_amt': total_amt, + 'limit_up_ratio': limit_up_ratio, + 'stock_count': len(concept_changes), + }) + + if not concept_features: + continue + + concept_df = pd.DataFrame(concept_features) + concept_df['rank_pct'] = concept_df['alpha'].rank(pct=True) + concept_df['timestamp'] = ts + concept_df['time_slot'] = time_to_slot(ts) + concept_df['trade_date'] = trade_date + + results.append(concept_df) + + if not results: + return pd.DataFrame() + + result_df = pd.concat(results, ignore_index=True) + + # 保存缓存 + result_df.to_parquet(cache_file, index=False) + + return result_df + + +# ==================== 滚动窗口基线计算 ==================== + +def compute_rolling_baseline( + historical_data: pd.DataFrame, + concept_id: str +) -> Dict[str, Dict]: + """ + 计算单个概念的滚动基线 + + 返回: {time_slot: {alpha_mean, alpha_std, amt_mean, amt_std, rank_mean, rank_std, sample_count}} + """ + if historical_data.empty: + return {} + + concept_data = historical_data[historical_data['concept_id'] == concept_id] + if concept_data.empty: + return {} + + baseline_dict = {} + + for time_slot, group in concept_data.groupby('time_slot'): + if len(group) < CONFIG['min_baseline_samples']: + continue + + alpha_std = group['alpha'].std() + amt_std = group['total_amt'].std() + rank_std = group['rank_pct'].std() + + baseline_dict[time_slot] = { + 'alpha_mean': group['alpha'].mean(), + 'alpha_std': max(alpha_std if pd.notna(alpha_std) else 1.0, 0.1), + 'amt_mean': group['total_amt'].mean(), + 'amt_std': max(amt_std if pd.notna(amt_std) else group['total_amt'].mean() * 0.5, 1.0), + 'rank_mean': group['rank_pct'].mean(), + 'rank_std': max(rank_std if pd.notna(rank_std) else 0.2, 0.05), + 'sample_count': len(group), + } + + return baseline_dict + + +# ==================== 计算单日 Z-Score 特征(带滚动基线)==================== + +def compute_zscore_features_rolling( + trade_date: str, + concepts: List[dict], + all_stocks: List[str], + historical_raw_data: pd.DataFrame # 该日期之前 N 天的原始数据 +) -> pd.DataFrame: + """ + 计算单日的 Z-Score 特征(使用滚动窗口基线) + + 关键改进: + 1. 基线只使用 trade_date 之前的数据(无未来泄露) + 2. 动量基于 Z-Score 计算(消除波动率异构性) + """ + # 计算当日原始特征 + raw_df = compute_raw_concept_features(trade_date, concepts, all_stocks) + + if raw_df.empty: + return pd.DataFrame() + + zscore_records = [] + + for concept_id, group in raw_df.groupby('concept_id'): + # 计算该概念的滚动基线(只用历史数据) + baseline_dict = compute_rolling_baseline(historical_raw_data, concept_id) + + if not baseline_dict: + continue + + # 按时间排序 + group = group.sort_values('timestamp').reset_index(drop=True) + + # Z-Score 历史(用于计算基于 Z-Score 的动量) + zscore_history = [] + + for idx, row in group.iterrows(): + time_slot = row['time_slot'] + + if time_slot not in baseline_dict: + continue + + bl = baseline_dict[time_slot] + + # 计算 Z-Score + alpha_zscore = (row['alpha'] - bl['alpha_mean']) / bl['alpha_std'] + amt_zscore = (row['total_amt'] - bl['amt_mean']) / bl['amt_std'] + rank_zscore = (row['rank_pct'] - bl['rank_mean']) / bl['rank_std'] + + # 截断极端值 + clip = CONFIG['zscore_clip'] + alpha_zscore = np.clip(alpha_zscore, -clip, clip) + amt_zscore = np.clip(amt_zscore, -clip, clip) + rank_zscore = np.clip(rank_zscore, -clip, clip) + + # 记录 Z-Score 历史 + zscore_history.append(alpha_zscore) + + # 基于 Z-Score 计算动量(消除波动率异构性) + momentum_3m = 0.0 + momentum_5m = 0.0 + + if len(zscore_history) >= 3: + recent_3 = zscore_history[-3:] + older_3 = zscore_history[-6:-3] if len(zscore_history) >= 6 else [zscore_history[0]] + momentum_3m = np.mean(recent_3) - np.mean(older_3) + + if len(zscore_history) >= 5: + recent_5 = zscore_history[-5:] + older_5 = zscore_history[-10:-5] if len(zscore_history) >= 10 else [zscore_history[0]] + momentum_5m = np.mean(recent_5) - np.mean(older_5) + + zscore_records.append({ + 'concept_id': concept_id, + 'timestamp': row['timestamp'], + 'time_slot': time_slot, + 'trade_date': trade_date, + # 原始特征 + 'alpha': row['alpha'], + 'total_amt': row['total_amt'], + 'limit_up_ratio': row['limit_up_ratio'], + 'stock_count': row['stock_count'], + 'rank_pct': row['rank_pct'], + # Z-Score 特征 + 'alpha_zscore': alpha_zscore, + 'amt_zscore': amt_zscore, + 'rank_zscore': rank_zscore, + # 基于 Z-Score 的动量 + 'momentum_3m': momentum_3m, + 'momentum_5m': momentum_5m, + }) + + if not zscore_records: + return pd.DataFrame() + + return pd.DataFrame(zscore_records) + + +# ==================== 多进程处理 ==================== + +def process_single_day_v2(args) -> Tuple[str, bool]: + """处理单个交易日(多进程版本)""" + trade_date, day_index, concepts, all_stocks, all_trading_days = args + output_file = os.path.join(OUTPUT_DIR, f'features_v2_{trade_date}.parquet') + + if os.path.exists(output_file): + return (trade_date, True) + + try: + # 计算滚动窗口范围(该日期之前的 N 天) + baseline_days = CONFIG['baseline_days'] + + # 找出 trade_date 之前的交易日 + start_idx = max(0, day_index - baseline_days) + end_idx = day_index # 不包含当天 + + if end_idx <= start_idx: + # 没有足够的历史数据 + return (trade_date, False) + + historical_days = all_trading_days[start_idx:end_idx] + + # 加载历史原始数据 + historical_dfs = [] + for hist_date in historical_days: + cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{hist_date}.parquet') + if os.path.exists(cache_file): + historical_dfs.append(pd.read_parquet(cache_file)) + else: + # 需要计算 + hist_df = compute_raw_concept_features(hist_date, concepts, all_stocks) + if not hist_df.empty: + historical_dfs.append(hist_df) + + if not historical_dfs: + return (trade_date, False) + + historical_raw_data = pd.concat(historical_dfs, ignore_index=True) + + # 计算当日 Z-Score 特征(使用滚动基线) + df = compute_zscore_features_rolling(trade_date, concepts, all_stocks, historical_raw_data) + + if df.empty: + return (trade_date, False) + + df.to_parquet(output_file, index=False) + return (trade_date, True) + + except Exception as e: + print(f"[{trade_date}] 处理失败: {e}") + import traceback + traceback.print_exc() + return (trade_date, False) + + +# ==================== 主流程 ==================== + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='准备训练数据 V2(滚动窗口基线 + Z-Score 动量)') + parser.add_argument('--start', type=str, default='2022-01-01', help='开始日期') + parser.add_argument('--end', type=str, default=None, help='结束日期(默认今天)') + parser.add_argument('--workers', type=int, default=18, help='并行进程数') + parser.add_argument('--baseline-days', type=int, default=20, help='滚动基线窗口大小') + parser.add_argument('--force', action='store_true', help='强制重新计算(忽略缓存)') + + args = parser.parse_args() + + end_date = args.end or datetime.now().strftime('%Y-%m-%d') + CONFIG['baseline_days'] = args.baseline_days + + print("=" * 60) + print("数据准备 V2 - 滚动窗口基线 + Z-Score 动量") + print("=" * 60) + print(f"日期范围: {args.start} ~ {end_date}") + print(f"并行进程数: {args.workers}") + print(f"滚动基线窗口: {args.baseline_days} 天") + + # 初始化主进程连接 + init_process_connections() + + # 1. 获取概念列表 + concepts = get_all_concepts() + all_stocks = list(set(s for c in concepts for s in c['stocks'])) + print(f"股票总数: {len(all_stocks)}") + + # 2. 获取交易日列表 + trading_days = get_trading_days(args.start, end_date) + + if not trading_days: + print("无交易日数据") + return + + # 3. 第一阶段:预计算所有原始特征(用于缓存) + print(f"\n{'='*60}") + print("第一阶段:预计算原始特征(用于滚动基线)") + print(f"{'='*60}") + + # 如果强制重新计算,删除缓存 + if args.force: + import shutil + if os.path.exists(RAW_CACHE_DIR): + shutil.rmtree(RAW_CACHE_DIR) + os.makedirs(RAW_CACHE_DIR, exist_ok=True) + if os.path.exists(OUTPUT_DIR): + for f in os.listdir(OUTPUT_DIR): + if f.startswith('features_v2_'): + os.remove(os.path.join(OUTPUT_DIR, f)) + + # 单线程预计算原始特征(因为需要顺序缓存) + print(f"预计算 {len(trading_days)} 天的原始特征...") + for trade_date in tqdm(trading_days, desc="预计算原始特征"): + cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet') + if not os.path.exists(cache_file): + compute_raw_concept_features(trade_date, concepts, all_stocks) + + # 4. 第二阶段:计算 Z-Score 特征(多进程) + print(f"\n{'='*60}") + print("第二阶段:计算 Z-Score 特征(滚动基线)") + print(f"{'='*60}") + + # 从第 baseline_days 天开始(前面的没有足够历史) + start_idx = args.baseline_days + processable_days = trading_days[start_idx:] + + if not processable_days: + print(f"错误:需要至少 {args.baseline_days + 1} 天的数据") + return + + print(f"可处理日期: {processable_days[0]} ~ {processable_days[-1]} ({len(processable_days)} 天)") + print(f"跳过前 {start_idx} 天(基线预热期)") + + # 构建任务 + tasks = [] + for i, trade_date in enumerate(trading_days): + if i >= start_idx: + tasks.append((trade_date, i, concepts, all_stocks, trading_days)) + + print(f"开始处理 {len(tasks)} 个交易日({args.workers} 进程并行)...") + + success_count = 0 + failed_dates = [] + + # 使用进程池初始化器 + with ProcessPoolExecutor(max_workers=args.workers, initializer=init_process_connections) as executor: + futures = {executor.submit(process_single_day_v2, task): task[0] for task in tasks} + + with tqdm(total=len(futures), desc="处理进度", unit="天") as pbar: + for future in as_completed(futures): + trade_date = futures[future] + try: + result_date, success = future.result() + if success: + success_count += 1 + else: + failed_dates.append(result_date) + except Exception as e: + print(f"\n[{trade_date}] 进程异常: {e}") + failed_dates.append(trade_date) + pbar.update(1) + + print("\n" + "=" * 60) + print(f"处理完成: {success_count}/{len(tasks)} 个交易日") + if failed_dates: + print(f"失败日期: {failed_dates[:10]}{'...' if len(failed_dates) > 10 else ''}") + print(f"数据保存在: {OUTPUT_DIR}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/ml/realtime_detector.py b/ml/realtime_detector.py new file mode 100644 index 00000000..3af09828 --- /dev/null +++ b/ml/realtime_detector.py @@ -0,0 +1,1520 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +实时概念异动检测服务(实盘可用) + +盘中每分钟运行一次,检测概念异动并写入数据库 + +数据流程: +1. 启动时从 ES 获取概念列表,从 MySQL 获取昨收价 +2. 自动预热:从 ClickHouse 加载当天已有的历史分钟数据 +3. 每分钟增量获取最新分钟数据 +4. 在内存中实时计算概念特征(无前瞻偏差) +5. 使用规则+ML融合评分检测异动 +6. 异动写入 MySQL + +特征计算说明(无 Looking Forward): +- alpha: 当前时间点的概念超额收益 +- alpha_delta: 使用过去 5 分钟的 alpha 变化 +- amt_ratio: 使用过去 20 分钟的成交额均值 +- rank_pct: 当前时间点所有概念的 alpha 排名 +- limit_up_ratio: 当前时间点的涨停股占比 + +使用方法: + # 实盘模式(推荐)- 自动预热,不依赖 prepare_data.py + python realtime_detector.py + + # 单次检测 + python realtime_detector.py --once + + # 回补历史异动到数据库(需要 prepare_data.py 生成 parquet) + python realtime_detector.py --backfill-only + + # 实盘模式 + 启动时回补历史 + python realtime_detector.py --backfill + +最小数据量要求: +- ML 评分需要 seq_len=15 分钟的序列 +- amt_ratio 需要 amt_ma_window=20 分钟的历史 +- 即:开盘后约 35 分钟才能正常工作 +""" + +import os +import sys +import time +import json +import argparse +import schedule +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Set +from collections import defaultdict + +import numpy as np +import pandas as pd +import torch +from sqlalchemy import create_engine, text +from elasticsearch import Elasticsearch +from clickhouse_driver import Client as CHClient + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# ==================== 配置 ==================== + +MYSQL_ENGINE = create_engine( + "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock", + echo=False, + pool_pre_ping=True, + pool_recycle=3600, +) + +ES_CLIENT = Elasticsearch(['http://127.0.0.1:9200']) +ES_INDEX = 'concept_library_v3' + +CLICKHOUSE_CONFIG = { + 'host': '127.0.0.1', + 'port': 9000, + 'user': 'default', + 'password': 'Zzl33818!', + 'database': 'stock' +} + +REFERENCE_INDEX = '000001.SH' + +FEATURES = ['alpha', 'alpha_delta', 'amt_ratio', 'amt_delta', 'rank_pct', 'limit_up_ratio'] + +# 特征计算参数 +FEATURE_CONFIG = { + 'alpha_delta_window': 5, + 'amt_ma_window': 20, + 'limit_up_threshold': 9.8, + 'limit_down_threshold': -9.8, +} + +# 检测配置(与 backtest_fast.py 保持一致) +CONFIG = { + 'seq_len': 15, + 'min_alpha_abs': 0.3, + 'cooldown_minutes': 8, + 'max_alerts_per_minute': 20, + 'clip_value': 10.0, + # === 融合权重:与 backtest_fast.py 一致 === + 'rule_weight': 0.5, + 'ml_weight': 0.5, + # === 触发阈值:与 backtest_fast.py 一致 === + 'rule_trigger': 65, + 'ml_trigger': 70, + 'fusion_trigger': 45, +} + +TRADING_PERIODS = [ + ('09:30', '11:30'), + ('13:00', '15:00'), +] + + +# ==================== 工具函数 ==================== + +def get_ch_client(): + return CHClient(**CLICKHOUSE_CONFIG) + + +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 is_trading_time() -> bool: + now = datetime.now() + if now.weekday() >= 5: + return False + current_time = now.strftime('%H:%M') + for start, end in TRADING_PERIODS: + if start <= current_time <= end: + return True + return False + + +def get_current_trade_date() -> str: + now = datetime.now() + if now.hour < 9: + now = now - timedelta(days=1) + return now.strftime('%Y-%m-%d') + + +# ==================== 数据获取 ==================== + +def get_all_concepts() -> List[dict]: + """从 ES 获取所有概念""" + concepts = [] + query = { + "query": {"match_all": {}}, + "size": 100, + "_source": ["concept_id", "concept", "stocks"] + } + + resp = ES_CLIENT.search(index=ES_INDEX, body=query, scroll='2m') + scroll_id = resp['_scroll_id'] + hits = resp['hits']['hits'] + + while len(hits) > 0: + for hit in hits: + source = hit['_source'] + stocks = [] + 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']: + stocks.append(stock['code']) + + if stocks: + concepts.append({ + 'concept_id': source.get('concept_id'), + 'concept_name': source.get('concept'), + 'stocks': stocks + }) + + 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) + print(f"获取到 {len(concepts)} 个概念") + return concepts + + +def get_prev_close(stock_codes: List[str], trade_date: str) -> Dict[str, float]: + """获取昨收价(上一交易日的收盘价 F007N)""" + valid_codes = [c for c in stock_codes if c and len(c) == 6 and c.isdigit()] + if not valid_codes: + return {} + + codes_str = "','".join(valid_codes) + # 注意:F007N 是"最近成交价"即当日收盘价,F002N 是"昨日收盘价" + # 我们需要查上一交易日的 F007N(那天的收盘价)作为今天的昨收 + query = f""" + SELECT SECCODE, F007N + FROM ea_trade + WHERE SECCODE IN ('{codes_str}') + AND TRADEDATE = ( + SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}' + ) + AND F007N IS NOT NULL AND F007N > 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]} + except Exception as e: + print(f"获取昨收价失败: {e}") + return {} + + +def get_index_prev_close(trade_date: str) -> float: + """获取指数昨收价""" + code_no_suffix = REFERENCE_INDEX.split('.')[0] + try: + with MYSQL_ENGINE.connect() as conn: + 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 result and result[0]: + return float(result[0]) + except Exception as e: + print(f"获取指数昨收失败: {e}") + return None + + +def get_stock_minute_data(trade_date: str, stock_codes: List[str], since_time: datetime = None) -> pd.DataFrame: + """ + 从 ClickHouse 获取股票分钟数据 + + Args: + trade_date: 交易日期 + stock_codes: 股票代码列表 + since_time: 只获取该时间之后的数据(增量获取) + """ + client = get_ch_client() + + ch_codes = [] + code_map = {} + for code in stock_codes: + ch_code = code_to_ch_format(code) + if ch_code: + ch_codes.append(ch_code) + code_map[ch_code] = code + + if not ch_codes: + return pd.DataFrame() + + ch_codes_str = "','".join(ch_codes) + + time_filter = "" + if since_time: + time_filter = f"AND timestamp > '{since_time.strftime('%Y-%m-%d %H:%M:%S')}'" + + query = f""" + SELECT code, timestamp, close, volume, amt + FROM stock_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code IN ('{ch_codes_str}') + {time_filter} + ORDER BY code, timestamp + """ + + result = client.execute(query) + if not result: + return pd.DataFrame() + + df = pd.DataFrame(result, columns=['ch_code', 'timestamp', 'close', 'volume', 'amt']) + df['code'] = df['ch_code'].map(code_map) + df = df.dropna(subset=['code']) + return df[['code', 'timestamp', 'close', 'volume', 'amt']] + + +def get_index_minute_data(trade_date: str, since_time: datetime = None) -> pd.DataFrame: + """从 ClickHouse 获取指数分钟数据""" + client = get_ch_client() + + time_filter = "" + if since_time: + time_filter = f"AND timestamp > '{since_time.strftime('%Y-%m-%d %H:%M:%S')}'" + + query = f""" + SELECT timestamp, close, volume, amt + FROM index_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code = '{REFERENCE_INDEX}' + {time_filter} + ORDER BY timestamp + """ + + result = client.execute(query) + if not result: + return pd.DataFrame() + + return pd.DataFrame(result, columns=['timestamp', 'close', 'volume', 'amt']) + + +# ==================== 规则评分 ==================== + +def get_size_adjusted_thresholds(stock_count: np.ndarray) -> np.ndarray: + """根据概念股票数量计算动态阈值""" + n = len(stock_count) + size_factor = np.ones(n) + + size_factor[stock_count < 5] = 1.8 + size_factor[(stock_count >= 5) & (stock_count < 10)] = 1.4 + size_factor[(stock_count >= 10) & (stock_count < 20)] = 1.2 + size_factor[(stock_count >= 20) & (stock_count < 50)] = 1.0 + size_factor[(stock_count >= 50) & (stock_count < 100)] = 0.85 + size_factor[stock_count >= 100] = 0.7 + + return size_factor + + +def score_rules_batch(df: pd.DataFrame) -> Tuple[np.ndarray, List[List[str]]]: + """批量计算规则得分""" + n = len(df) + scores = np.zeros(n) + triggered = [[] for _ in range(n)] + + alpha = df['alpha'].values + alpha_delta = df['alpha_delta'].values + amt_ratio = df['amt_ratio'].values + rank_pct = df['rank_pct'].values + limit_up_ratio = df['limit_up_ratio'].values + stock_count = df['stock_count'].values if 'stock_count' in df.columns else np.full(n, 20) + + alpha_abs = np.abs(alpha) + alpha_delta_abs = np.abs(alpha_delta) + size_factor = get_size_adjusted_thresholds(stock_count) + + # Alpha 规则 + alpha_extreme_thresh = 5.0 * size_factor + mask = alpha_abs >= alpha_extreme_thresh + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('alpha_extreme') + + alpha_strong_thresh = 4.0 * size_factor + mask = (alpha_abs >= alpha_strong_thresh) & (alpha_abs < alpha_extreme_thresh) + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('alpha_strong') + + alpha_medium_thresh = 3.0 * size_factor + mask = (alpha_abs >= alpha_medium_thresh) & (alpha_abs < alpha_strong_thresh) + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('alpha_medium') + + # Alpha 加速度 + delta_strong_thresh = 2.0 * size_factor + mask = alpha_delta_abs >= delta_strong_thresh + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('alpha_delta_strong') + + delta_medium_thresh = 1.5 * size_factor + mask = (alpha_delta_abs >= delta_medium_thresh) & (alpha_delta_abs < delta_strong_thresh) + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('alpha_delta_medium') + + # 成交额 + mask = amt_ratio >= 10.0 + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('volume_extreme') + + mask = (amt_ratio >= 6.0) & (amt_ratio < 10.0) + scores[mask] += 12 + for i in np.where(mask)[0]: triggered[i].append('volume_strong') + + # 排名 + mask = rank_pct >= 0.98 + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('rank_top') + + mask = rank_pct <= 0.02 + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('rank_bottom') + + # 涨停 + limit_high_thresh = 0.30 * size_factor + mask = limit_up_ratio >= limit_high_thresh + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('limit_up_high') + + limit_medium_thresh = 0.20 * size_factor + mask = (limit_up_ratio >= limit_medium_thresh) & (limit_up_ratio < limit_high_thresh) + scores[mask] += 12 + for i in np.where(mask)[0]: triggered[i].append('limit_up_medium') + + # 概念规模加分 + large_concept = stock_count >= 50 + has_signal = scores > 0 + mask = large_concept & has_signal + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('large_concept_bonus') + + xlarge_concept = stock_count >= 100 + mask = xlarge_concept & has_signal + scores[mask] += 10 + for i in np.where(mask)[0]: triggered[i].append('xlarge_concept_bonus') + + # 组合规则 + combo_alpha_thresh = 3.0 * size_factor + mask = (alpha_abs >= combo_alpha_thresh) & (amt_ratio >= 5.0) & ((rank_pct >= 0.95) | (rank_pct <= 0.05)) + scores[mask] += 20 + for i in np.where(mask)[0]: triggered[i].append('triple_signal') + + mask = (alpha_abs >= combo_alpha_thresh) & (limit_up_ratio >= 0.15 * size_factor) + scores[mask] += 15 + for i in np.where(mask)[0]: triggered[i].append('alpha_with_limit') + + # 小概念惩罚 + tiny_concept = stock_count < 5 + single_rule = np.array([len(t) <= 1 for t in triggered]) + mask = tiny_concept & single_rule & (scores > 0) + scores[mask] *= 0.5 + for i in np.where(mask)[0]: triggered[i].append('tiny_concept_penalty') + + scores = np.clip(scores, 0, 100) + return scores, triggered + + +def rule_score_with_details(features: Dict, stock_count: int = 50) -> Tuple[float, Dict[str, float]]: + """ + 单条记录的规则评分(带详情) + + Args: + features: 特征字典,包含 alpha, alpha_delta, amt_ratio, rank_pct, limit_up_ratio + stock_count: 概念股票数量 + + Returns: + (score, details): 总分和各规则触发详情 + """ + score = 0.0 + details = {} + + alpha = features.get('alpha', 0) + alpha_delta = features.get('alpha_delta', 0) + amt_ratio = features.get('amt_ratio', 1) + rank_pct = features.get('rank_pct', 0.5) + limit_up_ratio = features.get('limit_up_ratio', 0) + + alpha_abs = abs(alpha) + alpha_delta_abs = abs(alpha_delta) + size_factor = get_size_adjusted_thresholds(np.array([stock_count]))[0] + + # Alpha 规则 + alpha_extreme_thresh = 5.0 * size_factor + alpha_strong_thresh = 4.0 * size_factor + alpha_medium_thresh = 3.0 * size_factor + + if alpha_abs >= alpha_extreme_thresh: + score += 20 + details['alpha_extreme'] = 20 + elif alpha_abs >= alpha_strong_thresh: + score += 15 + details['alpha_strong'] = 15 + elif alpha_abs >= alpha_medium_thresh: + score += 10 + details['alpha_medium'] = 10 + + # Alpha 加速度 + delta_strong_thresh = 2.0 * size_factor + delta_medium_thresh = 1.5 * size_factor + + if alpha_delta_abs >= delta_strong_thresh: + score += 15 + details['alpha_delta_strong'] = 15 + elif alpha_delta_abs >= delta_medium_thresh: + score += 10 + details['alpha_delta_medium'] = 10 + + # 成交额 + if amt_ratio >= 10.0: + score += 20 + details['volume_extreme'] = 20 + elif amt_ratio >= 6.0: + score += 12 + details['volume_strong'] = 12 + + # 排名 + if rank_pct >= 0.98: + score += 15 + details['rank_top'] = 15 + elif rank_pct <= 0.02: + score += 15 + details['rank_bottom'] = 15 + + # 涨停 + limit_high_thresh = 0.30 * size_factor + limit_medium_thresh = 0.20 * size_factor + + if limit_up_ratio >= limit_high_thresh: + score += 20 + details['limit_up_high'] = 20 + elif limit_up_ratio >= limit_medium_thresh: + score += 12 + details['limit_up_medium'] = 12 + + # 概念规模加分 + if score > 0: + if stock_count >= 50: + score += 10 + details['large_concept_bonus'] = 10 + if stock_count >= 100: + score += 10 + details['xlarge_concept_bonus'] = 10 + + # 组合规则 + combo_alpha_thresh = 3.0 * size_factor + + if alpha_abs >= combo_alpha_thresh and amt_ratio >= 5.0 and (rank_pct >= 0.95 or rank_pct <= 0.05): + score += 20 + details['triple_signal'] = 20 + + if alpha_abs >= combo_alpha_thresh and limit_up_ratio >= 0.15 * size_factor: + score += 15 + details['alpha_with_limit'] = 15 + + # 小概念惩罚 + if stock_count < 5 and len(details) <= 1 and score > 0: + penalty = score * 0.5 + score *= 0.5 + details['tiny_concept_penalty'] = -penalty + + score = min(max(score, 0), 100) + return score, details + + +# ==================== ML 评分器 ==================== + +class MLScorer: + def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'): + self.checkpoint_dir = Path(checkpoint_dir) + if device == 'auto': + self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + else: + self.device = torch.device(device) + + self.model = None + self.thresholds = None + self._load_model() + + def _load_model(self): + model_path = self.checkpoint_dir / 'best_model.pt' + thresholds_path = self.checkpoint_dir / 'thresholds.json' + config_path = self.checkpoint_dir / 'config.json' + + if not model_path.exists(): + print(f"警告: 模型不存在 {model_path}") + return + + try: + from model import LSTMAutoencoder + + config = {} + if config_path.exists(): + with open(config_path) as f: + config = json.load(f).get('model', {}) + + if 'd_model' in config: + config['hidden_dim'] = config.pop('d_model') // 2 + for key in ['num_encoder_layers', 'num_decoder_layers', 'nhead', 'dim_feedforward', 'max_seq_len', 'use_instance_norm']: + config.pop(key, None) + if 'num_layers' not in config: + config['num_layers'] = 1 + + checkpoint = torch.load(model_path, map_location='cpu') + self.model = LSTMAutoencoder(**config) + self.model.load_state_dict(checkpoint['model_state_dict']) + self.model.to(self.device) + self.model.eval() + + if thresholds_path.exists(): + with open(thresholds_path) as f: + self.thresholds = json.load(f) + + print(f"ML模型加载成功 (设备: {self.device})") + except Exception as e: + print(f"ML模型加载失败: {e}") + + def is_ready(self): + return self.model is not None + + @torch.no_grad() + def score_batch(self, sequences: np.ndarray, debug: bool = False) -> np.ndarray: + if not self.is_ready() or len(sequences) == 0: + return np.zeros(len(sequences)) + + x = torch.FloatTensor(sequences).to(self.device) + output, _ = self.model(x) + mse = ((output - x) ** 2).mean(dim=-1) + errors = mse[:, -1].cpu().numpy() + + p95 = self.thresholds.get('p95', 0.1) if self.thresholds else 0.1 + scores = np.clip(errors / p95 * 50, 0, 100) + + if debug and len(errors) > 0: + print(f"[ML调试] p95={p95:.4f}, errors: min={errors.min():.4f}, max={errors.max():.4f}, mean={errors.mean():.4f}") + print(f"[ML调试] scores: min={scores.min():.0f}, max={scores.max():.0f}, mean={scores.mean():.0f}, =100占比={100*(scores>=100).mean():.1f}%") + + return scores + + +# ==================== 内存数据管理器 ==================== + +class RealtimeDataManager: + """ + 内存数据管理器 + + - 缓存股票分钟数据和指数数据 + - 增量获取新数据 + - 实时计算概念特征 + """ + + def __init__(self, concepts: List[dict], prev_close: Dict[str, float], index_prev_close: float): + self.concepts = concepts + self.prev_close = prev_close + self.index_prev_close = index_prev_close + + # 概念到股票的映射 + self.concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts} + self.all_stocks = list(set(s for c in concepts for s in c['stocks'])) + + # 内存缓存:股票分钟数据 + self.stock_data = pd.DataFrame() # code, timestamp, close, volume, amt, change_pct + self.index_data = pd.DataFrame() # timestamp, close, change_pct + + # 最后更新时间 + self.last_update_time = None + + # 概念历史(用于计算变化率) + self.concept_history = defaultdict(lambda: {'alpha': [], 'amt': []}) + + # 概念特征时间序列(用于 ML) + self.concept_features_history = defaultdict(list) # concept_id -> list of feature dicts + + def update(self, trade_date: str) -> int: + """ + 增量更新数据 + + Returns: + 新增的时间点数量 + """ + # 获取增量数据 + new_stock_df = get_stock_minute_data(trade_date, self.all_stocks, self.last_update_time) + new_index_df = get_index_minute_data(trade_date, self.last_update_time) + + if new_stock_df.empty and new_index_df.empty: + return 0 + + # 计算涨跌幅 + if not new_stock_df.empty: + new_stock_df['prev_close'] = new_stock_df['code'].map(self.prev_close) + new_stock_df = new_stock_df.dropna(subset=['prev_close']) + new_stock_df['change_pct'] = (new_stock_df['close'] - new_stock_df['prev_close']) / new_stock_df['prev_close'] * 100 + + # 合并到缓存 + self.stock_data = pd.concat([self.stock_data, new_stock_df], ignore_index=True) + self.stock_data = self.stock_data.drop_duplicates(subset=['code', 'timestamp'], keep='last') + + if not new_index_df.empty: + new_index_df['change_pct'] = (new_index_df['close'] - self.index_prev_close) / self.index_prev_close * 100 + + # 调试:打印指数涨跌幅范围 + if len(self.index_data) == 0: # 第一次 + print(f"[调试] 指数 close 范围: {new_index_df['close'].min():.2f} ~ {new_index_df['close'].max():.2f}") + print(f"[调试] 指数 change_pct 范围: {new_index_df['change_pct'].min():.2f}% ~ {new_index_df['change_pct'].max():.2f}%") + + self.index_data = pd.concat([self.index_data, new_index_df], ignore_index=True) + self.index_data = self.index_data.drop_duplicates(subset=['timestamp'], keep='last') + + # 更新最后时间 + if not new_stock_df.empty: + self.last_update_time = new_stock_df['timestamp'].max() + elif not new_index_df.empty: + self.last_update_time = new_index_df['timestamp'].max() + + # 获取新时间点 + new_timestamps = sorted(new_stock_df['timestamp'].unique()) if not new_stock_df.empty else [] + + # 计算新时间点的概念特征 + for ts in new_timestamps: + self._compute_features_for_timestamp(ts) + + return len(new_timestamps) + + def _compute_features_for_timestamp(self, ts): + """计算单个时间点的概念特征""" + ts_stock_data = self.stock_data[self.stock_data['timestamp'] == ts] + index_row = self.index_data[self.index_data['timestamp'] == ts] + + if ts_stock_data.empty or index_row.empty: + return + + index_change = index_row['change_pct'].values[0] + stock_change = dict(zip(ts_stock_data['code'], ts_stock_data['change_pct'])) + stock_amt = dict(zip(ts_stock_data['code'], ts_stock_data['amt'])) + + for concept_id, stocks in self.concept_stocks.items(): + concept_changes = [stock_change[s] for s in stocks if s in stock_change] + concept_amts = [stock_amt.get(s, 0) for s in stocks if s in stock_change] + + if not concept_changes: + continue + + avg_change = np.mean(concept_changes) + total_amt = sum(concept_amts) + alpha = avg_change - index_change + + # 涨停比例 + limit_up_count = sum(1 for c in concept_changes if c >= FEATURE_CONFIG['limit_up_threshold']) + limit_up_ratio = limit_up_count / len(concept_changes) + + # 更新历史 + history = self.concept_history[concept_id] + history['alpha'].append(alpha) + history['amt'].append(total_amt) + + # 计算变化率 + alpha_delta = 0 + if len(history['alpha']) > FEATURE_CONFIG['alpha_delta_window']: + alpha_delta = alpha - history['alpha'][-FEATURE_CONFIG['alpha_delta_window'] - 1] + + amt_ratio = 1.0 + amt_delta = 0 + if len(history['amt']) > FEATURE_CONFIG['amt_ma_window']: + amt_ma = np.mean(history['amt'][-FEATURE_CONFIG['amt_ma_window'] - 1:-1]) + if amt_ma > 0: + amt_ratio = total_amt / amt_ma + amt_delta = total_amt - history['amt'][-2] if len(history['amt']) > 1 else 0 + + features = { + 'timestamp': ts, + 'concept_id': concept_id, + 'alpha': alpha, + 'alpha_delta': alpha_delta, + 'amt_ratio': amt_ratio, + 'amt_delta': amt_delta, + 'limit_up_ratio': limit_up_ratio, + 'stock_count': len(concept_changes), + 'total_amt': total_amt, + } + + self.concept_features_history[concept_id].append(features) + + def get_latest_features(self) -> pd.DataFrame: + """获取最新时间点的所有概念特征""" + if not self.concept_features_history: + return pd.DataFrame() + + latest_features = [] + for concept_id, history in self.concept_features_history.items(): + if history: + latest_features.append(history[-1]) + + if not latest_features: + return pd.DataFrame() + + df = pd.DataFrame(latest_features) + + # 计算排名百分位 + if len(df) > 1: + df['rank_pct'] = df['alpha'].rank(pct=True) + else: + df['rank_pct'] = 0.5 + + return df + + def get_sequences_for_concepts(self, seq_len: int) -> Tuple[np.ndarray, pd.DataFrame]: + """获取所有概念的特征序列(用于 ML 评分)""" + sequences = [] + infos = [] + + for concept_id, history in self.concept_features_history.items(): + if len(history) < seq_len: + continue + + # 取最近 seq_len 个时间点 + recent = history[-seq_len:] + + # 构建序列 + seq = np.array([[ + f['alpha'], + f['alpha_delta'], + f['amt_ratio'], + f['amt_delta'], + f.get('rank_pct', 0.5), + f['limit_up_ratio'] + ] for f in recent]) + + seq = np.nan_to_num(seq, nan=0.0, posinf=0.0, neginf=0.0) + seq = np.clip(seq, -CONFIG['clip_value'], CONFIG['clip_value']) + + sequences.append(seq) + infos.append(recent[-1]) # 最新特征 + + if not sequences: + return np.array([]), pd.DataFrame() + + # 补充 rank_pct + info_df = pd.DataFrame(infos) + if 'rank_pct' not in info_df.columns and len(info_df) > 1: + info_df['rank_pct'] = info_df['alpha'].rank(pct=True) + + return np.array(sequences), info_df + + def get_all_timestamps(self) -> List: + """获取所有时间点""" + if self.stock_data.empty: + return [] + return sorted(self.stock_data['timestamp'].unique()) + + def get_concept_features_df(self) -> pd.DataFrame: + """获取概念特征的 DataFrame 形式(用于批量回测)""" + if not self.concept_features_history: + return pd.DataFrame() + + rows = [] + for concept_id, history in self.concept_features_history.items(): + for f in history: + row = { + 'concept_id': concept_id, + 'timestamp': f['timestamp'], + 'alpha': f['alpha'], + 'alpha_delta': f['alpha_delta'], + 'amt_ratio': f['amt_ratio'], + 'amt_delta': f.get('amt_delta', 0), + 'limit_up_ratio': f['limit_up_ratio'], + 'stock_count': f.get('stock_count', 0), + 'total_amt': f.get('total_amt', 0), + } + rows.append(row) + + if not rows: + return pd.DataFrame() + + df = pd.DataFrame(rows) + + # 按时间点计算 rank_pct(每个时间点内部排名) + df['rank_pct'] = df.groupby('timestamp')['alpha'].rank(pct=True) + + return df + + +# ==================== 冷却期管理 ==================== + +class CooldownManager: + def __init__(self, cooldown_minutes: int = 8): + self.cooldown_minutes = cooldown_minutes + self.last_alert_time = {} + + def is_in_cooldown(self, concept_id: str, current_time: datetime) -> bool: + if concept_id not in self.last_alert_time: + return False + last_time = self.last_alert_time[concept_id] + diff = (current_time - last_time).total_seconds() / 60 + return diff < self.cooldown_minutes + + def record_alert(self, concept_id: str, alert_time: datetime): + self.last_alert_time[concept_id] = alert_time + + def cleanup_old(self, current_time: datetime): + cutoff = current_time - timedelta(minutes=self.cooldown_minutes * 2) + self.last_alert_time = {cid: t for cid, t in self.last_alert_time.items() if t > cutoff} + + +# ==================== 异动检测 ==================== + +def detect_anomalies( + ml_scorer: MLScorer, + data_mgr: RealtimeDataManager, + cooldown_mgr: CooldownManager, + trade_date: str, + config: Dict +) -> List[Dict]: + """检测当前时刻的异动""" + + # 获取最新特征 + latest_df = data_mgr.get_latest_features() + if latest_df.empty: + return [] + + # 获取 ML 序列 + sequences, info_df = data_mgr.get_sequences_for_concepts(config['seq_len']) + + if len(sequences) == 0: + return [] + + # 获取当前时间 + current_time = pd.to_datetime(info_df['timestamp'].iloc[0]) + + # 清理过期冷却 + cooldown_mgr.cleanup_old(current_time) + + # 过滤冷却中的概念 + valid_mask = [] + for _, row in info_df.iterrows(): + in_cooldown = cooldown_mgr.is_in_cooldown(row['concept_id'], current_time) + valid_mask.append(not in_cooldown) + + valid_mask = np.array(valid_mask) + sequences = sequences[valid_mask] + info_df = info_df[valid_mask].reset_index(drop=True) + + if len(sequences) == 0: + return [] + + # 过滤小波动 + alpha_mask = np.abs(info_df['alpha'].values) >= config['min_alpha_abs'] + sequences = sequences[alpha_mask] + info_df = info_df[alpha_mask].reset_index(drop=True) + + if len(sequences) == 0: + return [] + + # 规则评分 + rule_scores, triggered_rules = score_rules_batch(info_df) + + # ML 评分 + ml_scores = ml_scorer.score_batch(sequences) + + # 融合得分 + w1, w2 = config['rule_weight'], config['ml_weight'] + final_scores = w1 * rule_scores + w2 * ml_scores + + # 判断异动 + alerts = [] + for i, row in info_df.iterrows(): + rule_score = rule_scores[i] + ml_score = ml_scores[i] + final_score = final_scores[i] + + is_anomaly = ( + rule_score >= config['rule_trigger'] or + ml_score >= config['ml_trigger'] or + final_score >= config['fusion_trigger'] + ) + + if not is_anomaly: + continue + + # 触发原因 + if rule_score >= config['rule_trigger']: + trigger = f'规则强信号({rule_score:.0f}分)' + elif ml_score >= config['ml_trigger']: + trigger = f'ML强信号({ml_score:.0f}分)' + else: + trigger = f'融合触发({final_score:.0f}分)' + + # 异动类型 + alpha = row['alpha'] + if alpha >= 1.5: + alert_type = 'surge_up' + elif alpha <= -1.5: + alert_type = 'surge_down' + elif row['amt_ratio'] >= 3.0: + alert_type = 'volume_spike' + else: + alert_type = 'unknown' + + alert = { + 'concept_id': row['concept_id'], + 'alert_time': row['timestamp'], + 'trade_date': trade_date, + 'alert_type': alert_type, + 'final_score': final_score, + 'rule_score': rule_score, + 'ml_score': ml_score, + 'trigger_reason': trigger, + 'triggered_rules': triggered_rules[i], + 'alpha': row['alpha'], + 'alpha_delta': row['alpha_delta'], + 'amt_ratio': row['amt_ratio'], + 'amt_delta': row.get('amt_delta', 0), + 'rank_pct': row.get('rank_pct', 0.5), + 'limit_up_ratio': row['limit_up_ratio'], + 'stock_count': row['stock_count'], + 'total_amt': row['total_amt'], + } + + alerts.append(alert) + cooldown_mgr.record_alert(row['concept_id'], current_time) + + # 按得分排序 + alerts.sort(key=lambda x: x['final_score'], reverse=True) + return alerts[:config['max_alerts_per_minute']] + + +# ==================== 数据库写入 ==================== + +def save_alerts_to_mysql(alerts: List[Dict]) -> int: + if not alerts: + return 0 + + saved = 0 + with MYSQL_ENGINE.begin() as conn: + for alert in alerts: + try: + insert_sql = text(""" + INSERT IGNORE INTO concept_anomaly_hybrid + (concept_id, alert_time, trade_date, alert_type, + final_score, rule_score, ml_score, trigger_reason, + alpha, alpha_delta, amt_ratio, amt_delta, + rank_pct, limit_up_ratio, stock_count, total_amt, + triggered_rules) + VALUES + (:concept_id, :alert_time, :trade_date, :alert_type, + :final_score, :rule_score, :ml_score, :trigger_reason, + :alpha, :alpha_delta, :amt_ratio, :amt_delta, + :rank_pct, :limit_up_ratio, :stock_count, :total_amt, + :triggered_rules) + """) + + result = conn.execute(insert_sql, { + 'concept_id': alert['concept_id'], + 'alert_time': alert['alert_time'], + 'trade_date': alert['trade_date'], + 'alert_type': alert['alert_type'], + 'final_score': alert['final_score'], + 'rule_score': alert['rule_score'], + 'ml_score': alert['ml_score'], + 'trigger_reason': alert['trigger_reason'], + 'alpha': alert.get('alpha', 0), + 'alpha_delta': alert.get('alpha_delta', 0), + 'amt_ratio': alert.get('amt_ratio', 1), + 'amt_delta': alert.get('amt_delta', 0), + 'rank_pct': alert.get('rank_pct', 0.5), + 'limit_up_ratio': alert.get('limit_up_ratio', 0), + 'stock_count': alert.get('stock_count', 0), + 'total_amt': alert.get('total_amt', 0), + 'triggered_rules': json.dumps(alert.get('triggered_rules', []), ensure_ascii=False), + }) + + if result.rowcount > 0: + saved += 1 + except Exception as e: + print(f"保存失败: {alert['concept_id']} - {e}") + + return saved + + +# ==================== 主服务 ==================== + +class RealtimeDetectorService: + def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'): + self.checkpoint_dir = checkpoint_dir + self.device = device + + # 初始化 ML 评分器 + self.ml_scorer = MLScorer(checkpoint_dir, device) + + # 这些在 init_for_trade_date 中初始化 + self.data_mgr = None + self.cooldown_mgr = None + self.trade_date = None + + def init_for_trade_date(self, trade_date: str, preload_history: bool = True): + """ + 为指定交易日初始化 + + Args: + trade_date: 交易日期 + preload_history: 是否预加载当天已有的历史数据(实盘必须为 True) + """ + if self.trade_date == trade_date and self.data_mgr is not None: + return + + print(f"[初始化] 交易日: {trade_date}") + + # 获取概念列表 + print(f"[初始化] 获取概念列表...") + concepts = get_all_concepts() + + # 获取所有股票 + all_stocks = list(set(s for c in concepts for s in c['stocks'])) + print(f"[初始化] 共 {len(all_stocks)} 只股票") + + # 获取昨收价 + print(f"[初始化] 获取昨收价...") + prev_close = get_prev_close(all_stocks, trade_date) + index_prev_close = get_index_prev_close(trade_date) + print(f"[初始化] 获取到 {len(prev_close)} 只股票的昨收价") + print(f"[初始化] 指数昨收价: {index_prev_close}") + + # 创建数据管理器 + self.data_mgr = RealtimeDataManager(concepts, prev_close, index_prev_close) + self.cooldown_mgr = CooldownManager(CONFIG['cooldown_minutes']) + self.trade_date = trade_date + + # 预加载当天已有的历史数据(实盘关键) + if preload_history: + self._preload_today_history(trade_date) + + def _preload_today_history(self, trade_date: str): + """ + 预加载当天已有的历史数据到内存 + + 这是实盘运行的关键: + - 在盘中任意时刻启动服务时,需要先加载当天已有的数据 + - 这样才能正确计算 alpha_delta(需要过去 5 分钟)和 amt_ratio(需要过去 20 分钟) + - 以及构建 ML 所需的序列(需要 seq_len=15 分钟) + + 整个过程不依赖 prepare_data.py,直接从 ClickHouse 读取原始数据计算 + """ + print(f"[预热] 加载当天历史数据...") + + # 直接调用 update,但不设置 last_update_time,会获取当天所有数据 + # data_mgr.last_update_time 初始为 None,会获取全部数据 + n_updates = self.data_mgr.update(trade_date) + + if n_updates > 0: + print(f"[预热] 加载完成,共 {n_updates} 个时间点") + + # 检查是否满足 ML 所需的最小数据量 + min_required = CONFIG['seq_len'] + FEATURE_CONFIG['amt_ma_window'] + if n_updates < min_required: + print(f"[预热] 警告:数据量 {n_updates} < 最小需求 {min_required},部分特征可能不准确") + else: + print(f"[预热] 数据充足,可以正常检测") + else: + print(f"[预热] 当天暂无历史数据(可能是开盘前)") + + def backfill_today(self): + """ + 补齐当天历史数据并检测异动(回补模式) + + 使用与 backtest_fast.py 完全相同的逻辑: + 1. 先用 prepare_data.py 生成当天的 parquet 文件 + 2. 读取 parquet 文件进行回测 + + 注意:这个方法用于回补历史异动记录,不是实盘必须的 + 实盘模式下,init_for_trade_date 会自动预热历史数据 + """ + trade_date = get_current_trade_date() + print(f"[补齐] 交易日: {trade_date}") + + # 1. 生成当天的 parquet 文件 + parquet_path = Path('ml/data') / f'features_{trade_date}.parquet' + + if not parquet_path.exists(): + print(f"[补齐] 生成当天特征数据...") + self._generate_today_parquet(trade_date) + + if not parquet_path.exists(): + print(f"[补齐] 无法生成特征数据,跳过") + return + + # 2. 读取 parquet 文件 + df = pd.read_parquet(parquet_path) + print(f"[补齐] 读取到 {len(df)} 条特征数据") + + if df.empty: + print("[补齐] 无数据") + return + + # 打印特征分布(调试) + print(f"[调试] alpha 分布: min={df['alpha'].min():.2f}, max={df['alpha'].max():.2f}, mean={df['alpha'].mean():.2f}") + print(f"[调试] |alpha| >= 0.3 的数量: {(df['alpha'].abs() >= 0.3).sum()}") + + # 3. 使用 backtest_fast.py 相同的回测逻辑 + alerts = self._backtest_from_parquet(df, trade_date) + + # 4. 保存结果 + if alerts: + saved = save_alerts_to_mysql(alerts) + print(f"[补齐] 完成!共 {len(alerts)} 个异动, 保存 {saved} 条") + + # 统计触发来源 + trigger_stats = {'规则': 0, 'ML': 0, '融合': 0} + for a in alerts: + reason = a['trigger_reason'] + if '规则' in reason: + trigger_stats['规则'] += 1 + elif 'ML' in reason: + trigger_stats['ML'] += 1 + else: + trigger_stats['融合'] += 1 + print(f"[补齐] 触发来源: {trigger_stats}") + else: + print("[补齐] 无异动") + + def _generate_today_parquet(self, trade_date: str): + """ + 生成当天的 parquet 文件(调用 prepare_data.py 的逻辑) + """ + import subprocess + cmd = ['python', 'ml/prepare_data.py', '--start', trade_date, '--end', trade_date] + print(f"[补齐] 执行: {' '.join(cmd)}") + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode != 0: + print(f"[补齐] prepare_data.py 执行失败: {result.stderr}") + except Exception as e: + print(f"[补齐] prepare_data.py 执行异常: {e}") + + def _backtest_from_parquet(self, df: pd.DataFrame, trade_date: str) -> List[Dict]: + """ + 从 parquet 数据回测(与 backtest_fast.py 完全一致的逻辑) + """ + seq_len = CONFIG['seq_len'] + now = datetime.now() + + # 确保按概念和时间排序 + df = df.sort_values(['concept_id', 'timestamp']).reset_index(drop=True) + + # 获取所有时间点 + all_timestamps = sorted(df['timestamp'].unique()) + + # 只处理当前时间之前的 + past_timestamps = [] + for ts in all_timestamps: + try: + ts_dt = pd.to_datetime(ts) + if ts_dt.tzinfo is not None: + ts_dt = ts_dt.tz_localize(None) + if ts_dt < now: + past_timestamps.append(ts) + except Exception: + continue + + print(f"[补齐] 处理 {len(past_timestamps)} 个历史时间点...") + + if len(past_timestamps) < seq_len: + print(f"[补齐] 时间点不足 {seq_len},跳过") + return [] + + # 构建序列(与 backtest_fast.py 的 build_sequences_fast 一致) + sequences = [] + infos = [] + + groups = df.groupby('concept_id') + + for concept_id, gdf in groups: + gdf = gdf.reset_index(drop=True) + feat_matrix = gdf[FEATURES].values + feat_matrix = np.nan_to_num(feat_matrix, nan=0.0, posinf=0.0, neginf=0.0) + feat_matrix = np.clip(feat_matrix, -CONFIG['clip_value'], CONFIG['clip_value']) + + n_total = len(feat_matrix) + if n_total < seq_len: + continue + + for i in range(n_total - seq_len + 1): + seq = feat_matrix[i:i + seq_len] + row = gdf.iloc[i + seq_len - 1] + + # 只保留当前时间之前的 + ts = row['timestamp'] + try: + ts_dt = pd.to_datetime(ts) + if ts_dt.tzinfo is not None: + ts_dt = ts_dt.tz_localize(None) + if ts_dt >= now: + continue + except Exception: + continue + + sequences.append(seq) + infos.append({ + 'concept_id': concept_id, + 'timestamp': row['timestamp'], + 'alpha': row['alpha'], + 'alpha_delta': row.get('alpha_delta', 0), + 'amt_ratio': row.get('amt_ratio', 1), + 'amt_delta': row.get('amt_delta', 0), + 'rank_pct': row.get('rank_pct', 0.5), + 'limit_up_ratio': row.get('limit_up_ratio', 0), + 'stock_count': row.get('stock_count', 0), + 'total_amt': row.get('total_amt', 0), + }) + + if not sequences: + return [] + + sequences = np.array(sequences) + info_df = pd.DataFrame(infos) + + print(f"[补齐] 构建了 {len(sequences)} 个序列") + + # 过滤小波动 + alpha_abs = np.abs(info_df['alpha'].values) + valid_mask = alpha_abs >= CONFIG['min_alpha_abs'] + sequences = sequences[valid_mask] + info_df = info_df[valid_mask].reset_index(drop=True) + + if len(sequences) == 0: + return [] + + print(f"[补齐] 过滤后 {len(sequences)} 个序列") + + # 批量规则评分 + rule_scores, triggered_rules = score_rules_batch(info_df) + + # 批量 ML 评分 + batch_size = 2048 + ml_scores = [] + for i in range(0, len(sequences), batch_size): + batch_seq = sequences[i:i+batch_size] + batch_scores = self.ml_scorer.score_batch(batch_seq) + ml_scores.append(batch_scores) + ml_scores = np.concatenate(ml_scores) if ml_scores else np.zeros(len(sequences)) + + # 融合得分 + w1, w2 = CONFIG['rule_weight'], CONFIG['ml_weight'] + final_scores = w1 * rule_scores + w2 * ml_scores + + # 判断异动 + is_anomaly = ( + (rule_scores >= CONFIG['rule_trigger']) | + (ml_scores >= CONFIG['ml_trigger']) | + (final_scores >= CONFIG['fusion_trigger']) + ) + + # 添加分数到 info_df + info_df['rule_score'] = rule_scores + info_df['ml_score'] = ml_scores + info_df['final_score'] = final_scores + info_df['is_anomaly'] = is_anomaly + info_df['triggered_rules'] = triggered_rules + + # 只保留异动 + anomaly_df = info_df[info_df['is_anomaly']].copy() + + if len(anomaly_df) == 0: + return [] + + print(f"[补齐] 发现 {len(anomaly_df)} 个候选异动") + + # 应用冷却期 + anomaly_df = anomaly_df.sort_values(['concept_id', 'timestamp']) + cooldown = {} + keep_mask = [] + + for _, row in anomaly_df.iterrows(): + cid = row['concept_id'] + ts = row['timestamp'] + + if cid in cooldown: + try: + diff = (pd.to_datetime(ts) - pd.to_datetime(cooldown[cid])).total_seconds() / 60 + except: + diff = CONFIG['cooldown_minutes'] + 1 + + if diff < CONFIG['cooldown_minutes']: + keep_mask.append(False) + continue + + cooldown[cid] = ts + keep_mask.append(True) + + anomaly_df = anomaly_df[keep_mask] + + print(f"[补齐] 冷却后 {len(anomaly_df)} 个异动") + + # 按时间分组,每分钟最多 max_alerts_per_minute 个 + alerts = [] + for ts, group in anomaly_df.groupby('timestamp'): + group = group.nlargest(CONFIG['max_alerts_per_minute'], 'final_score') + + for _, row in group.iterrows(): + alpha = row['alpha'] + if alpha >= 1.5: + atype = 'surge_up' + elif alpha <= -1.5: + atype = 'surge_down' + elif row['amt_ratio'] >= 3.0: + atype = 'volume_spike' + else: + atype = 'unknown' + + rule_score = row['rule_score'] + ml_score = row['ml_score'] + final_score = row['final_score'] + + if rule_score >= CONFIG['rule_trigger']: + trigger = f'规则强信号({rule_score:.0f}分)' + elif ml_score >= CONFIG['ml_trigger']: + trigger = f'ML强信号({ml_score:.0f}分)' + else: + trigger = f'融合触发({final_score:.0f}分)' + + alerts.append({ + 'concept_id': row['concept_id'], + 'alert_time': row['timestamp'], + 'trade_date': trade_date, + 'alert_type': atype, + 'final_score': final_score, + 'rule_score': rule_score, + 'ml_score': ml_score, + 'trigger_reason': trigger, + 'triggered_rules': row['triggered_rules'], + 'alpha': alpha, + 'alpha_delta': row['alpha_delta'], + 'amt_ratio': row['amt_ratio'], + 'amt_delta': row['amt_delta'], + 'rank_pct': row['rank_pct'], + 'limit_up_ratio': row['limit_up_ratio'], + 'stock_count': row['stock_count'], + 'total_amt': row['total_amt'], + }) + + return alerts + + def run_once(self): + """执行一次检测""" + now = datetime.now() + trade_date = get_current_trade_date() + + if not is_trading_time(): + print(f"[{now.strftime('%H:%M:%S')}] 非交易时间,跳过") + return + + # 初始化 + self.init_for_trade_date(trade_date) + + print(f"[{now.strftime('%H:%M:%S')}] 获取新数据...") + + # 增量更新 + n_updates = self.data_mgr.update(trade_date) + print(f" 新增 {n_updates} 个时间点") + + if n_updates == 0: + print(f" 无新数据") + return + + # 检测 + alerts = detect_anomalies( + self.ml_scorer, + self.data_mgr, + self.cooldown_mgr, + trade_date, + CONFIG + ) + + if alerts: + saved = save_alerts_to_mysql(alerts) + print(f" 检测到 {len(alerts)} 个异动, 保存 {saved} 条") + + for alert in alerts[:5]: + print(f" - {alert['concept_id']}: {alert['alert_type']} " + f"(final={alert['final_score']:.0f}, rule={alert['rule_score']:.0f}, ml={alert['ml_score']:.0f})") + else: + print(f" 无异动") + + def run_loop(self, backfill: bool = False): + """ + 持续运行(实盘模式) + + Args: + backfill: 是否回补历史异动到数据库(使用 prepare_data.py 方式) + 默认 False,因为实盘模式下 init_for_trade_date 会自动预热数据 + """ + print("=" * 60) + print("实时概念异动检测服务(实盘模式)") + print("=" * 60) + print(f"模型目录: {self.checkpoint_dir}") + print(f"交易时段: {TRADING_PERIODS}") + print(f"ML 序列长度: {CONFIG['seq_len']} 分钟") + print(f"成交额均值窗口: {FEATURE_CONFIG['amt_ma_window']} 分钟") + print("=" * 60) + + # 立即初始化并预热(即使不在交易时间也预热,方便测试) + trade_date = get_current_trade_date() + print(f"\n[启动] 初始化交易日 {trade_date}...") + self.init_for_trade_date(trade_date, preload_history=True) + + # 可选:回补历史异动记录到数据库 + if backfill and is_trading_time(): + print("\n[启动] 回补历史异动...") + self.backfill_today() + + # 每分钟第 10 秒执行 + schedule.every().minute.at(":10").do(self.run_once) + + print("\n服务已启动,等待下一分钟...") + + while True: + schedule.run_pending() + time.sleep(1) + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description='实时概念异动检测') + parser.add_argument('--checkpoint_dir', default='ml/checkpoints', help='模型目录') + parser.add_argument('--device', default='auto', help='设备 (auto/cpu/cuda)') + parser.add_argument('--once', action='store_true', help='只运行一次检测') + parser.add_argument('--backfill', action='store_true', help='启动时回补历史异动到数据库') + parser.add_argument('--backfill-only', action='store_true', help='只回补历史(不持续运行)') + + args = parser.parse_args() + + service = RealtimeDetectorService( + checkpoint_dir=args.checkpoint_dir, + device=args.device + ) + + if args.once: + # 单次检测模式 + service.run_once() + elif args.backfill_only: + # 仅回补历史模式(需要 prepare_data.py) + service.backfill_today() + else: + # 实盘持续运行模式(自动预热,不依赖 prepare_data.py) + service.run_loop(backfill=args.backfill) + + +if __name__ == "__main__": + main() diff --git a/ml/realtime_detector_v2.py b/ml/realtime_detector_v2.py new file mode 100644 index 00000000..e5ccc942 --- /dev/null +++ b/ml/realtime_detector_v2.py @@ -0,0 +1,729 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +V2 实时异动检测器 + +使用方法: + # 作为模块导入 + from ml.realtime_detector_v2 import RealtimeDetectorV2 + + detector = RealtimeDetectorV2() + alerts = detector.detect_realtime() # 检测当前时刻 + + # 或命令行测试 + python ml/realtime_detector_v2.py --date 2025-12-09 +""" + +import os +import sys +import json +import pickle +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple +from collections import defaultdict, deque + +import numpy as np +import pandas as pd +import torch +from sqlalchemy import create_engine, text +from elasticsearch import Elasticsearch +from clickhouse_driver import Client + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ml.model import TransformerAutoencoder + +# ==================== 配置 ==================== + +MYSQL_URL = "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock" +ES_HOST = 'http://127.0.0.1:9200' +ES_INDEX = 'concept_library_v3' + +CLICKHOUSE_CONFIG = { + 'host': '127.0.0.1', + 'port': 9000, + 'user': 'default', + 'password': 'Zzl33818!', + 'database': 'stock' +} + +REFERENCE_INDEX = '000001.SH' +BASELINE_FILE = 'ml/data_v2/baselines/realtime_baseline.pkl' +MODEL_DIR = 'ml/checkpoints_v2' + +# 检测配置 +CONFIG = { + 'seq_len': 10, # LSTM 序列长度 + 'confirm_window': 5, # 持续确认窗口 + 'confirm_ratio': 0.6, # 确认比例 + 'rule_weight': 0.5, + 'ml_weight': 0.5, + 'rule_trigger': 60, + 'ml_trigger': 70, + 'fusion_trigger': 50, + 'cooldown_minutes': 10, + 'max_alerts_per_minute': 15, + 'zscore_clip': 5.0, + 'limit_up_threshold': 9.8, +} + +FEATURES = ['alpha_zscore', 'amt_zscore', 'rank_zscore', 'momentum_3m', 'momentum_5m', 'limit_up_ratio'] + + +# ==================== 数据库连接 ==================== + +_mysql_engine = None +_es_client = None +_ch_client = None + + +def get_mysql_engine(): + global _mysql_engine + if _mysql_engine is None: + _mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True) + return _mysql_engine + + +def get_es_client(): + global _es_client + if _es_client is None: + _es_client = Elasticsearch([ES_HOST]) + return _es_client + + +def get_ch_client(): + global _ch_client + if _ch_client is None: + _ch_client = Client(**CLICKHOUSE_CONFIG) + return _ch_client + + +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" + return f"{code}.BJ" + + +def time_to_slot(ts) -> str: + if isinstance(ts, str): + return ts + return ts.strftime('%H:%M') + + +# ==================== 规则评分 ==================== + +def score_rules_zscore(features: Dict) -> Tuple[float, List[str]]: + """基于 Z-Score 的规则评分""" + score = 0.0 + triggered = [] + + alpha_z = abs(features.get('alpha_zscore', 0)) + amt_z = features.get('amt_zscore', 0) + rank_z = abs(features.get('rank_zscore', 0)) + mom_3m = features.get('momentum_3m', 0) + mom_5m = features.get('momentum_5m', 0) + limit_up = features.get('limit_up_ratio', 0) + + # Alpha Z-Score + if alpha_z >= 4.0: + score += 25 + triggered.append('alpha_extreme') + elif alpha_z >= 3.0: + score += 18 + triggered.append('alpha_strong') + elif alpha_z >= 2.0: + score += 10 + triggered.append('alpha_moderate') + + # 成交额 Z-Score + if amt_z >= 4.0: + score += 20 + triggered.append('amt_extreme') + elif amt_z >= 3.0: + score += 12 + triggered.append('amt_strong') + elif amt_z >= 2.0: + score += 6 + triggered.append('amt_moderate') + + # 排名 Z-Score + if rank_z >= 3.0: + score += 15 + triggered.append('rank_extreme') + elif rank_z >= 2.0: + score += 8 + triggered.append('rank_strong') + + # 动量(基于 Z-Score 的) + if mom_3m >= 1.0: + score += 12 + triggered.append('momentum_3m_strong') + elif mom_3m >= 0.5: + score += 6 + triggered.append('momentum_3m_moderate') + + if mom_5m >= 1.5: + score += 10 + triggered.append('momentum_5m_strong') + + # 涨停比例 + if limit_up >= 0.3: + score += 20 + triggered.append('limit_up_extreme') + elif limit_up >= 0.15: + score += 12 + triggered.append('limit_up_strong') + elif limit_up >= 0.08: + score += 5 + triggered.append('limit_up_moderate') + + # 组合规则 + if alpha_z >= 2.0 and amt_z >= 2.0: + score += 15 + triggered.append('combo_alpha_amt') + + if alpha_z >= 2.0 and limit_up >= 0.1: + score += 12 + triggered.append('combo_alpha_limitup') + + return min(score, 100), triggered + + +# ==================== 实时检测器 ==================== + +class RealtimeDetectorV2: + """V2 实时异动检测器""" + + def __init__(self, model_dir: str = MODEL_DIR, baseline_file: str = BASELINE_FILE): + print("初始化 V2 实时检测器...") + + # 加载概念 + self.concepts = self._load_concepts() + self.concept_stocks = {c['concept_id']: set(c['stocks']) for c in self.concepts} + self.all_stocks = list(set(s for c in self.concepts for s in c['stocks'])) + + # 加载基线 + self.baselines = self._load_baselines(baseline_file) + + # 加载模型 + self.model, self.thresholds, self.device = self._load_model(model_dir) + + # 状态管理 + self.zscore_history = defaultdict(lambda: deque(maxlen=CONFIG['seq_len'])) + self.anomaly_candidates = defaultdict(lambda: deque(maxlen=CONFIG['confirm_window'])) + self.cooldown = {} + + print(f"初始化完成: {len(self.concepts)} 概念, {len(self.baselines)} 基线") + + def _load_concepts(self) -> List[dict]: + """从 ES 加载概念""" + es = get_es_client() + concepts = [] + + query = {"query": {"match_all": {}}, "size": 100, "_source": ["concept_id", "concept", "stocks"]} + resp = es.search(index=ES_INDEX, body=query, scroll='2m') + scroll_id = resp['_scroll_id'] + hits = resp['hits']['hits'] + + while hits: + for hit in hits: + src = hit['_source'] + stocks = [s['code'] for s in src.get('stocks', []) if isinstance(s, dict) and s.get('code')] + if stocks: + concepts.append({ + 'concept_id': src.get('concept_id'), + 'concept_name': src.get('concept'), + 'stocks': stocks + }) + resp = es.scroll(scroll_id=scroll_id, scroll='2m') + scroll_id = resp['_scroll_id'] + hits = resp['hits']['hits'] + + es.clear_scroll(scroll_id=scroll_id) + return concepts + + def _load_baselines(self, baseline_file: str) -> Dict: + """加载基线""" + if not os.path.exists(baseline_file): + print(f"警告: 基线文件不存在: {baseline_file}") + print("请先运行: python ml/update_baseline.py") + return {} + + with open(baseline_file, 'rb') as f: + data = pickle.load(f) + + print(f"基线日期范围: {data.get('date_range', 'unknown')}") + print(f"更新时间: {data.get('update_time', 'unknown')}") + + return data.get('baselines', {}) + + def _load_model(self, model_dir: str): + """加载模型""" + device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + + config_path = os.path.join(model_dir, 'config.json') + model_path = os.path.join(model_dir, 'best_model.pt') + threshold_path = os.path.join(model_dir, 'thresholds.json') + + if not os.path.exists(model_path): + print(f"警告: 模型不存在: {model_path}") + return None, {}, device + + with open(config_path) as f: + config = json.load(f) + + model = TransformerAutoencoder(**config['model']) + checkpoint = torch.load(model_path, map_location=device) + model.load_state_dict(checkpoint['model_state_dict']) + model.to(device) + model.eval() + + thresholds = {} + if os.path.exists(threshold_path): + with open(threshold_path) as f: + thresholds = json.load(f) + + print(f"模型已加载: {model_path}") + return model, thresholds, device + + def _get_realtime_data(self, trade_date: str) -> pd.DataFrame: + """获取实时数据并计算原始特征""" + ch = get_ch_client() + + # 获取股票数据 + ch_codes = [code_to_ch_format(c) for c in self.all_stocks if code_to_ch_format(c)] + ch_codes_str = "','".join(ch_codes) + + stock_query = f""" + SELECT code, timestamp, close, amt + FROM stock_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code IN ('{ch_codes_str}') + ORDER BY timestamp + """ + stock_result = ch.execute(stock_query) + if not stock_result: + return pd.DataFrame() + + stock_df = pd.DataFrame(stock_result, columns=['ch_code', 'timestamp', 'close', 'amt']) + + # 映射回原始代码 + ch_to_code = {code_to_ch_format(c): c for c in self.all_stocks if code_to_ch_format(c)} + stock_df['code'] = stock_df['ch_code'].map(ch_to_code) + stock_df = stock_df.dropna(subset=['code']) + + # 获取指数数据 + index_query = f""" + SELECT timestamp, close + FROM index_minute + WHERE toDate(timestamp) = '{trade_date}' + AND code = '{REFERENCE_INDEX}' + ORDER BY timestamp + """ + index_result = ch.execute(index_query) + if not index_result: + return pd.DataFrame() + + index_df = pd.DataFrame(index_result, columns=['timestamp', 'close']) + + # 获取昨收价 + engine = get_mysql_engine() + codes_str = "','".join([c for c in self.all_stocks if c and len(c) == 6]) + + with engine.connect() as conn: + prev_result = conn.execute(text(f""" + SELECT SECCODE, F007N FROM ea_trade + WHERE SECCODE IN ('{codes_str}') + AND TRADEDATE = (SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}') + AND F007N > 0 + """)) + prev_close = {row[0]: float(row[1]) for row in prev_result if row[1]} + + idx_result = conn.execute(text(""" + SELECT F006N FROM ea_exchangetrade + WHERE INDEXCODE = '000001' AND TRADEDATE < :today + ORDER BY TRADEDATE DESC LIMIT 1 + """), {'today': trade_date}).fetchone() + index_prev_close = float(idx_result[0]) if idx_result else None + + if not prev_close or not index_prev_close: + return pd.DataFrame() + + # 计算涨跌幅 + stock_df['prev_close'] = stock_df['code'].map(prev_close) + stock_df = stock_df.dropna(subset=['prev_close']) + stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100 + + index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100 + index_map = dict(zip(index_df['timestamp'], index_df['change_pct'])) + + # 按时间聚合概念特征 + results = [] + for ts in sorted(stock_df['timestamp'].unique()): + ts_data = stock_df[stock_df['timestamp'] == ts] + idx_chg = index_map.get(ts, 0) + + stock_chg = dict(zip(ts_data['code'], ts_data['change_pct'])) + stock_amt = dict(zip(ts_data['code'], ts_data['amt'])) + + for cid, stocks in self.concept_stocks.items(): + changes = [stock_chg[s] for s in stocks if s in stock_chg] + amts = [stock_amt.get(s, 0) for s in stocks if s in stock_chg] + + if not changes: + continue + + alpha = np.mean(changes) - idx_chg + total_amt = sum(amts) + limit_up_ratio = sum(1 for c in changes if c >= CONFIG['limit_up_threshold']) / len(changes) + + results.append({ + 'concept_id': cid, + 'timestamp': ts, + 'time_slot': time_to_slot(ts), + 'alpha': alpha, + 'total_amt': total_amt, + 'limit_up_ratio': limit_up_ratio, + 'stock_count': len(changes), + }) + + if not results: + return pd.DataFrame() + + df = pd.DataFrame(results) + + # 计算排名 + for ts in df['timestamp'].unique(): + mask = df['timestamp'] == ts + df.loc[mask, 'rank_pct'] = df.loc[mask, 'alpha'].rank(pct=True) + + return df + + def _compute_zscore(self, concept_id: str, time_slot: str, alpha: float, total_amt: float, rank_pct: float) -> Optional[Dict]: + """计算 Z-Score""" + if concept_id not in self.baselines: + return None + + baseline = self.baselines[concept_id] + if time_slot not in baseline: + return None + + bl = baseline[time_slot] + + alpha_z = np.clip((alpha - bl['alpha_mean']) / bl['alpha_std'], -5, 5) + amt_z = np.clip((total_amt - bl['amt_mean']) / bl['amt_std'], -5, 5) + rank_z = np.clip((rank_pct - bl['rank_mean']) / bl['rank_std'], -5, 5) + + # 动量(基于 Z-Score 历史) + history = list(self.zscore_history[concept_id]) + mom_3m = 0.0 + mom_5m = 0.0 + + if len(history) >= 3: + recent = [h['alpha_zscore'] for h in history[-3:]] + older = [h['alpha_zscore'] for h in history[-6:-3]] if len(history) >= 6 else [history[0]['alpha_zscore']] + mom_3m = np.mean(recent) - np.mean(older) + + if len(history) >= 5: + recent = [h['alpha_zscore'] for h in history[-5:]] + older = [h['alpha_zscore'] for h in history[-10:-5]] if len(history) >= 10 else [history[0]['alpha_zscore']] + mom_5m = np.mean(recent) - np.mean(older) + + return { + 'alpha_zscore': float(alpha_z), + 'amt_zscore': float(amt_z), + 'rank_zscore': float(rank_z), + 'momentum_3m': float(mom_3m), + 'momentum_5m': float(mom_5m), + } + + @torch.no_grad() + def _ml_score(self, sequences: np.ndarray) -> np.ndarray: + """批量 ML 评分""" + if self.model is None or len(sequences) == 0: + return np.zeros(len(sequences)) + + x = torch.FloatTensor(sequences).to(self.device) + errors = self.model.compute_reconstruction_error(x, reduction='none') + last_errors = errors[:, -1].cpu().numpy() + + # 转换为 0-100 分数 + if self.thresholds: + p50 = self.thresholds.get('median', 0.001) + p99 = self.thresholds.get('p99', 0.05) + scores = 50 + (last_errors - p50) / (p99 - p50 + 1e-6) * 49 + else: + scores = last_errors * 1000 + + return np.clip(scores, 0, 100) + + def detect(self, trade_date: str = None) -> List[Dict]: + """检测指定日期的异动""" + trade_date = trade_date or datetime.now().strftime('%Y-%m-%d') + print(f"\n检测 {trade_date} 的异动...") + + # 重置状态 + self.zscore_history.clear() + self.anomaly_candidates.clear() + self.cooldown.clear() + + # 获取数据 + raw_df = self._get_realtime_data(trade_date) + if raw_df.empty: + print("无数据") + return [] + + timestamps = sorted(raw_df['timestamp'].unique()) + print(f"时间点数: {len(timestamps)}") + + all_alerts = [] + + for ts in timestamps: + ts_data = raw_df[raw_df['timestamp'] == ts] + time_slot = time_to_slot(ts) + + candidates = [] + + # 计算每个概念的 Z-Score + for _, row in ts_data.iterrows(): + cid = row['concept_id'] + + zscore = self._compute_zscore( + cid, time_slot, + row['alpha'], row['total_amt'], row['rank_pct'] + ) + + if zscore is None: + continue + + # 完整特征 + features = { + **zscore, + 'alpha': row['alpha'], + 'limit_up_ratio': row['limit_up_ratio'], + 'total_amt': row['total_amt'], + } + + # 更新历史 + self.zscore_history[cid].append(zscore) + + # 规则评分 + rule_score, triggered = score_rules_zscore(features) + + candidates.append((cid, features, rule_score, triggered)) + + if not candidates: + continue + + # 批量 ML 评分 + sequences = [] + valid_candidates = [] + + for cid, features, rule_score, triggered in candidates: + history = list(self.zscore_history[cid]) + if len(history) >= CONFIG['seq_len']: + seq = np.array([[h['alpha_zscore'], h['amt_zscore'], h['rank_zscore'], + h['momentum_3m'], h['momentum_5m'], features['limit_up_ratio']] + for h in history]) + sequences.append(seq) + valid_candidates.append((cid, features, rule_score, triggered)) + + if not sequences: + continue + + ml_scores = self._ml_score(np.array(sequences)) + + # 融合 + 确认 + for i, (cid, features, rule_score, triggered) in enumerate(valid_candidates): + ml_score = ml_scores[i] + final_score = CONFIG['rule_weight'] * rule_score + CONFIG['ml_weight'] * ml_score + + # 判断触发 + is_triggered = ( + rule_score >= CONFIG['rule_trigger'] or + ml_score >= CONFIG['ml_trigger'] or + final_score >= CONFIG['fusion_trigger'] + ) + + self.anomaly_candidates[cid].append((ts, final_score)) + + if not is_triggered: + continue + + # 冷却期 + if cid in self.cooldown: + if (ts - self.cooldown[cid]).total_seconds() < CONFIG['cooldown_minutes'] * 60: + continue + + # 持续确认 + recent = list(self.anomaly_candidates[cid]) + if len(recent) < CONFIG['confirm_window']: + continue + + exceed = sum(1 for _, s in recent if s >= CONFIG['fusion_trigger']) + ratio = exceed / len(recent) + + if ratio < CONFIG['confirm_ratio']: + continue + + # 确认异动! + self.cooldown[cid] = ts + + alpha = features['alpha'] + alert_type = 'surge_up' if alpha >= 1.5 else 'surge_down' if alpha <= -1.5 else 'surge' + + concept_name = next((c['concept_name'] for c in self.concepts if c['concept_id'] == cid), cid) + + all_alerts.append({ + 'concept_id': cid, + 'concept_name': concept_name, + 'alert_time': ts, + 'trade_date': trade_date, + 'alert_type': alert_type, + 'final_score': float(final_score), + 'rule_score': float(rule_score), + 'ml_score': float(ml_score), + 'confirm_ratio': float(ratio), + 'alpha': float(alpha), + 'alpha_zscore': float(features['alpha_zscore']), + 'amt_zscore': float(features['amt_zscore']), + 'rank_zscore': float(features['rank_zscore']), + 'momentum_3m': float(features['momentum_3m']), + 'momentum_5m': float(features['momentum_5m']), + 'limit_up_ratio': float(features['limit_up_ratio']), + 'triggered_rules': triggered, + 'trigger_reason': f"融合({final_score:.0f})+确认({ratio:.0%})", + }) + + print(f"检测到 {len(all_alerts)} 个异动") + return all_alerts + + +# ==================== 数据库存储 ==================== + +def create_v2_table(): + """创建 V2 异动表(如果不存在)""" + engine = get_mysql_engine() + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS concept_anomaly_v2 ( + id INT AUTO_INCREMENT PRIMARY KEY, + concept_id VARCHAR(50) NOT NULL, + alert_time DATETIME NOT NULL, + trade_date DATE NOT NULL, + alert_type VARCHAR(20) NOT NULL, + final_score FLOAT, + rule_score FLOAT, + ml_score FLOAT, + trigger_reason VARCHAR(200), + confirm_ratio FLOAT, + alpha FLOAT, + alpha_zscore FLOAT, + amt_zscore FLOAT, + rank_zscore FLOAT, + momentum_3m FLOAT, + momentum_5m FLOAT, + limit_up_ratio FLOAT, + triggered_rules TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_concept_time (concept_id, alert_time), + INDEX idx_trade_date (trade_date), + INDEX idx_alert_type (alert_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + print("concept_anomaly_v2 表已就绪") + + +def save_alerts_to_db(alerts: List[Dict]) -> int: + """保存异动到数据库""" + if not alerts: + return 0 + + engine = get_mysql_engine() + saved = 0 + + with engine.begin() as conn: + for alert in alerts: + try: + insert_sql = text(""" + INSERT IGNORE INTO concept_anomaly_v2 + (concept_id, alert_time, trade_date, alert_type, + final_score, rule_score, ml_score, trigger_reason, confirm_ratio, + alpha, alpha_zscore, amt_zscore, rank_zscore, + momentum_3m, momentum_5m, limit_up_ratio, triggered_rules) + VALUES + (:concept_id, :alert_time, :trade_date, :alert_type, + :final_score, :rule_score, :ml_score, :trigger_reason, :confirm_ratio, + :alpha, :alpha_zscore, :amt_zscore, :rank_zscore, + :momentum_3m, :momentum_5m, :limit_up_ratio, :triggered_rules) + """) + + result = conn.execute(insert_sql, { + 'concept_id': alert['concept_id'], + 'alert_time': alert['alert_time'], + 'trade_date': alert['trade_date'], + 'alert_type': alert['alert_type'], + 'final_score': alert['final_score'], + 'rule_score': alert['rule_score'], + 'ml_score': alert['ml_score'], + 'trigger_reason': alert['trigger_reason'], + 'confirm_ratio': alert['confirm_ratio'], + 'alpha': alert['alpha'], + 'alpha_zscore': alert['alpha_zscore'], + 'amt_zscore': alert['amt_zscore'], + 'rank_zscore': alert['rank_zscore'], + 'momentum_3m': alert['momentum_3m'], + 'momentum_5m': alert['momentum_5m'], + 'limit_up_ratio': alert['limit_up_ratio'], + 'triggered_rules': json.dumps(alert.get('triggered_rules', []), ensure_ascii=False), + }) + + if result.rowcount > 0: + saved += 1 + except Exception as e: + print(f"保存失败: {alert['concept_id']} - {e}") + + return saved + + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('--date', type=str, default=None) + parser.add_argument('--no-save', action='store_true', help='不保存到数据库,只打印') + args = parser.parse_args() + + # 确保表存在 + if not args.no_save: + create_v2_table() + + detector = RealtimeDetectorV2() + alerts = detector.detect(args.date) + + print(f"\n{'='*60}") + print(f"检测结果 ({len(alerts)} 个异动)") + print('='*60) + + for a in alerts[:20]: + print(f"[{a['alert_time'].strftime('%H:%M') if hasattr(a['alert_time'], 'strftime') else a['alert_time']}] " + f"{a['concept_name']} | {a['alert_type']} | " + f"分数={a['final_score']:.0f} 确认={a['confirm_ratio']:.0%} " + f"α={a['alpha']:.2f}% αZ={a['alpha_zscore']:.1f}") + + if len(alerts) > 20: + print(f"... 共 {len(alerts)} 个") + + # 保存到数据库 + if not args.no_save and alerts: + saved = save_alerts_to_db(alerts) + print(f"\n✅ 已保存 {saved}/{len(alerts)} 条到 concept_anomaly_v2 表") + elif args.no_save: + print(f"\n⚠️ --no-save 模式,未保存到数据库") + + +if __name__ == "__main__": + main() diff --git a/ml/requirements.txt b/ml/requirements.txt new file mode 100644 index 00000000..7b052bb8 --- /dev/null +++ b/ml/requirements.txt @@ -0,0 +1,25 @@ +# 概念异动检测 ML 模块依赖 +# 安装: pip install -r ml/requirements.txt + +# PyTorch (根据 CUDA 版本选择) +# 5090 显卡需要 CUDA 12.x +# pip install torch --index-url https://download.pytorch.org/whl/cu124 +torch>=2.0.0 + +# 数据处理 +numpy>=1.24.0 +pandas>=2.0.0 +pyarrow>=14.0.0 + +# 数据库 +clickhouse-driver>=0.2.6 +elasticsearch>=7.0.0,<8.0.0 +sqlalchemy>=2.0.0 +pymysql>=1.1.0 + +# 训练工具 +tqdm>=4.65.0 + +# 可选: 可视化 +# matplotlib>=3.7.0 +# tensorboard>=2.14.0 diff --git a/ml/run_training.sh b/ml/run_training.sh new file mode 100644 index 00000000..7f7f2dcd --- /dev/null +++ b/ml/run_training.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# 概念异动检测模型训练脚本 (Linux) +# +# 使用方法: +# chmod +x run_training.sh +# ./run_training.sh +# +# 或指定参数: +# ./run_training.sh --start 2022-01-01 --epochs 100 + +set -e + +echo "============================================================" +echo "概念异动检测模型训练流程" +echo "============================================================" +echo "" + +# 获取脚本所在目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +echo "[1/4] 检查环境..." +python3 --version || { echo "Python3 未找到!"; exit 1; } + +# 检查 GPU +if python3 -c "import torch; print(f'CUDA: {torch.cuda.is_available()}')" 2>/dev/null; then + echo "PyTorch GPU 检测完成" +else + echo "警告: PyTorch 未安装或无法检测 GPU" +fi + +echo "" +echo "[2/4] 检查依赖..." +pip3 install -q torch pandas numpy pyarrow tqdm clickhouse-driver elasticsearch sqlalchemy pymysql + +echo "" +echo "[3/4] 准备训练数据..." +echo "从 ClickHouse 提取历史数据,这可能需要较长时间..." +echo "" + +# 解析参数 +START_DATE="2022-01-01" +END_DATE="" +EPOCHS=100 +BATCH_SIZE=256 +TRAIN_END="2025-06-30" +VAL_END="2025-09-30" + +while [[ $# -gt 0 ]]; do + case $1 in + --start) + START_DATE="$2" + shift 2 + ;; + --end) + END_DATE="$2" + shift 2 + ;; + --epochs) + EPOCHS="$2" + shift 2 + ;; + --batch_size) + BATCH_SIZE="$2" + shift 2 + ;; + --train_end) + TRAIN_END="$2" + shift 2 + ;; + --val_end) + VAL_END="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +# 数据准备 +if [ -n "$END_DATE" ]; then + python3 ml/prepare_data.py --start "$START_DATE" --end "$END_DATE" +else + python3 ml/prepare_data.py --start "$START_DATE" +fi + +echo "" +echo "[4/4] 训练模型..." +echo "使用 GPU 加速训练..." +echo "" + +python3 ml/train.py --epochs "$EPOCHS" --batch_size "$BATCH_SIZE" --train_end "$TRAIN_END" --val_end "$VAL_END" + +echo "" +echo "============================================================" +echo "训练完成!" +echo "模型保存在: ml/checkpoints/" +echo "============================================================" diff --git a/ml/train.py b/ml/train.py new file mode 100644 index 00000000..b93120ac --- /dev/null +++ b/ml/train.py @@ -0,0 +1,808 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Transformer Autoencoder 训练脚本 (修复版) + +修复问题: +1. 按概念分组构建序列,避免跨概念切片 +2. 按时间(日期)切分数据集,避免数据泄露 +3. 使用 RobustScaler + Clipping 处理非平稳性 +4. 使用验证集计算阈值 + +训练流程: +1. 加载预处理好的特征数据(parquet 文件) +2. 按概念分组,在每个概念内部构建序列 +3. 按日期划分训练/验证/测试集 +4. 训练 Autoencoder(最小化重构误差) +5. 保存模型和阈值 + +使用方法: + python train.py --data_dir ml/data --epochs 100 --batch_size 256 +""" + +import os +import sys +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import List, Tuple, Dict + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +from torch.utils.data import Dataset, DataLoader +from torch.optim import AdamW +from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts +from tqdm import tqdm + +from model import TransformerAutoencoder, AnomalyDetectionLoss, count_parameters + +# 性能优化:启用 cuDNN benchmark(对固定输入尺寸自动选择最快算法) +torch.backends.cudnn.benchmark = True +# 启用 TF32(RTX 30/40 系列特有,提速约 3 倍) +torch.backends.cuda.matmul.allow_tf32 = True +torch.backends.cudnn.allow_tf32 = True + +# 可视化(可选) +try: + import matplotlib + matplotlib.use('Agg') # 无头模式,不需要显示器 + import matplotlib.pyplot as plt + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + + +# ==================== 配置 ==================== + +TRAIN_CONFIG = { + # 数据配置 + 'seq_len': 30, # 输入序列长度(30分钟) + 'stride': 5, # 滑动窗口步长 + + # 时间切分(按日期) + 'train_end_date': '2024-06-30', # 训练集截止日期 + 'val_end_date': '2024-09-30', # 验证集截止日期(之后为测试集) + + # 特征配置 + 'features': [ + 'alpha', # 超额收益 + 'alpha_delta', # Alpha 变化率 + 'amt_ratio', # 成交额比率 + 'amt_delta', # 成交额变化率 + 'rank_pct', # Alpha 排名百分位 + 'limit_up_ratio', # 涨停比例 + ], + + # 训练配置(针对 4x RTX 4090 优化) + 'batch_size': 4096, # 256 -> 4096(大幅增加,充分利用显存) + 'epochs': 100, + 'learning_rate': 3e-4, # 1e-4 -> 3e-4(大 batch 需要更大学习率) + 'weight_decay': 1e-5, + 'gradient_clip': 1.0, + + # 早停配置 + 'patience': 10, + 'min_delta': 1e-6, + + # 模型配置(LSTM Autoencoder,简洁有效) + 'model': { + 'n_features': 6, + 'hidden_dim': 32, # LSTM 隐藏维度(小) + 'latent_dim': 4, # 瓶颈维度(非常小!关键) + 'num_layers': 1, # LSTM 层数 + 'dropout': 0.2, + 'bidirectional': True, # 双向编码器 + }, + + # 标准化配置 + 'use_instance_norm': True, # 模型内部使用 Instance Norm(推荐) + 'clip_value': 10.0, # 简单截断极端值 + + # 阈值配置 + 'threshold_percentiles': [90, 95, 99], +} + + +# ==================== 数据加载(修复版)==================== + +def load_data_by_date(data_dir: str, features: List[str]) -> Dict[str, pd.DataFrame]: + """ + 按日期加载数据,返回 {date: DataFrame} 字典 + + 每个 DataFrame 包含该日所有概念的所有时间点数据 + """ + data_path = Path(data_dir) + parquet_files = sorted(data_path.glob("features_*.parquet")) + + if not parquet_files: + raise FileNotFoundError(f"未找到 parquet 文件: {data_dir}") + + print(f"找到 {len(parquet_files)} 个数据文件") + + date_data = {} + + for pf in tqdm(parquet_files, desc="加载数据"): + # 提取日期 + date = pf.stem.replace('features_', '') + + df = pd.read_parquet(pf) + + # 检查必要列 + required_cols = features + ['concept_id', 'timestamp'] + missing_cols = [c for c in required_cols if c not in df.columns] + if missing_cols: + print(f"警告: {date} 缺少列: {missing_cols}, 跳过") + continue + + date_data[date] = df + + print(f"成功加载 {len(date_data)} 天的数据") + return date_data + + +def split_data_by_date( + date_data: Dict[str, pd.DataFrame], + train_end: str, + val_end: str +) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]: + """ + 按日期严格划分数据集 + + - 训练集: <= train_end + - 验证集: train_end < date <= val_end + - 测试集: > val_end + """ + train_data = {} + val_data = {} + test_data = {} + + for date, df in date_data.items(): + if date <= train_end: + train_data[date] = df + elif date <= val_end: + val_data[date] = df + else: + test_data[date] = df + + print(f"数据集划分(按日期):") + print(f" 训练集: {len(train_data)} 天 (<= {train_end})") + print(f" 验证集: {len(val_data)} 天 ({train_end} ~ {val_end})") + print(f" 测试集: {len(test_data)} 天 (> {val_end})") + + return train_data, val_data, test_data + + +def build_sequences_by_concept( + date_data: Dict[str, pd.DataFrame], + features: List[str], + seq_len: int, + stride: int +) -> np.ndarray: + """ + 按概念分组构建序列(性能优化版) + + 使用 groupby 一次性分组,避免重复扫描大数组 + + 1. 将所有日期的数据合并 + 2. 使用 groupby 按 concept_id 分组 + 3. 在每个概念内部,按时间排序并滑动窗口 + 4. 合并所有序列 + """ + # 合并所有日期的数据 + all_dfs = [] + for date, df in sorted(date_data.items()): + df = df.copy() + df['date'] = date + all_dfs.append(df) + + if not all_dfs: + return np.array([]) + + combined = pd.concat(all_dfs, ignore_index=True) + + # 预先排序(按概念、日期、时间),这样 groupby 会更快 + combined = combined.sort_values(['concept_id', 'date', 'timestamp']) + + # 使用 groupby 一次性分组(性能关键!) + all_sequences = [] + grouped = combined.groupby('concept_id', sort=False) + n_concepts = len(grouped) + + for concept_id, concept_df in tqdm(grouped, desc="构建序列", total=n_concepts, leave=False): + # 已经排序过了,直接提取特征 + feature_data = concept_df[features].values + + # 处理缺失值 + feature_data = np.nan_to_num(feature_data, nan=0.0, posinf=0.0, neginf=0.0) + + # 在该概念内部滑动窗口 + n_points = len(feature_data) + for start in range(0, n_points - seq_len + 1, stride): + seq = feature_data[start:start + seq_len] + all_sequences.append(seq) + + if not all_sequences: + return np.array([]) + + sequences = np.array(all_sequences) + print(f" 构建序列: {len(sequences):,} 条 (来自 {n_concepts} 个概念)") + + return sequences + + +# ==================== 数据集 ==================== + +class SequenceDataset(Dataset): + """序列数据集(已经构建好的序列)""" + + def __init__(self, sequences: np.ndarray): + self.sequences = torch.FloatTensor(sequences) + + def __len__(self) -> int: + return len(self.sequences) + + def __getitem__(self, idx: int) -> torch.Tensor: + return self.sequences[idx] + + +# ==================== 训练器 ==================== + +class EarlyStopping: + """早停机制""" + + def __init__(self, patience: int = 10, min_delta: float = 1e-6): + self.patience = patience + self.min_delta = min_delta + self.counter = 0 + self.best_loss = float('inf') + self.early_stop = False + + def __call__(self, val_loss: float) -> bool: + if val_loss < self.best_loss - self.min_delta: + self.best_loss = val_loss + self.counter = 0 + else: + self.counter += 1 + if self.counter >= self.patience: + self.early_stop = True + + return self.early_stop + + +class Trainer: + """模型训练器(支持 AMP 混合精度加速)""" + + def __init__( + self, + model: nn.Module, + train_loader: DataLoader, + val_loader: DataLoader, + config: Dict, + device: torch.device, + save_dir: str = 'ml/checkpoints' + ): + self.model = model.to(device) + self.train_loader = train_loader + self.val_loader = val_loader + self.config = config + self.device = device + self.save_dir = Path(save_dir) + self.save_dir.mkdir(parents=True, exist_ok=True) + + # 优化器 + self.optimizer = AdamW( + model.parameters(), + lr=config['learning_rate'], + weight_decay=config['weight_decay'] + ) + + # 学习率调度器 + self.scheduler = CosineAnnealingWarmRestarts( + self.optimizer, + T_0=10, + T_mult=2, + eta_min=1e-6 + ) + + # 损失函数(简化版,只用 MSE) + self.criterion = AnomalyDetectionLoss() + + # 早停 + self.early_stopping = EarlyStopping( + patience=config['patience'], + min_delta=config['min_delta'] + ) + + # AMP 混合精度训练(大幅提速 + 省显存) + self.use_amp = torch.cuda.is_available() + self.scaler = torch.cuda.amp.GradScaler() if self.use_amp else None + if self.use_amp: + print(" ✓ 启用 AMP 混合精度训练") + + # 训练历史 + self.history = { + 'train_loss': [], + 'val_loss': [], + 'learning_rate': [], + } + + self.best_val_loss = float('inf') + + def train_epoch(self) -> float: + """训练一个 epoch(使用 AMP 混合精度)""" + self.model.train() + total_loss = 0.0 + n_batches = 0 + + pbar = tqdm(self.train_loader, desc="Training", leave=False) + for batch in pbar: + batch = batch.to(self.device, non_blocking=True) # 异步传输 + + self.optimizer.zero_grad(set_to_none=True) # 更快的梯度清零 + + # AMP 混合精度前向传播 + if self.use_amp: + with torch.cuda.amp.autocast(): + output, latent = self.model(batch) + loss, loss_dict = self.criterion(output, batch, latent) + + # AMP 反向传播 + self.scaler.scale(loss).backward() + + # 梯度裁剪(需要 unscale) + self.scaler.unscale_(self.optimizer) + torch.nn.utils.clip_grad_norm_( + self.model.parameters(), + self.config['gradient_clip'] + ) + + self.scaler.step(self.optimizer) + self.scaler.update() + else: + # 非 AMP 模式 + output, latent = self.model(batch) + loss, loss_dict = self.criterion(output, batch, latent) + + loss.backward() + torch.nn.utils.clip_grad_norm_( + self.model.parameters(), + self.config['gradient_clip'] + ) + self.optimizer.step() + + total_loss += loss.item() + n_batches += 1 + + pbar.set_postfix({'loss': f"{loss.item():.4f}"}) + + return total_loss / n_batches + + @torch.no_grad() + def validate(self) -> float: + """验证(使用 AMP)""" + self.model.eval() + total_loss = 0.0 + n_batches = 0 + + for batch in self.val_loader: + batch = batch.to(self.device, non_blocking=True) + + if self.use_amp: + with torch.cuda.amp.autocast(): + output, latent = self.model(batch) + loss, _ = self.criterion(output, batch, latent) + else: + output, latent = self.model(batch) + loss, _ = self.criterion(output, batch, latent) + + total_loss += loss.item() + n_batches += 1 + + return total_loss / n_batches + + def save_checkpoint(self, epoch: int, val_loss: float, is_best: bool = False): + """保存检查点""" + # 处理 DataParallel 包装 + model_to_save = self.model.module if hasattr(self.model, 'module') else self.model + + checkpoint = { + 'epoch': epoch, + 'model_state_dict': model_to_save.state_dict(), + 'optimizer_state_dict': self.optimizer.state_dict(), + 'scheduler_state_dict': self.scheduler.state_dict(), + 'val_loss': val_loss, + 'config': self.config, + } + + # 保存最新检查点 + torch.save(checkpoint, self.save_dir / 'last_checkpoint.pt') + + # 保存最佳模型 + if is_best: + torch.save(checkpoint, self.save_dir / 'best_model.pt') + print(f" ✓ 保存最佳模型 (val_loss: {val_loss:.6f})") + + def train(self, epochs: int): + """完整训练流程""" + print(f"\n开始训练 ({epochs} epochs)...") + print(f"设备: {self.device}") + print(f"模型参数量: {count_parameters(self.model):,}") + + for epoch in range(1, epochs + 1): + print(f"\nEpoch {epoch}/{epochs}") + + # 训练 + train_loss = self.train_epoch() + + # 验证 + val_loss = self.validate() + + # 更新学习率 + self.scheduler.step() + current_lr = self.optimizer.param_groups[0]['lr'] + + # 记录历史 + self.history['train_loss'].append(train_loss) + self.history['val_loss'].append(val_loss) + self.history['learning_rate'].append(current_lr) + + # 打印进度 + print(f" Train Loss: {train_loss:.6f}") + print(f" Val Loss: {val_loss:.6f}") + print(f" LR: {current_lr:.2e}") + + # 保存检查点 + is_best = val_loss < self.best_val_loss + if is_best: + self.best_val_loss = val_loss + self.save_checkpoint(epoch, val_loss, is_best) + + # 早停检查 + if self.early_stopping(val_loss): + print(f"\n早停触发!验证损失已 {self.early_stopping.patience} 个 epoch 未改善") + break + + print(f"\n训练完成!最佳验证损失: {self.best_val_loss:.6f}") + + # 保存训练历史 + self.save_history() + + return self.history + + def save_history(self): + """保存训练历史""" + history_path = self.save_dir / 'training_history.json' + with open(history_path, 'w') as f: + json.dump(self.history, f, indent=2) + print(f"训练历史已保存: {history_path}") + + # 绘制训练曲线 + self.plot_training_curves() + + def plot_training_curves(self): + """绘制训练曲线""" + if not HAS_MATPLOTLIB: + print("matplotlib 未安装,跳过绘图") + return + + fig, axes = plt.subplots(1, 2, figsize=(14, 5)) + + epochs = range(1, len(self.history['train_loss']) + 1) + + # 1. Loss 曲线 + ax1 = axes[0] + ax1.plot(epochs, self.history['train_loss'], 'b-', label='Train Loss', linewidth=2) + ax1.plot(epochs, self.history['val_loss'], 'r-', label='Val Loss', linewidth=2) + ax1.set_xlabel('Epoch', fontsize=12) + ax1.set_ylabel('Loss', fontsize=12) + ax1.set_title('Training & Validation Loss', fontsize=14) + ax1.legend(fontsize=11) + ax1.grid(True, alpha=0.3) + + # 标记最佳点 + best_epoch = np.argmin(self.history['val_loss']) + 1 + best_val_loss = min(self.history['val_loss']) + ax1.axvline(x=best_epoch, color='g', linestyle='--', alpha=0.7, label=f'Best Epoch: {best_epoch}') + ax1.scatter([best_epoch], [best_val_loss], color='g', s=100, zorder=5) + ax1.annotate(f'Best: {best_val_loss:.6f}', xy=(best_epoch, best_val_loss), + xytext=(best_epoch + 2, best_val_loss + 0.0005), + fontsize=10, color='green') + + # 2. 学习率曲线 + ax2 = axes[1] + ax2.plot(epochs, self.history['learning_rate'], 'g-', linewidth=2) + ax2.set_xlabel('Epoch', fontsize=12) + ax2.set_ylabel('Learning Rate', fontsize=12) + ax2.set_title('Learning Rate Schedule', fontsize=14) + ax2.set_yscale('log') + ax2.grid(True, alpha=0.3) + + plt.tight_layout() + + # 保存图片 + plot_path = self.save_dir / 'training_curves.png' + plt.savefig(plot_path, dpi=150, bbox_inches='tight') + plt.close() + print(f"训练曲线已保存: {plot_path}") + + +# ==================== 阈值计算(使用验证集)==================== + +@torch.no_grad() +def compute_thresholds( + model: nn.Module, + data_loader: DataLoader, + device: torch.device, + percentiles: List[float] = [90, 95, 99] +) -> Dict[str, float]: + """ + 在验证集上计算重构误差的百分位数阈值 + + 注:使用验证集而非测试集,避免数据泄露 + """ + model.eval() + all_errors = [] + + print("计算异动阈值(使用验证集)...") + for batch in tqdm(data_loader, desc="Computing thresholds"): + batch = batch.to(device) + errors = model.compute_reconstruction_error(batch, reduction='none') + + # 取每个序列的最后一个时刻误差(预测当前时刻) + seq_errors = errors[:, -1] # (batch,) + all_errors.append(seq_errors.cpu().numpy()) + + all_errors = np.concatenate(all_errors) + + thresholds = {} + for p in percentiles: + threshold = np.percentile(all_errors, p) + thresholds[f'p{p}'] = float(threshold) + print(f" P{p}: {threshold:.6f}") + + # 额外统计 + thresholds['mean'] = float(np.mean(all_errors)) + thresholds['std'] = float(np.std(all_errors)) + thresholds['median'] = float(np.median(all_errors)) + + print(f" Mean: {thresholds['mean']:.6f}") + print(f" Median: {thresholds['median']:.6f}") + print(f" Std: {thresholds['std']:.6f}") + + return thresholds + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description='训练概念异动检测模型') + parser.add_argument('--data_dir', type=str, default='ml/data', + help='数据目录路径') + parser.add_argument('--epochs', type=int, default=100, + help='训练轮数') + parser.add_argument('--batch_size', type=int, default=4096, + help='批次大小(4x RTX 4090 推荐 4096~8192)') + parser.add_argument('--lr', type=float, default=3e-4, + help='学习率(大 batch 推荐 3e-4)') + parser.add_argument('--device', type=str, default='auto', + help='设备 (auto/cuda/cpu)') + parser.add_argument('--save_dir', type=str, default='ml/checkpoints', + help='模型保存目录') + parser.add_argument('--train_end', type=str, default='2024-06-30', + help='训练集截止日期') + parser.add_argument('--val_end', type=str, default='2024-09-30', + help='验证集截止日期') + + args = parser.parse_args() + + # 更新配置 + config = TRAIN_CONFIG.copy() + config['batch_size'] = args.batch_size + config['epochs'] = args.epochs + config['learning_rate'] = args.lr + config['train_end_date'] = args.train_end + config['val_end_date'] = args.val_end + + # 设备选择 + if args.device == 'auto': + device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + else: + device = torch.device(args.device) + + print("=" * 60) + print("概念异动检测模型训练(修复版)") + print("=" * 60) + print(f"配置:") + print(f" 数据目录: {args.data_dir}") + print(f" 设备: {device}") + print(f" 批次大小: {config['batch_size']}") + print(f" 学习率: {config['learning_rate']}") + print(f" 训练轮数: {config['epochs']}") + print(f" 训练集截止: {config['train_end_date']}") + print(f" 验证集截止: {config['val_end_date']}") + print("=" * 60) + + # 1. 按日期加载数据 + print("\n[1/6] 加载数据...") + date_data = load_data_by_date(args.data_dir, config['features']) + + # 2. 按日期划分 + print("\n[2/6] 按日期划分数据集...") + train_data, val_data, test_data = split_data_by_date( + date_data, + config['train_end_date'], + config['val_end_date'] + ) + + # 3. 按概念构建序列 + print("\n[3/6] 按概念构建序列...") + print("训练集:") + train_sequences = build_sequences_by_concept( + train_data, config['features'], config['seq_len'], config['stride'] + ) + print("验证集:") + val_sequences = build_sequences_by_concept( + val_data, config['features'], config['seq_len'], config['stride'] + ) + print("测试集:") + test_sequences = build_sequences_by_concept( + test_data, config['features'], config['seq_len'], config['stride'] + ) + + if len(train_sequences) == 0: + print("错误: 训练集为空!请检查数据和日期范围") + return + + # 4. 数据预处理(简单截断极端值,标准化在模型内部通过 Instance Norm 完成) + print("\n[4/6] 数据预处理...") + print(" 注意: 使用 Instance Norm,每个序列在模型内部单独标准化") + print(" 这样可以处理不同概念波动率差异(银行 vs 半导体)") + + clip_value = config['clip_value'] + print(f" 截断极端值: ±{clip_value}") + + # 简单截断极端值(防止异常数据影响训练) + train_sequences = np.clip(train_sequences, -clip_value, clip_value) + if len(val_sequences) > 0: + val_sequences = np.clip(val_sequences, -clip_value, clip_value) + if len(test_sequences) > 0: + test_sequences = np.clip(test_sequences, -clip_value, clip_value) + + # 保存配置 + save_dir = Path(args.save_dir) + save_dir.mkdir(parents=True, exist_ok=True) + + preprocess_params = { + 'features': config['features'], + 'normalization': 'instance_norm', # 在模型内部完成 + 'clip_value': clip_value, + 'note': '标准化在模型内部通过 InstanceNorm1d 完成,无需外部 Scaler' + } + + with open(save_dir / 'normalization_stats.json', 'w') as f: + json.dump(preprocess_params, f, indent=2) + print(f" 预处理参数已保存") + + # 5. 创建数据集和加载器 + print("\n[5/6] 创建数据加载器...") + train_dataset = SequenceDataset(train_sequences) + val_dataset = SequenceDataset(val_sequences) if len(val_sequences) > 0 else None + test_dataset = SequenceDataset(test_sequences) if len(test_sequences) > 0 else None + + print(f" 训练序列: {len(train_dataset):,}") + print(f" 验证序列: {len(val_dataset) if val_dataset else 0:,}") + print(f" 测试序列: {len(test_dataset) if test_dataset else 0:,}") + + # 多卡时增加 num_workers(Linux 上可以用更多) + n_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1 + num_workers = min(32, 8 * n_gpus) if sys.platform != 'win32' else 0 + print(f" DataLoader workers: {num_workers}") + print(f" Batch size: {config['batch_size']}") + + # 大 batch + 多 worker + prefetch 提速 + train_loader = DataLoader( + train_dataset, + batch_size=config['batch_size'], + shuffle=True, + num_workers=num_workers, + pin_memory=True, + prefetch_factor=4 if num_workers > 0 else None, # 预取更多 batch + persistent_workers=True if num_workers > 0 else False, # 保持 worker 存活 + drop_last=True # 丢弃不完整的最后一批,避免 batch 大小不一致 + ) + + val_loader = DataLoader( + val_dataset, + batch_size=config['batch_size'] * 2, # 验证时可以用更大 batch(无梯度) + shuffle=False, + num_workers=num_workers, + pin_memory=True, + prefetch_factor=4 if num_workers > 0 else None, + persistent_workers=True if num_workers > 0 else False, + ) if val_dataset else None + + test_loader = DataLoader( + test_dataset, + batch_size=config['batch_size'] * 2, + shuffle=False, + num_workers=num_workers, + pin_memory=True, + prefetch_factor=4 if num_workers > 0 else None, + persistent_workers=True if num_workers > 0 else False, + ) if test_dataset else None + + # 6. 训练 + print("\n[6/6] 训练模型...") + model_config = config['model'].copy() + model = TransformerAutoencoder(**model_config) + + # 多卡并行 + if torch.cuda.device_count() > 1: + print(f" 使用 {torch.cuda.device_count()} 张 GPU 并行训练") + model = nn.DataParallel(model) + + if val_loader is None: + print("警告: 验证集为空,将使用训练集的一部分作为验证") + # 简单处理:用训练集的后 10% 作为验证 + split_idx = int(len(train_dataset) * 0.9) + train_subset = torch.utils.data.Subset(train_dataset, range(split_idx)) + val_subset = torch.utils.data.Subset(train_dataset, range(split_idx, len(train_dataset))) + + train_loader = DataLoader(train_subset, batch_size=config['batch_size'], shuffle=True, num_workers=num_workers, pin_memory=True) + val_loader = DataLoader(val_subset, batch_size=config['batch_size'], shuffle=False, num_workers=num_workers, pin_memory=True) + + trainer = Trainer( + model=model, + train_loader=train_loader, + val_loader=val_loader, + config=config, + device=device, + save_dir=args.save_dir + ) + + history = trainer.train(config['epochs']) + + # 7. 计算阈值(使用验证集) + print("\n[额外] 计算异动阈值...") + + # 加载最佳模型 + best_checkpoint = torch.load( + save_dir / 'best_model.pt', + map_location=device + ) + model.load_state_dict(best_checkpoint['model_state_dict']) + model.to(device) + + # 使用验证集计算阈值(避免数据泄露) + thresholds = compute_thresholds( + model, + val_loader, + device, + config['threshold_percentiles'] + ) + + # 保存阈值 + with open(save_dir / 'thresholds.json', 'w') as f: + json.dump(thresholds, f, indent=2) + print(f"阈值已保存") + + # 保存完整配置 + with open(save_dir / 'config.json', 'w') as f: + json.dump(config, f, indent=2) + + print("\n" + "=" * 60) + print("训练完成!") + print("=" * 60) + print(f"模型保存位置: {args.save_dir}") + print(f" - best_model.pt: 最佳模型权重") + print(f" - thresholds.json: 异动阈值") + print(f" - normalization_stats.json: 标准化参数") + print(f" - config.json: 训练配置") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/ml/train_v2.py b/ml/train_v2.py new file mode 100644 index 00000000..47e6cdb7 --- /dev/null +++ b/ml/train_v2.py @@ -0,0 +1,622 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +训练脚本 V2 - 基于 Z-Score 特征的 LSTM Autoencoder + +改进点: +1. 使用 Z-Score 特征(相对于同时间片历史的偏离) +2. 短序列:10分钟(不需要30分钟预热) +3. 开盘即可检测:9:30 直接有特征 + +模型输入: +- 过去10分钟的 Z-Score 特征序列 +- 特征:alpha_zscore, amt_zscore, rank_zscore, momentum_3m, momentum_5m, limit_up_ratio + +模型学习: +- 学习 Z-Score 序列的"正常演化模式" +- 异动 = Z-Score 序列的异常演化(重构误差大) +""" + +import os +import sys +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import List, Tuple, Dict + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +from torch.utils.data import Dataset, DataLoader +from torch.optim import AdamW +from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts +from tqdm import tqdm + +from model import TransformerAutoencoder, AnomalyDetectionLoss, count_parameters + +# 性能优化 +torch.backends.cudnn.benchmark = True +torch.backends.cuda.matmul.allow_tf32 = True +torch.backends.cudnn.allow_tf32 = True + +try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + + +# ==================== 配置 ==================== + +TRAIN_CONFIG = { + # 数据配置(改进!) + 'seq_len': 10, # 10分钟序列(不是30分钟!) + 'stride': 2, # 步长2分钟 + + # 时间切分 + 'train_end_date': '2024-06-30', + 'val_end_date': '2024-09-30', + + # V2 特征(Z-Score 为主) + 'features': [ + 'alpha_zscore', # Alpha 的 Z-Score + 'amt_zscore', # 成交额的 Z-Score + 'rank_zscore', # 排名的 Z-Score + 'momentum_3m', # 3分钟动量 + 'momentum_5m', # 5分钟动量 + 'limit_up_ratio', # 涨停占比 + ], + + # 训练配置 + 'batch_size': 4096, + 'epochs': 100, + 'learning_rate': 3e-4, + 'weight_decay': 1e-5, + 'gradient_clip': 1.0, + + # 早停配置 + 'patience': 15, + 'min_delta': 1e-6, + + # 模型配置(小型 LSTM) + 'model': { + 'n_features': 6, + 'hidden_dim': 32, + 'latent_dim': 4, + 'num_layers': 1, + 'dropout': 0.2, + 'bidirectional': True, + }, + + # 标准化配置 + 'clip_value': 5.0, # Z-Score 已经标准化,clip 5.0 足够 + + # 阈值配置 + 'threshold_percentiles': [90, 95, 99], +} + + +# ==================== 数据加载 ==================== + +def load_data_by_date(data_dir: str, features: List[str]) -> Dict[str, pd.DataFrame]: + """按日期加载 V2 数据""" + data_path = Path(data_dir) + parquet_files = sorted(data_path.glob("features_v2_*.parquet")) + + if not parquet_files: + raise FileNotFoundError(f"未找到 V2 数据文件: {data_dir}") + + print(f"找到 {len(parquet_files)} 个 V2 数据文件") + + date_data = {} + + for pf in tqdm(parquet_files, desc="加载数据"): + date = pf.stem.replace('features_v2_', '') + + df = pd.read_parquet(pf) + + required_cols = features + ['concept_id', 'timestamp'] + missing_cols = [c for c in required_cols if c not in df.columns] + if missing_cols: + print(f"警告: {date} 缺少列: {missing_cols}, 跳过") + continue + + date_data[date] = df + + print(f"成功加载 {len(date_data)} 天的数据") + return date_data + + +def split_data_by_date( + date_data: Dict[str, pd.DataFrame], + train_end: str, + val_end: str +) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]: + """按日期划分数据集""" + train_data = {} + val_data = {} + test_data = {} + + for date, df in date_data.items(): + if date <= train_end: + train_data[date] = df + elif date <= val_end: + val_data[date] = df + else: + test_data[date] = df + + print(f"数据集划分:") + print(f" 训练集: {len(train_data)} 天 (<= {train_end})") + print(f" 验证集: {len(val_data)} 天 ({train_end} ~ {val_end})") + print(f" 测试集: {len(test_data)} 天 (> {val_end})") + + return train_data, val_data, test_data + + +def build_sequences_by_concept( + date_data: Dict[str, pd.DataFrame], + features: List[str], + seq_len: int, + stride: int +) -> np.ndarray: + """按概念分组构建序列""" + all_dfs = [] + for date, df in sorted(date_data.items()): + df = df.copy() + df['date'] = date + all_dfs.append(df) + + if not all_dfs: + return np.array([]) + + combined = pd.concat(all_dfs, ignore_index=True) + combined = combined.sort_values(['concept_id', 'date', 'timestamp']) + + all_sequences = [] + grouped = combined.groupby('concept_id', sort=False) + n_concepts = len(grouped) + + for concept_id, concept_df in tqdm(grouped, desc="构建序列", total=n_concepts, leave=False): + feature_data = concept_df[features].values + feature_data = np.nan_to_num(feature_data, nan=0.0, posinf=0.0, neginf=0.0) + + n_points = len(feature_data) + for start in range(0, n_points - seq_len + 1, stride): + seq = feature_data[start:start + seq_len] + all_sequences.append(seq) + + if not all_sequences: + return np.array([]) + + sequences = np.array(all_sequences) + print(f" 构建序列: {len(sequences):,} 条 (来自 {n_concepts} 个概念)") + + return sequences + + +# ==================== 数据集 ==================== + +class SequenceDataset(Dataset): + def __init__(self, sequences: np.ndarray): + self.sequences = torch.FloatTensor(sequences) + + def __len__(self) -> int: + return len(self.sequences) + + def __getitem__(self, idx: int) -> torch.Tensor: + return self.sequences[idx] + + +# ==================== 训练器 ==================== + +class EarlyStopping: + def __init__(self, patience: int = 10, min_delta: float = 1e-6): + self.patience = patience + self.min_delta = min_delta + self.counter = 0 + self.best_loss = float('inf') + self.early_stop = False + + def __call__(self, val_loss: float) -> bool: + if val_loss < self.best_loss - self.min_delta: + self.best_loss = val_loss + self.counter = 0 + else: + self.counter += 1 + if self.counter >= self.patience: + self.early_stop = True + return self.early_stop + + +class Trainer: + def __init__( + self, + model: nn.Module, + train_loader: DataLoader, + val_loader: DataLoader, + config: Dict, + device: torch.device, + save_dir: str = 'ml/checkpoints_v2' + ): + self.model = model.to(device) + self.train_loader = train_loader + self.val_loader = val_loader + self.config = config + self.device = device + self.save_dir = Path(save_dir) + self.save_dir.mkdir(parents=True, exist_ok=True) + + self.optimizer = AdamW( + model.parameters(), + lr=config['learning_rate'], + weight_decay=config['weight_decay'] + ) + + self.scheduler = CosineAnnealingWarmRestarts( + self.optimizer, T_0=10, T_mult=2, eta_min=1e-6 + ) + + self.criterion = AnomalyDetectionLoss() + + self.early_stopping = EarlyStopping( + patience=config['patience'], + min_delta=config['min_delta'] + ) + + self.use_amp = torch.cuda.is_available() + self.scaler = torch.cuda.amp.GradScaler() if self.use_amp else None + if self.use_amp: + print(" ✓ 启用 AMP 混合精度训练") + + self.history = {'train_loss': [], 'val_loss': [], 'learning_rate': []} + self.best_val_loss = float('inf') + + def train_epoch(self) -> float: + self.model.train() + total_loss = 0.0 + n_batches = 0 + + pbar = tqdm(self.train_loader, desc="Training", leave=False) + for batch in pbar: + batch = batch.to(self.device, non_blocking=True) + self.optimizer.zero_grad(set_to_none=True) + + if self.use_amp: + with torch.cuda.amp.autocast(): + output, latent = self.model(batch) + loss, _ = self.criterion(output, batch, latent) + + self.scaler.scale(loss).backward() + self.scaler.unscale_(self.optimizer) + torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.config['gradient_clip']) + self.scaler.step(self.optimizer) + self.scaler.update() + else: + output, latent = self.model(batch) + loss, _ = self.criterion(output, batch, latent) + loss.backward() + torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.config['gradient_clip']) + self.optimizer.step() + + total_loss += loss.item() + n_batches += 1 + pbar.set_postfix({'loss': f"{loss.item():.4f}"}) + + return total_loss / n_batches + + @torch.no_grad() + def validate(self) -> float: + self.model.eval() + total_loss = 0.0 + n_batches = 0 + + for batch in self.val_loader: + batch = batch.to(self.device, non_blocking=True) + + if self.use_amp: + with torch.cuda.amp.autocast(): + output, latent = self.model(batch) + loss, _ = self.criterion(output, batch, latent) + else: + output, latent = self.model(batch) + loss, _ = self.criterion(output, batch, latent) + + total_loss += loss.item() + n_batches += 1 + + return total_loss / n_batches + + def save_checkpoint(self, epoch: int, val_loss: float, is_best: bool = False): + model_to_save = self.model.module if hasattr(self.model, 'module') else self.model + + checkpoint = { + 'epoch': epoch, + 'model_state_dict': model_to_save.state_dict(), + 'optimizer_state_dict': self.optimizer.state_dict(), + 'scheduler_state_dict': self.scheduler.state_dict(), + 'val_loss': val_loss, + 'config': self.config, + } + + torch.save(checkpoint, self.save_dir / 'last_checkpoint.pt') + + if is_best: + torch.save(checkpoint, self.save_dir / 'best_model.pt') + print(f" ✓ 保存最佳模型 (val_loss: {val_loss:.6f})") + + def train(self, epochs: int): + print(f"\n开始训练 ({epochs} epochs)...") + print(f"设备: {self.device}") + print(f"模型参数量: {count_parameters(self.model):,}") + + for epoch in range(1, epochs + 1): + print(f"\nEpoch {epoch}/{epochs}") + + train_loss = self.train_epoch() + val_loss = self.validate() + + self.scheduler.step() + current_lr = self.optimizer.param_groups[0]['lr'] + + self.history['train_loss'].append(train_loss) + self.history['val_loss'].append(val_loss) + self.history['learning_rate'].append(current_lr) + + print(f" Train Loss: {train_loss:.6f}") + print(f" Val Loss: {val_loss:.6f}") + print(f" LR: {current_lr:.2e}") + + is_best = val_loss < self.best_val_loss + if is_best: + self.best_val_loss = val_loss + self.save_checkpoint(epoch, val_loss, is_best) + + if self.early_stopping(val_loss): + print(f"\n早停触发!") + break + + print(f"\n训练完成!最佳验证损失: {self.best_val_loss:.6f}") + self.save_history() + + return self.history + + def save_history(self): + history_path = self.save_dir / 'training_history.json' + with open(history_path, 'w') as f: + json.dump(self.history, f, indent=2) + print(f"训练历史已保存: {history_path}") + + if HAS_MATPLOTLIB: + self.plot_training_curves() + + def plot_training_curves(self): + fig, axes = plt.subplots(1, 2, figsize=(14, 5)) + epochs = range(1, len(self.history['train_loss']) + 1) + + ax1 = axes[0] + ax1.plot(epochs, self.history['train_loss'], 'b-', label='Train Loss', linewidth=2) + ax1.plot(epochs, self.history['val_loss'], 'r-', label='Val Loss', linewidth=2) + ax1.set_xlabel('Epoch') + ax1.set_ylabel('Loss') + ax1.set_title('Training & Validation Loss (V2)') + ax1.legend() + ax1.grid(True, alpha=0.3) + + best_epoch = np.argmin(self.history['val_loss']) + 1 + best_val_loss = min(self.history['val_loss']) + ax1.axvline(x=best_epoch, color='g', linestyle='--', alpha=0.7) + ax1.scatter([best_epoch], [best_val_loss], color='g', s=100, zorder=5) + + ax2 = axes[1] + ax2.plot(epochs, self.history['learning_rate'], 'g-', linewidth=2) + ax2.set_xlabel('Epoch') + ax2.set_ylabel('Learning Rate') + ax2.set_title('Learning Rate Schedule') + ax2.set_yscale('log') + ax2.grid(True, alpha=0.3) + + plt.tight_layout() + plt.savefig(self.save_dir / 'training_curves.png', dpi=150, bbox_inches='tight') + plt.close() + print(f"训练曲线已保存") + + +# ==================== 阈值计算 ==================== + +@torch.no_grad() +def compute_thresholds( + model: nn.Module, + data_loader: DataLoader, + device: torch.device, + percentiles: List[float] = [90, 95, 99] +) -> Dict[str, float]: + """在验证集上计算阈值""" + model.eval() + all_errors = [] + + print("计算异动阈值...") + for batch in tqdm(data_loader, desc="Computing thresholds"): + batch = batch.to(device) + errors = model.compute_reconstruction_error(batch, reduction='none') + seq_errors = errors[:, -1] # 最后一个时刻 + all_errors.append(seq_errors.cpu().numpy()) + + all_errors = np.concatenate(all_errors) + + thresholds = {} + for p in percentiles: + threshold = np.percentile(all_errors, p) + thresholds[f'p{p}'] = float(threshold) + print(f" P{p}: {threshold:.6f}") + + thresholds['mean'] = float(np.mean(all_errors)) + thresholds['std'] = float(np.std(all_errors)) + thresholds['median'] = float(np.median(all_errors)) + + return thresholds + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser(description='训练 V2 模型') + parser.add_argument('--data_dir', type=str, default='ml/data_v2', help='V2 数据目录') + parser.add_argument('--epochs', type=int, default=100) + parser.add_argument('--batch_size', type=int, default=4096) + parser.add_argument('--lr', type=float, default=3e-4) + parser.add_argument('--device', type=str, default='auto') + parser.add_argument('--save_dir', type=str, default='ml/checkpoints_v2') + parser.add_argument('--train_end', type=str, default='2024-06-30') + parser.add_argument('--val_end', type=str, default='2024-09-30') + parser.add_argument('--seq_len', type=int, default=10, help='序列长度(分钟)') + + args = parser.parse_args() + + config = TRAIN_CONFIG.copy() + config['batch_size'] = args.batch_size + config['epochs'] = args.epochs + config['learning_rate'] = args.lr + config['train_end_date'] = args.train_end + config['val_end_date'] = args.val_end + config['seq_len'] = args.seq_len + + if args.device == 'auto': + device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + else: + device = torch.device(args.device) + + print("=" * 60) + print("概念异动检测模型训练 V2(Z-Score 特征)") + print("=" * 60) + print(f"数据目录: {args.data_dir}") + print(f"设备: {device}") + print(f"序列长度: {config['seq_len']} 分钟") + print(f"批次大小: {config['batch_size']}") + print(f"特征: {config['features']}") + print("=" * 60) + + # 1. 加载数据 + print("\n[1/6] 加载 V2 数据...") + date_data = load_data_by_date(args.data_dir, config['features']) + + # 2. 划分数据集 + print("\n[2/6] 划分数据集...") + train_data, val_data, test_data = split_data_by_date( + date_data, config['train_end_date'], config['val_end_date'] + ) + + # 3. 构建序列 + print("\n[3/6] 构建序列...") + print("训练集:") + train_sequences = build_sequences_by_concept( + train_data, config['features'], config['seq_len'], config['stride'] + ) + print("验证集:") + val_sequences = build_sequences_by_concept( + val_data, config['features'], config['seq_len'], config['stride'] + ) + + if len(train_sequences) == 0: + print("错误: 训练集为空!") + return + + # 4. 预处理 + print("\n[4/6] 数据预处理...") + clip_value = config['clip_value'] + print(f" Z-Score 特征已标准化,截断: ±{clip_value}") + + train_sequences = np.clip(train_sequences, -clip_value, clip_value) + if len(val_sequences) > 0: + val_sequences = np.clip(val_sequences, -clip_value, clip_value) + + # 保存配置 + save_dir = Path(args.save_dir) + save_dir.mkdir(parents=True, exist_ok=True) + + with open(save_dir / 'config.json', 'w') as f: + json.dump(config, f, indent=2) + + # 5. 创建数据加载器 + print("\n[5/6] 创建数据加载器...") + train_dataset = SequenceDataset(train_sequences) + val_dataset = SequenceDataset(val_sequences) if len(val_sequences) > 0 else None + + print(f" 训练序列: {len(train_dataset):,}") + print(f" 验证序列: {len(val_dataset) if val_dataset else 0:,}") + + n_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1 + num_workers = min(32, 8 * n_gpus) if sys.platform != 'win32' else 0 + + train_loader = DataLoader( + train_dataset, + batch_size=config['batch_size'], + shuffle=True, + num_workers=num_workers, + pin_memory=True, + prefetch_factor=4 if num_workers > 0 else None, + persistent_workers=True if num_workers > 0 else False, + drop_last=True + ) + + val_loader = DataLoader( + val_dataset, + batch_size=config['batch_size'] * 2, + shuffle=False, + num_workers=num_workers, + pin_memory=True, + ) if val_dataset else None + + # 6. 训练 + print("\n[6/6] 训练模型...") + model = TransformerAutoencoder(**config['model']) + + if torch.cuda.device_count() > 1: + print(f" 使用 {torch.cuda.device_count()} 张 GPU 并行训练") + model = nn.DataParallel(model) + + if val_loader is None: + print("警告: 验证集为空,使用训练集的 10% 作为验证") + split_idx = int(len(train_dataset) * 0.9) + train_subset = torch.utils.data.Subset(train_dataset, range(split_idx)) + val_subset = torch.utils.data.Subset(train_dataset, range(split_idx, len(train_dataset))) + train_loader = DataLoader(train_subset, batch_size=config['batch_size'], shuffle=True, num_workers=num_workers, pin_memory=True) + val_loader = DataLoader(val_subset, batch_size=config['batch_size'], shuffle=False, num_workers=num_workers, pin_memory=True) + + trainer = Trainer( + model=model, + train_loader=train_loader, + val_loader=val_loader, + config=config, + device=device, + save_dir=args.save_dir + ) + + trainer.train(config['epochs']) + + # 计算阈值 + print("\n[额外] 计算异动阈值...") + best_checkpoint = torch.load(save_dir / 'best_model.pt', map_location=device) + + # 创建新的单 GPU 模型用于计算阈值(避免 DataParallel 问题) + threshold_model = TransformerAutoencoder(**config['model']) + threshold_model.load_state_dict(best_checkpoint['model_state_dict']) + threshold_model.to(device) + threshold_model.eval() + + thresholds = compute_thresholds(threshold_model, val_loader, device, config['threshold_percentiles']) + + with open(save_dir / 'thresholds.json', 'w') as f: + json.dump(thresholds, f, indent=2) + + print("\n" + "=" * 60) + print("训练完成!") + print(f"模型保存位置: {args.save_dir}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/ml/update_baseline.py b/ml/update_baseline.py new file mode 100644 index 00000000..7ee7e3cc --- /dev/null +++ b/ml/update_baseline.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +每日盘后运行:更新滚动基线 + +使用方法: + python ml/update_baseline.py + +建议加入 crontab,每天 15:30 后运行: + 30 15 * * 1-5 cd /path/to/project && python ml/update_baseline.py +""" + +import os +import sys +import pickle +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from pathlib import Path +from tqdm import tqdm + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ml.prepare_data_v2 import ( + get_all_concepts, get_trading_days, compute_raw_concept_features, + init_process_connections, CONFIG, RAW_CACHE_DIR, BASELINE_DIR +) + + +def update_rolling_baseline(baseline_days: int = 20): + """ + 更新滚动基线(用于实盘检测) + + 基线 = 最近 N 个交易日每个时间片的统计量 + """ + print("=" * 60) + print("更新滚动基线(用于实盘)") + print("=" * 60) + + # 初始化连接 + init_process_connections() + + # 获取概念列表 + concepts = get_all_concepts() + all_stocks = list(set(s for c in concepts for s in c['stocks'])) + + # 获取最近的交易日 + today = datetime.now().strftime('%Y-%m-%d') + start_date = (datetime.now() - timedelta(days=60)).strftime('%Y-%m-%d') # 多取一些 + + trading_days = get_trading_days(start_date, today) + + if len(trading_days) < baseline_days: + print(f"错误:交易日不足 {baseline_days} 天") + return + + # 只取最近 N 天 + recent_days = trading_days[-baseline_days:] + print(f"使用 {len(recent_days)} 天数据: {recent_days[0]} ~ {recent_days[-1]}") + + # 加载原始数据 + all_data = [] + for trade_date in tqdm(recent_days, desc="加载数据"): + cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet') + + if os.path.exists(cache_file): + df = pd.read_parquet(cache_file) + else: + df = compute_raw_concept_features(trade_date, concepts, all_stocks) + + if not df.empty: + all_data.append(df) + + if not all_data: + print("错误:无数据") + return + + combined = pd.concat(all_data, ignore_index=True) + print(f"总数据量: {len(combined):,} 条") + + # 按概念计算基线 + baselines = {} + + for concept_id, group in tqdm(combined.groupby('concept_id'), desc="计算基线"): + baseline_dict = {} + + for time_slot, slot_group in group.groupby('time_slot'): + if len(slot_group) < CONFIG['min_baseline_samples']: + continue + + alpha_std = slot_group['alpha'].std() + amt_std = slot_group['total_amt'].std() + rank_std = slot_group['rank_pct'].std() + + baseline_dict[time_slot] = { + 'alpha_mean': float(slot_group['alpha'].mean()), + 'alpha_std': float(max(alpha_std if pd.notna(alpha_std) else 1.0, 0.1)), + 'amt_mean': float(slot_group['total_amt'].mean()), + 'amt_std': float(max(amt_std if pd.notna(amt_std) else slot_group['total_amt'].mean() * 0.5, 1.0)), + 'rank_mean': float(slot_group['rank_pct'].mean()), + 'rank_std': float(max(rank_std if pd.notna(rank_std) else 0.2, 0.05)), + 'sample_count': len(slot_group), + } + + if baseline_dict: + baselines[concept_id] = baseline_dict + + print(f"计算了 {len(baselines)} 个概念的基线") + + # 保存 + os.makedirs(BASELINE_DIR, exist_ok=True) + baseline_file = os.path.join(BASELINE_DIR, 'realtime_baseline.pkl') + + with open(baseline_file, 'wb') as f: + pickle.dump({ + 'baselines': baselines, + 'update_time': datetime.now().isoformat(), + 'date_range': [recent_days[0], recent_days[-1]], + 'baseline_days': baseline_days, + }, f) + + print(f"基线已保存: {baseline_file}") + print("=" * 60) + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('--days', type=int, default=20, help='基线天数') + args = parser.parse_args() + + update_rolling_baseline(args.days) diff --git a/public/htmls/BOPET膜.html b/public/htmls/BOPET膜.html new file mode 100644 index 00000000..ad636569 --- /dev/null +++ b/public/htmls/BOPET膜.html @@ -0,0 +1,687 @@ + + + + + + BOPET膜概念深度洞察:深空数据终端 + + + + + + + + + + + + +
+
+
+ +
+

BOPET膜概念深度洞察

+

+ 北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现,本报告为AI合成数据,投资需谨慎。 +

+
+ +
+ + +
+

01. 概念事件与核心观点

+
+ +
+ 概念事件:行业自律与价格底部反转 +
+
+

BOPET膜概念近期核心事件围绕“行业自律”与“价格底部反转”展开。自 2022年下半年以来,BOPET行业因严重的产能过剩和“以价换量”的恶性竞争,导致企业盈利持续承压,多数公司陷入亏损泥潭。

+

2024年10月,行业迎来转折点,在第11届聚酯薄膜产业大会上,43家企业共同签署《PET行业自律自强宣言》,倡导“以销定产”,减少无效供给。此后,以“BOPET龙头”双星新材 (002585.SZ) 为代表的企业,于近期发布减产通知,计划在现有基础上减产20%,并预计春节期间进一步增加减产幅度至50%左右,以落实国家治理价格无序竞争的要求。

+

受此系列“反内卷”措施影响,BOPET价格开始偏强运行。据百川盈孚数据,12月4日12μ普通BOPET膜市场均价为7269元/吨,较上一工作日上涨0.73%,价格端于历史低位区间趋稳,并有持续上涨趋势。多数公司已于 2025年一季度减亏,行业底部修复迹象显著,资本开支明显缩减,预示着行业供需格局和盈利状况有望改善。

+
+
+ +
+ +
+ 核心观点摘要 +
+
+

BOPET膜行业正经历从周期底部向复苏的转折,核心驱动力在于行业自律引发的供给侧优化与结构性高端化转型。短期内,减产保价策略将推动盈利改善;长期看,具备成本优势和在光学、新能源等高附加值领域实现国产替代的企业将实现价值重估。

+
+
+
+ + +
+

02. 行业概况与市场动态

+
+
+

定义、分类与应用

+
    +
  • 定义: 双向拉伸聚酯薄膜 (BOPET) 是一种性能优良的薄膜材料。
  • +
  • 分类: 分通用类(薄型6–25 μm、中型25–65 μm)与功能类(超薄<6 μm、厚型>65 μm)。
  • +
  • 广泛应用: 包装印刷 (≈45.8%)、光学显示 (≈10.2%)、光伏背板 (12–13%)、电力电气、医疗包装、防水/建筑等工业用途。
  • +
  • 包装地位: 继BOPP薄膜之后,与BOPA薄膜并列的包装材料之一。
  • +
+
+ +
+

生产工艺与技术

+
    +
  • 主要方法: +
      +
    • 切片法(原料聚酯切片,成本高、差异化强)。2024年起新投项目以切片法为主。
    • +
    • 熔融法(原料PTA+MEG,成本低、品种受限)。
    • +
    +
  • +
  • 恒力石化康辉新材: 汾湖基地12条布鲁克纳BOPET生产线已投产,聚焦光学电子膜、医疗膜等高端领域;南通基地12条产线(3条投产,其余2025年H1完成),新增24万吨/年产能。
  • +
+
+ +
+

供需分析与产能结构

+
+
+
    +
  • 产能扩张: 2014-2024年,国内BOPET产能从247万吨扩张至695万吨 (CAGR 10.9%)。
  • +
  • 产量增长: 2014-2024年,产量从161万吨增长至459万吨 (CAGR 11.0%)。
  • +
  • 表观消费量: 2024年415万吨 (同比+8.2%),低于产能/产量增速,行业供过于求。2025年国内需求354万吨 (+5.4%)。
  • +
  • 产能结构 (2024年): 总产能648.2万吨/年 (68家企业);CR10=63%,中小产能 (<5万吨) 39家合计93.5万吨,占14.4%。
  • +
+
+
+
    +
  • 新增产能: 规划在建87.2万吨/年,预计2025-2027年陆续投放。2025年国内产能749万吨 (+7.8%)。
  • +
  • 进出口: 2024年中国为净出口国,净出口44.2万吨 (+50%)。2025H1进口12.6万吨 (-3.9%),出口33.9万吨 (+14%)。
  • +
  • 高端依赖: 进口均价是出口均价的2倍以上,高端产品依赖日本、韩国、中国台湾、美国进口。
  • +
  • 供需差: 2025年供需差91万吨为近年峰值,显示行业仍供过于求。
  • +
+
+
+
+ +
+

价格走势与盈利状况

+
    +
  • 价格水平: 22年以来持续下跌,2025年7月初价格为7,640元/吨,处于2020年以来2.9%历史低位。2025年8月13日均价7,410元/吨,同比-9.2%,较年初-9%。
  • +
  • 近期波动: 12月4日,12μ普通BOPET膜市场均价7269元/吨,较上一工作日上涨0.73%。企业签署自律减产协议后,本周均价已涨1.9%。
  • +
  • 成本结构: BOPET价格与原油价格走势趋同,切片法成本略高于直熔法。
  • +
  • 行业盈利: 2022年以来持续承压,2024年由盈转亏 (8家主营上市公司归母净利-6.5亿元)。2025Q1营收46.1亿元 (+3.4%),归母净利-0.1亿元 (同比减亏0.9亿元)。
  • +
  • 底部修复: 行业自律减产后,多数公司已于2025年一季度减亏。
  • +
+
+ +
+

政策与行业自律

+
    +
  • 行业倡议: 2024年10月,43家企业签署《PET行业自律自强宣言》,倡导“以销定产”、减少无效供给、防止恶性竞争。
  • +
  • 减产措施: 双星新材带头减产20%,春节期间预计达50%。
  • +
  • 格局优化: 政策端“反内卷”持续发力,有望推动供需再平衡、格局优化。资源向成本和技术优势企业集中。
  • +
  • 国际壁垒: 印度已提交对孟加拉、中国、泰国、美国产BOPET的反倾销调查申请;美国自2025年8月起印度输美BOPET关税从25%提至50%。
  • +
+
+ + +
+

BOPET膜下游应用结构

+
+
+ +
+
+ + +
+

03. 概念的核心逻辑与市场认知

+
+
+

核心驱动力

+
    +
  • 周期底部反转与供给侧改革: 行业主动去产能、优化供给,从“内卷”走向“反内卷”,是扭转颓势、推动价格回升和利润修复的根本动力。2024年8家主营上市公司归母净利由盈转亏-6.5亿元,催生了行业自律的必要性。
  • +
  • 高端化与差异化转型: 虽然通用BOPET膜竞争激烈,但光学膜、新能源电池膜、医疗膜、阻燃膜等高端产品仍存在进口依赖,进口均价是出口均价的2倍以上。技术创新和产品升级实现国产替代是行业新的增长点。
  • +
  • 成本与技术优势集中: 行业低谷加速优胜劣汰,资源向具备成本控制(如恒力石化)和技术研发优势(如洁美科技、和顺科技)的企业集中,提升行业集中度,优化竞争格局。
  • +
+
+ +
+

市场热度与情绪

+

+ 市场对BOPET概念的关注度正在升温,情绪从过去的悲观转向谨慎乐观。新闻报道中“反内卷初见成效”、“价格继续上涨”等字眼,以及减产消息的发布,都反映了市场对行业底部反转的期待。研报密集度也有所增加,普遍提及行业自律和盈利改善的预期,表明机构对该领域的关注度提升。然而,路演数据中“2024年行业供过于求”、“BOPET行业周期底部”等表述也提示,市场并非盲目乐观,仍存在对产能释放、需求增速和自律执行力的担忧。整体情绪是“触底反弹,但前路漫漫”,期待供给侧的持续优化和高端化突破。 +

+
+ +
+

预期差分析

+
    +
  • 自律的执行强度与持续性: 市场可能低估了短期内龙头企业执行减产的决心及其对价格的拉动作用。历史经验表明,市场在价格回暖后是否能持续保持理性,存在不确定性。
  • +
  • 高端产品国产替代的加速: 多家公司在光学、新能源、医疗等领域的具体布局和客户验证进展表明高端国产替代正在加速落地,其对公司盈利结构改善的贡献可能被市场低估。
  • +
  • 全球化竞争与贸易壁垒: 印度对中国BOPET的反倾销调查和美国对印度BOPET的关税提升,显示全球BOPET行业仍处于过剩状态,且贸易壁垒风险不容忽视,这可能未被充分纳入国内市场的定价模型中。
  • +
+
+
+
+ + +
+

04. 关键催化剂与未来发展路径

+
+
+

近期催化剂 (未来3-6个月)

+
    +
  • 行业减产执行到位与价格持续上涨: 双星新材“减产20%,春节期间达50%”的举措若能有效执行并带动其他企业跟进,将直接推高BOPET产品价格。
  • +
  • 上市公司2025年一季度财报验证盈利改善: 若实际业绩能超预期扭亏或大幅减亏,将成为验证行业底部反转的强力催化剂。
  • +
  • 高端产品新产能投产与客户验证进展: 和顺科技募投的3.8万吨BOPET光学基膜产能计划2024年5月投产;洁美科技BOPET膜二期2万吨产能预计2025年二季度试生产。
  • +
  • 国家层面“反内卷”政策的进一步细化或落地: 更具约束力的政策出台将进一步巩固行业自律成果。
  • +
+
+ +
+

长期发展路径

+
    +
  • 行业集中度持续提升: 市场化整合和淘汰落后产能将使CR10持续上升,优化竞争格局。
  • +
  • 高端化、差异化产品实现规模化国产替代: 从通用包装膜向光学膜、新能源电池膜、光伏背板、医疗包装、特种工业膜等高附加值领域全面渗透。
  • +
  • 绿色化、环保化趋势: 生产将更注重能耗降低和废弃物回收再利用,再生BOPET(rPET)材料应用普及。
  • +
  • 全球化市场拓展与应对贸易摩擦: 中国BOPET企业需有效应对各国日益增多的贸易保护主义措施。
  • +
+
+
+
+ + +
+

05. 产业链与核心公司深度剖析

+ +
+

产业链图谱

+

+ 上游: 主要为聚酯切片(切片法)或PTA+MEG(直熔法)。
+ 中游: BOPET膜生产商,涵盖通用型BOPET膜和各类功能性BOPET膜(光学膜、光伏膜、电池膜、离型膜等)。
+ 下游: 应用领域广泛,包括包装印刷(~45%)、光学显示(~10%)、光伏背板(~12-13%)、医疗、新能源电池(隔膜、阻燃膜)、防水、建筑、车膜等工业用途。 +

+
+ +

核心玩家对比

+
+ +
+

双星新材 (002585.SZ)

+

现有产能: 90.0万吨/年

+

特点: 行业龙头,产能最大,品类最全,在行业自律中发挥带头作用。

+

进展: 发布减产通知,积极应对价格战,有望率先受益于价格回升。

+

风险提示: 远期产能数据1376万吨存在巨大不确定性,需警惕过度解读。

+
+ +
+

恒力石化 (600346.SH)

+

现有产能: 76.8万吨/年;在建产能: 22.3万吨/年

+

特点: PTA-MEG一体化优势,成本控制能力强。康辉新材聚焦光学电子膜、医疗膜等高端领域。

+

进展: 南通基地多条产线规划投产,有望成第一大产能。

+

风险提示: 传统普膜受竞争拖累,高端产品放量速度是关键。

+
+ +
+

裕兴股份 (300305.SZ)

+

现有产能: 25.0万吨/年

+

特点: 光伏用膜占比超60%,在光伏领域有较深积累。

+

进展: 2022-2024毛利率由正转负,显示专业化公司在行业低谷期的盈利脆弱性。

+

风险提示: 新能源需求增速放缓。

+
+ +
+

东材科技 (601208.SH)

+

现有产能: 23.3万吨/年

+

特点: 光学膜盈利能力强,定位中高端,毛利率显著高于行业平均。

+

风险提示: 整体BOPET产能规模相对较小,对行业周期性波动的抵御能力需关注。

+
+ +
+

和顺科技 (301237.SZ)

+

现有产能: 11.5万吨/年

+

特点: 差异化功能性BOPET膜专家 (有色光电机膜、超模太阳能背板基膜、车衣保护膜、高亮膜),产品高端化布局明确。

+

进展: 募投3.8万吨光学基膜产能计划2024年5月投产,并启动高亮膜批量生产。

+

风险提示: 2024年因新产能爬坡预计亏损,高端产品市场接受度与爬坡效率是关键。

+
+ +
+

洁美科技 (002859.SZ)

+

现有产能: 1.8万吨/年;在建产能: 2.0万吨/年

+

特点: 基膜自供一体化,打破外资垄断,在MLCC离型膜、复合铜箔等高端应用有独特优势和布局。

+

进展: BOPET膜二期2万吨产能预计2025年二季度试生产,MLCC离型膜已实现稳定批量供货并进入韩日系大客户验证。

+

风险提示: 现有BOPET产能规模较小,但增长迅速。

+
+ +
+

其他相关公司概览

+
    +
  • 大东南 (002263.SZ): 上半年扭亏为盈,BOPET薄膜、光学膜毛利率改善,经营效益提升。
  • +
  • 长阳科技 (688299.SH): “膜类产品专家”,涉足固态电池电解质基膜等前沿领域,多元化布局,BOPET并非其核心业务。
  • +
  • 百宏实业 (2299.HK): 具备较大BOPET产能。
  • +
  • 荣盛石化 (002493.SZ): 具备较大BOPET产能。
  • +
  • 航天彩虹 (002389.SZ): 光学膜盈利能力较强。
  • +
  • 国风新材 (000859.SZ): 具备BOPET产能,在建产能较大。
  • +
  • 斯迪克 (300806.SH): 具备BOPET产能。
  • +
+
+
+ + +
+

核心BOPET企业现有产能对比 (万吨/年)

+
+
+ +
+ + +
+

06. 潜在风险与挑战

+
+
+

技术风险

+
    +
  • 高端产品国产替代的技术瓶颈: 在光学膜、医疗膜等领域,对膜的均匀性、表面处理、物理性能要求极高,研发投入大,成功实现大规模量产和客户认证仍需时间。
  • +
  • 新品开发不及预期: 和顺科技在研的高透光学膜、窗膜等能否达到性能要求并被市场广泛接受,仍需观察。
  • +
+
+ +
+

商业化风险

+
    +
  • 新增产能的持续投放: 规划在建87.2万吨产能预计2025–2027年陆续投放,可能再次引发阶段性供需失衡,导致价格承压。
  • +
  • 下游需求增速不及预期: “新能源需求增速放缓”等风险提示,以及光伏背板因装机节奏放缓占比略降,都表明下游需求可能影响部分专业化公司的业绩。
  • +
  • 原材料价格波动: BOPET价格与原油价格走势趋同,PET粒子等原材料价格的上涨,可能侵蚀企业的盈利空间。
  • +
+
+ +
+

政策与竞争风险

+
    +
  • 行业自律执行的持续性与有效性: 最大的风险在于一旦价格回升,企业逐利本性是否会导致“以价换量”卷土重来。
  • +
  • 国际贸易摩擦加剧: 印度对中国、泰国等国BOPET的反倾销调查,以及美国对印度BOPET关税的提升,可能影响中国BOPET企业的出口策略和利润。
  • +
  • 中小产能的韧性: 中小产能(39家合计93.5万吨)的退出速度和其对市场价格的潜在冲击仍需观察。
  • +
  • 信息交叉验证风险: 双星新材“远期产能(万吨/年)1376”这一数据异常巨大,与实际投产距离甚远,不宜作为近期产能考量。
  • +
+
+
+
+ + +
+

07. 综合结论与投资启示

+ +

+ BOPET膜行业正处于一个重要的历史性拐点。从过去数年的产能过剩、价格战泥潭中走出,进入到“行业自律+结构升级”的双轮驱动阶段。目前,我们判断该概念已经从纯粹的主题炒作阶段,初步进入基本面驱动的早期修复阶段。减产保价的措施已初见成效,盈利底部修复的预期正在形成,而高端化、差异化产品的国产替代则为行业提供了结构性增长的长期逻辑。 +

+ +
+
+

最具投资价值的细分环节或方向

+
    +
  • 行业龙头: 具备规模优势、成本控制能力,并积极参与行业自律的企业。它们在市场份额、议价能力和抵御风险方面更具优势,将率先受益于行业盈利的修复。
  • +
  • 高端功能膜领域: 在光学膜、新能源电池膜(包括复合铜箔基膜、阻燃膜)、医疗膜等高附加值细分市场拥有核心技术和客户壁垒,并积极实现国产替代的企业。
  • +
  • 垂直一体化或具备基膜自供能力的企业: 这类企业能更好地控制成本、保证产品质量和响应市场需求,具备更强的竞争力。
  • +
+
+ +
+

需要重点跟踪和验证的关键指标

+
    +
  • BOPET产品价格的持续性上涨: 密切关注百川盈孚等价格指数,确认价格上涨的趋势和幅度。
  • +
  • 上市公司盈利能力改善: 尤其是Q1 2025及后续季度财报中,毛利率和净利润的环比、同比变化。
  • +
  • 行业库存水平: 通过观察主要企业的产销率和库存周转天数来间接判断供需平衡状况。
  • +
  • 高端产品出货量及客户渗透率: 跟踪各公司在光学、新能源等领域新产品的量产、出货量增长。
  • +
  • 新增产能的实际落地速度: 关注规划在建产能的实际投产节奏,以判断未来供给端压力。
  • +
+
+
+
+ + +
+

08. BOPET膜概念相关股票

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票名称股票代码现有产能 (万吨/年)在建产能 (万吨/年)总产能 (万吨/年)
双星新材 龙头00258590.090.0180.0
恒力石化60034676.822.399.1
百宏实业(港)2299.HK70.370.3140.6
荣盛石化00249343.043.086.0
裕兴股份30030525.08.033.0
东材科技60120823.32.525.8
大东南00226314.014.028.0
和顺科技30123711.511.523.0
长阳科技6882997.52.09.5
航天彩虹0023897.07.014.0
国风新材0008595.66.011.6
斯迪克3008062.52.55.0
洁美科技0028591.82.03.8
+
+
+
+ +
+

© 2024 ValueFrontier. All rights reserved.

+
+ + + + \ No newline at end of file diff --git a/public/htmls/SpaceX.html b/public/htmls/SpaceX.html new file mode 100644 index 00000000..7fd9c5b2 --- /dev/null +++ b/public/htmls/SpaceX.html @@ -0,0 +1,799 @@ +北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现,本报告为AI合成数据,投资需谨慎。 + + + + + + + SpaceX概念深度投研报告 + + + + + + + + +
+
+

+ SpaceX概念深度投研报告 +

+

北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现

+

本报告为AI合成数据,投资需谨慎。

+
+ + +
+

0. 概念事件:SpaceX的崛起与未来展望

+
+

SpaceX,这家由埃隆·马斯克于2002年创立的商业航天公司,以其颠覆性的火箭回收技术和雄心勃勃的“星链”(Starlink)卫星互联网项目,持续在全球科技与金融市场引发广泛关注。近期一系列事件,特别是围绕其估值和未来发展方向的讨论,使其成为焦点概念。

+ +

时间轴梳理:关键里程碑事件

+
    +
  • 2002年: SpaceX成立,旨在降低太空运输成本。
  • +
  • 2008年9月: “猎鹰1号”火箭第四次发射成功,标志公司从早期失败中崛起。
  • +
  • 2010年6月: “猎鹰9号”火箭首次发射成功,成为公司主力运载工具。
  • +
  • 2015-2016年: “猎鹰9号”成功实现陆地和海上火箭回收,标志着可重复使用技术商业化,显著降低发射成本。
  • +
  • 2023年第一季度: “星链”业务首次实现季度盈利,并在年底实现全年盈利,成为公司营收和利润的核心驱动力。
  • +
  • 2023年末: SpaceX估值在二级市场达到约1800亿美元,部分交易甚至达到3500亿美元
  • +
  • 2024年6月6日: “星舰”重型运载火箭第四次试射取得重大进展,首次成功实现海面软着陆,向完全重复使用迈出关键一步。
  • +
  • 2024年8-9月: “北极星黎明”任务成功完成人类首次商业太空行走,展示了其在私人太空飞行领域的领导力。
  • +
  • 2024年11月7日: 马斯克暗示SpaceX未来可能IPO,并表达希望特斯拉股东能参与投资。
  • +
  • 2024年11月7日: 马斯克要求SpaceX的台湾供应商将生产业务迁出台湾,以应对地缘政治风险。
  • +
  • 2024年11月28日: 马斯克提出未来五年内在轨建设100GW太空AI计算中心的宏伟计划,可能采用钙钛矿太阳能技术。
  • +
  • 2025年1月17日: “星舰”试飞中发生“计划外快速解体”,导致FAA启动事故调查,显示其技术成熟度仍面临挑战。
  • +
  • 2025年6月: 白宫指示国防部和NASA审查与SpaceX的联邦合同,总额约220亿美元,系特朗普与马斯克公开争执后的潜在报复措施。
  • +
  • 2025年9月: SpaceX同意收购卫星运营商EchoStar的频谱区块,据新闻报道耗资170亿美元(路演数据为17亿美元),旨在发展直连手机服务。
  • +
  • 2025年12月6日: 华尔街日报等多家媒体报道SpaceX即将启动二次股票发售,估值将达到8000亿美元,超越OpenAI成为美国最具价值的私营企业。
  • +
  • 2025年12月7日: 马斯克在社交媒体回应称,SpaceX以8000亿美元估值融资的消息“并不准确”,但未否认IPO计划。
  • +
  • 2025年12月7日: SpaceX高管向投资者表示,公司正考虑在2026年下半年进行首次公开募股(IPO)。
  • +
+
+
+ + +
+

1. 核心观点摘要

+
+

SpaceX作为商业航天的领军企业,正通过其可复用火箭技术和星链业务,实现从太空运输商到综合性太空基础设施服务商的转型。当前市场对其未来价值预期极高,估值争议与 IPO 计划并存,显示其已进入从技术验证走向大规模商业化扩张的关键阶段,但星舰的最终成熟度、地缘政治及估值泡沫是其核心风险点。

+
+
+ + +
+

2. 概念的核心逻辑与市场认知分析

+
+

核心驱动力:颠覆性创新与太空经济开创

+
    +
  • 技术颠覆与成本优势: 猎鹰系列火箭的可重复使用技术是其核心竞争力。通过火箭一级助推器回收,单次发射成本大幅降低(路演数据显示猎鹰9号回收后成本降低70-90%,报价约3000美元/千克,远低于竞争对手)。未来“星舰”若实现完全回收,预计成本可降至80-200美元/千克,将彻底改变太空运输的经济性。这种成本优势不仅吸引了商业客户,也使其成为NASA和美国政府的关键服务商。
  • +
  • 垂直整合与快速迭代: SpaceX高达70%的自研率和“第一性原理”的迭代开发模式,使其能够快速试错、优化设计并实现技术领先。通过使用消费级硬件(如龙飞船采用英特尔X86处理器)并通过冗余设计解决航天级问题,显著降低了成本并加速了开发进程。
  • +
  • 创新商业模式与飞轮效应: “星链”卫星互联网业务的成功,改变了SpaceX的营收结构。它从最初主要依赖一次性发射合同,转变为依赖“星链”的持续性订阅服务,并为自身火箭提供了稳定的发射需求,形成“低成本发射→更多卫星部署→更广覆盖→更多用户→更多营收→更多发射”的良性飞轮效应。2023年“星链”收入已超过发射业务,成为公司最大的收入和利润来源。
  • +
  • 战略地位与政策支持: SpaceX不仅为商业公司服务,更是美国国家航天局(NASA)和国防部等政府机构的核心服务商。其在载人航天、补给任务、“星盾”军事卫星项目中的作用,使其具备重要的国家战略价值,从而获得政策和资金的持续支持。
  • +
  • 马斯克的个人影响力: 埃隆·马斯克的愿景、魄力及其在全球范围内的巨大影响力,使得SpaceX能够吸引顶尖人才、巨额资本和全球关注,加速其技术研发和市场拓展。
  • +
+ +

市场热度与情绪:乐观与警惕并存

+

当前市场对SpaceX概念的关注度极高,新闻报道密集,研报和路演频繁提及。整体情绪呈现出 “极度乐观与高度警惕并存” 的两极分化状态。

+
    +
  • 乐观情绪主要源于: +
      +
    • 惊人的估值增长: 在不到两年时间里,从2023年初的1270亿美元飙升至2025年中的4000亿美元,甚至在2025年12月传出8000亿美元的估值(虽被马斯克否认),反映了市场对其未来增长潜力的无限憧憬。
    • +
    • “星链”的商业成功: 800万活跃用户、盈利能力确立,以及直连手机业务的巨大潜力,验证了其商业模式。
    • +
    • “星舰”的未来前景: 虽然坎坷,但其作为人类通往深空和火星殖民的唯一可见路径,激发了无限想象空间。
    • +
    • 马斯克的“太空AI计算中心”宏伟蓝图: 为市场描绘了新的科技革命叙事。
    • +
    +
  • +
  • 警惕情绪主要体现在: +
      +
    • 估值泡沫风险: 马斯克对8000亿美元估值的否认,以及路演中提及的“DXYZ基金溢价6倍”,都暗示市场对SpaceX的估值可能存在过度炒作的成分,与当前营收(2025年预计155亿美元)之间存在巨大的预期差。
    • +
    • “星舰”技术成熟度: 多次试飞失败和取消,以及FAA的事故调查,表明其技术仍未完全稳定,距离大规模商业应用仍有距离。
    • +
    • 地缘政治与政策风险: 白宫对SpaceX联邦合同的审查、要求台湾供应商撤离等事件,都提示了其业务并非完全脱离政治风险。
    • +
    +
  • +
+ +

预期差分析:市场认知可能存在的盲点

+
    +
  • 估值与基本面脱节的风险: 虽然SpaceX的营收增长迅速(2023年87.21亿美元,2024年预测125亿美元,2025年预测155亿美元),且星链已盈利,但高达数千亿甚至8000亿美元的估值,远超其短期盈利能力所能支撑,明显计入了数十年后的远期增长和多种颠覆性业务的巨大成功。市场共识可能对这些远期愿景的实现难度和时间表过于乐观。
  • +
  • “星舰”研发进度的不确定性: 市场往往聚焦于“星舰”的宏大愿景,而对多次试飞失败、解体、取消的常态化及其对研发周期、成本的实际影响关注不足。例如,新闻中关于“星舰”第9、第10次试飞的表述存在矛盾,加剧了对进展的模糊性。从研发到全面商业运营、实现超低成本,仍需漫长且高风险的技术攻关。
  • +
  • “手机直连”业务的商业化难度: 市场对收购EchoStar频谱(新闻报道170亿美元,路演报道17亿美元,巨大的金额差异本身就是预期差)以开展直连手机业务充满期待。然而,该业务在全球范围内的落地涉及复杂的监管、频谱协调、运营商合作和终端适配,其盈利模式和市场接受度仍需验证,且巨额投资带来的回报周期可能较长。
  • +
  • 政治风险的潜在影响: 白宫对SpaceX联邦合同的审查(220亿美元),在普遍认为SpaceX与美国政府深度绑定的背景下,构成了潜在的巨大风险,可能影响其收入来源和战略地位。市场可能低估了政治博弈对这家“私人”航天巨头的影响。
  • +
+
+
+ + +
+

3. 关键催化剂与未来发展路径

+
+

近期催化剂(未来3-6个月):潜在的增长加速器

+
    +
  • “星舰”的关键性试飞成功: 若“星舰”能成功完成全面测试,特别是实现一级助推器的返场精确捕获,将是里程碑式的技术验证。这将极大地提振市场信心,并加快其商业化进程。
  • +
  • SpaceX IPO的具体时间表和定价公布: 2026年下半年IPO的预期,以及马斯克对8000亿美元估值争议的最终澄清,将是市场关注的焦点。一旦IPO计划和定价确定,将为市场提供明确的估值锚点,并带来巨大的流动性溢价。
  • +
  • “星链”直连手机服务的进展: EchoStar频谱整合后,若能公布更多全球运营商合作细节,并成功启动大规模商业服务,将是“星链”业务增长的下一个重要催化剂,有望吸引大量新用户。
  • +
  • 太空AI计算中心计划的阶段性成果公布: 马斯克在2025年11月提出的太空AI计算中心计划,若能在近期发布具体的合作伙伴、技术路线图或早期试验成果,将为SpaceX开辟全新的叙事空间,吸引AI和半导体领域的投资者关注。
  • +
+ +

长期发展路径:宏大愿景与战略布局

+
    +
  • “星舰”的全面运营与火星殖民: 从当前的测试阶段发展到完全可重复使用的星际运输系统,支持NASA的“阿尔忒弥斯3号”登月任务,并最终实现火星探测和殖民,这将是SpaceX的终极愿景和最大价值所在。这一过程需要持续的技术突破和巨额投资。
  • +
  • “星链”的全球全覆盖与生态构建: 完成42000颗卫星的部署,实现全球任何角落的低成本、高速宽带连接。同时,将业务拓展至航空、海运、偏远地区、移动终端直连,并进一步构建基于卫星网络的物联网生态系统。
  • +
  • “星盾”与国家安全基础设施: 将“星盾”发展成为美国国防和情报机构的核心太空基础设施,提供强大的军事通信、侦察和响应能力,巩固其战略供应商地位。
  • +
  • 太空资源的开发与利用: 随着太空运输成本的大幅下降和技术的成熟,SpaceX有望涉足太空资源开采、在轨制造、太空旅游等更广阔的太空经济领域,成为未来太空经济的奠基者和领导者。
  • +
  • 太空AI计算中心的部署与商业化: 成功部署和运营在轨AI数据中心,利用太空的独特优势提供超低延迟、高安全性的AI计算服务,开创全新的商业模式,推动人工智能向更高维度发展。
  • +
+
+
+ + +
+

财务表现、估值与业务构成

+
+
+

财务概览与收入预测

+
    +
  • 2023财年表现: 总收入87.21亿美元,同比增长90%。星链业务贡献41.78亿美元(公司第一大收入板块),并于年底首次实现盈利。公司首次实现正向现金流。
  • +
  • 2024财年预测: 总收入预计达到125亿美元,同比增长43%。星链收入预计达69亿美元(同比增长65%),发射收入预计达46亿美元
  • +
  • 2025财年预测: 预计营收155亿美元。马斯克预测,2026年SpaceX的营收规模将超越同期NASA的预算总额188亿美元
  • +
+

估值变化与IPO计划

+
    +
  • 估值飙升: 从2019年的350亿美元到2023年底的1800亿美元(投后),部分交易达3500亿美元。2025年7月,估值达到4000亿美元。新闻报道2025年12月启动二次股票发售,估值将达到8000亿美元,超越OpenAI。
  • +
  • 马斯克回应: 针对8000亿美元估值传闻,马斯克在社交媒体回应称“并不准确”,但未否认IPO计划。
  • +
  • IPO考虑: SpaceX高管向投资者表示公司正考虑在2026年下半年进行首次公开募股(IPO)。
  • +
  • 融资与投资者: 公司通过股权要约收购为员工和投资者提供流动性。摩根士丹利为SpaceX持股员工提供专项贷款。投资平台Republic允许小额投资者押注SpaceX。
  • +
+
+
+
+
+
+
+
+
+
+
+ +
+

商业模式与业务构成

+

SpaceX的核心商业模式是以可复用火箭为基础,以“星链”宽带通信服务为核心,形成了商业闭环。业务范围涵盖卫星制造发射、“星链”宽带通信服务、载人航天、运载服务,并积极拓展“星盾”等新业务领域。

+
    +
  • “星链”业务: 已成为SpaceX最大收入和利润来源,打破了火箭订单短缺瓶颈,为SpaceX提供了持续发射需求。2024年Starlink业务收入占SpaceX总营收的62.4%。硬件销售和订阅销售分别占星链营收的21.3%和51.9%。住宅应用占比43.5%,是最大的收入来源。
  • +
  • 资金来源与政府支持: 公司年度投入巨大,资金来自政府订单(截至2021年政府订单收入151.7亿美元)、资本市场融资(截至2022年底累计超130亿美元)以及马斯克本人投资。NASA在技术和人才方面给予大力支持。
  • +
+
+
+ + +
+

SpaceX核心业务板块

+
+ +
+

星舰 (Starship) 项目

+
    +
  • 目标与运力: 低轨运力150吨(回收)/250吨(单次),目标火星殖民与深空探测。
  • +
  • NASA合作: 根据与NASA40亿美元合同,需改造为登月着陆器,服务“阿尔忒弥斯3号”任务。
  • +
  • 试飞进展: 2023年两次试飞失败。2024年6月6日第四次试射取得重大进展,首次实现海面软着陆。2025年1月17日试飞中发生“计划外快速解体”。
  • +
  • 未来计划: “星舰V2”预计25年内商业化使用。目标2026年火星发射。
  • +
  • 监管许可: FAA批准年发射任务从5次增加到最多25次 (2025-05-16)。
  • +
  • 成本预期: 若成功回收,成本有望降至80-200美元/千克
  • +
+
+ + +
+

星链 (Starlink) 与卫星业务

+
    +
  • 部署与用户: 已部署约9000颗卫星,全球用户超800万(2025年)。累计发射超8399颗卫星(2025-05),计划总量42000颗
  • +
  • 直连手机: 致力于发展直连手机服务,同意收购卫星运营商EchoStar的频谱区块,新闻报道耗资逾200亿美元(路演数据为17亿美元),包含85亿美元现金及85亿美元SpaceX股票。
  • +
  • 星盾 (Starshield): 支持国家安全工作,监视地球目标,携带军事载荷,已发射212颗
  • +
  • 军用价值: 在俄乌战争中凸显低轨卫星在军事OODA循环中的关键作用。
  • +
+
+ + +
+

猎鹰9号 (Falcon 9) 与龙飞船

+
    +
  • 火箭主导: 猎鹰系列火箭主导全球商业发射,2023年完成98次发射(美国全年占比66%),全球载荷占比高达80%
  • +
  • 回收技术: 猎鹰9号可复用20余次,成本降至1500美元/公斤(降幅≈90%)。
  • +
  • 发射任务: 2025年1月将首次向月球表面发射“蓝色幽灵”月球着陆器。
  • +
  • 龙飞船: 货运/载人任务,累计飞行超1300小时,运送40+宇航员。单座成本约5500万美元
  • +
+
+ + +
+

政府合同与战略地位

+
    +
  • 核心服务商: 作为美国政府发射卫星和宇航员的核心服务商,与NASA、五角大楼和情报机构深度合作。
  • +
  • 合同审查: 2025年6月白宫指示审查SpaceX高达220亿美元的联邦合同,系特朗普与马斯克公开争执后的潜在报复。
  • +
  • 成本效益: SpaceX不依赖政府合同获取大部分收入,且价格远低于竞争对手,为美国政府节省了数十亿美元。
  • +
  • NASA合作: NASA自奥巴马时代将近地轨道任务外包SpaceX/RKLB等,自身聚焦月球/火星深空任务。
  • +
+
+ + +
+

太空AI计算中心计划

+
    +
  • 宏伟规划: 马斯克规划未来每年建设100GW太空算力(能力储备300~500GW),预测5年内太空数据中心成本将低于地面。
  • +
  • 技术路线: 拟每年部署100GW太阳能AI卫星,推动高效光伏技术发展。考虑使用钙钛矿技术替代高成本砷化镓,作为未来太空光伏系统的可行方案。
  • +
+
+ + +
+

创新模式与供应链

+
    +
  • 降本路径: 消费级硬件替代航天级芯片,龙飞船控制器采用英特尔X86处理器,成本仅为传统方案的1/5384。工业级替代宇航级材料。
  • +
  • 自研与迭代: 自研率超70%,通过迭代开发+第一性原理,快速试错优化设计。
  • +
  • 供应链管理: 供应商分类管理。马斯克要求台湾供应商搬出台湾(2024-11),以应对地缘政治风险。
  • +
+
+
+
+ + +
+

4. 产业链与核心公司深度剖析

+
+

产业链图谱

+
    +
  • 上游(基础材料与核心部件): 提供特种合金、复合材料、精密紧固件、电子元器件、绝热材料、宇航屏幕等。例如,西部材料(铌合金独供)、超捷股份(高强度紧固件)、再升科技(绝热材料)、派克新材(锻件)、信维通信(连接器及线缆)、晋拓股份(微波放大器)、宸展光电(宇航屏幕)。
  • +
  • 中游(航天器设计、制造与发射): 这是SpaceX的核心环节,包括猎鹰系列火箭、星舰、龙飞船以及星链、星盾卫星的设计、制造、测试、发射与在轨运营。
  • +
  • 下游(应用与服务): 包括全球卫星互联网服务(Starlink)、直连手机通信、载人航天、货运补给、军事与政府任务、太空AI计算中心等。
  • +
  • 资本投资: 通过股权投资或二级市场购买SpaceX股份,如利欧股份通过合伙企业投资。
  • +
+ +

核心玩家对比

+
+
+

SpaceX(领导者)

+
    +
  • 竞争优势: 在可复用火箭技术、发射成本控制、垂直整合能力、迭代研发速度和低轨卫星星座部署规模上拥有压倒性优势。其市场份额(2023年全球载荷占比高达80%)和用户规模(Starlink 800万用户)均证明了其领导地位。马斯克的个人品牌和愿景也提供了无与伦比的资金吸引力。
  • +
  • 业务进展: Starlink已盈利并快速扩张用户基础,正大力推动直连手机和太空AI计算中心。星舰虽屡次挫折,但技术突破仍在持续。
  • +
  • 潜在风险: 星舰研发的不确定性、高达8000亿美元估值(新闻报道)的合理性、美国政府合同审查带来的不确定性、以及地缘政治(如要求台湾供应商撤离)对供应链的影响。
  • +
+
+
+

中国大陆供应商(绑定SpaceX的受益者)

+
    +
  • 信维通信: 作为SpaceX星链终端核心组件(连接器及线缆)的“独家直接供应商”,其与SpaceX的业务关联度高且技术壁垒强。但独家地位的持续性存在不确定性,可能面临竞争和份额稀释的风险。
  • +
  • 西部材料: “SpaceX锯合金产品的中国境内唯一供应商”,显示其在特定高端材料领域的稀缺性和技术认可度。
  • +
  • 再升科技、超捷股份、派克新材等: 均以其在各自领域的技术专长,切入SpaceX的直接供应链。
  • +
  • 共同特点与风险: 这些公司通过与SpaceX的合作,进入全球最前沿的商业航天供应链,是对其技术实力和产品质量的极高认可。但存在对单一客户的依赖、SpaceX供应链策略调整以及技术迭代带来的产品替代风险。
  • +
+
+
+

利欧股份(投资方)

+
    +
  • 逻辑纯粹性: 作为SpaceX的间接投资者,其价值主要来自于SpaceX估值的增长。
  • +
  • 潜在风险: 路演信息提及“投资争议目前处于互相诉讼中”,表明其投资收益的实现可能面临法律和程序上的不确定性,纯度受损。
  • +
+
+
+

中国商业航天企业(追赶者/对标者)

+
    +
  • 地位: 路演报告指出,中国卫星互联网(以中国星网为主)处于SpaceX 2018-2019年的试验阶段,民营火箭技术对标SpaceX 2009-2010年,整体处于追赶阶段。
  • +
  • 竞争环境: SpaceX已占据全球最优低轨轨道和大量频谱资源,形成“先到先得”的垄断优势,对中国构成巨大压力。中国企业需加速发展以避免资源被占。
  • +
  • 市场情绪: 受到政策利好、技术突破和国家战略需求而备受关注,但其发展阶段和规模与SpaceX仍有显著差距。
  • +
+
+
+
+
+ + +
+

5. 潜在风险与挑战

+
+
    +
  • 技术成熟度风险: +
      +
    • “星舰”的复杂性与可靠性: 新闻中多次报道“星舰”试飞失败、爆炸、解体(2025年1月、6月),以及试飞取消(2025年3月),表明其技术尚未完全稳定。尽管FAA批准了更多发射次数,但距离实现完全可复用、可靠的商业运营和火星任务,仍有漫长且充满不确定性的技术攻关之路。新闻中关于第9次和第10次试飞结果的矛盾描述(2025年6月报道“第9次试飞失控解体”,2025年8月报道“第10次试飞圆满结束”,但2025年6月还报道了S36“第10次试飞前”爆炸),使得外界难以准确评估其真实进展。
    • +
    • 太空AI计算中心的挑战: 100GW的太空算力部署、能源供给(钙钛矿技术仍需验证)、散热、在轨维护以及数据传输效率等,都是巨大的工程和技术挑战。
    • +
    +
  • +
  • 商业化与盈利风险: +
      +
    • Starlink用户增长瓶颈和ARPU值: 随着市场渗透率提高,用户增长速度可能放缓。同时,维持较高ARPU值以覆盖高昂的运营成本和初期投入,也面临来自地面通信或其他卫星互联网的竞争。
    • +
    • 直连手机服务的推广与盈利: EchoStar频谱收购金额存在巨大矛盾(新闻报道170亿美元,路演报道17亿美元),这一笔巨额投资的真实性及其商业化回报,仍是未知数。全球各地的监管、运营商合作和市场接受度将是关键。
    • +
    +
  • +
  • 政策与竞争风险: +
      +
    • 政府合同审查: 2025年6月,白宫因政治争执对SpaceX总额220亿美元的联邦合同进行审查,可能影响其在NASA和国防部的核心业务,甚至削弱其在“金穹”导弹防御系统中的作用。
    • +
    • 地缘政治对供应链的影响: 马斯克要求台湾供应商撤离(2024年11月),凸显地缘政治冲突对全球高科技供应链的脆弱性。这可能导致SpaceX供应链成本上升,或迫使其调整全球采购策略。
    • +
    • 全球商业航天竞争加剧: 尽管SpaceX领先,但各国(包括中国)都在加速布局商业航天,未来轨道资源和频谱的争夺将日益激烈。路演数据也显示,SpaceX的成本优势正在压制国内企业。
    • +
    +
  • +
  • 信息交叉验证风险: +
      +
    • 估值信息的高度矛盾: 新闻报道2025年12月SpaceX估值将达8000亿美元,马斯克本人随即否认“并不准确”,同时路演和研报在不同时间点给出了1270亿、1800亿、3500亿、4000亿美元等多个估值版本。这种巨大的估值差异和高层公开否认,说明市场对其真实估值存在严重分歧,投资者需高度警惕估值泡沫风险。
    • +
    • EchoStar收购金额的巨大差异: 新闻报道称收购金额为170亿美元(85亿美元现金+85亿美元股票),而路演报告则提及17亿美元。这一10倍的差异对SpaceX的财务影响和战略意义是截然不同的,是核心信息矛盾点,需要明确其真实性。
    • +
    • 星舰试飞结果和编号的混乱: 不同新闻源对“星舰”第9次和第10次试飞的时间、结果和编号存在矛盾,使得外界难以准确评估其研发进度。
    • +
    • 2023年发射次数的差异: 路演报告称2023年SpaceX全年发射71次,而研究报告则称98次。虽然不影响核心判断,但反映出数据来源可能存在统计口径或及时性差异。
    • +
    +
  • +
+
+
+ + +
+

6. 综合结论与投资启示

+
+

综合结论:转型扩张中的机遇与挑战

+

SpaceX目前正处于一个关键的转型与扩张时期。其可复用火箭技术和“星链”业务已成功从技术验证阶段迈向基本面驱动的商业化盈利阶段,确立了其在商业航天领域的领导者地位。然而,其更高维度的雄心壮志,如“星舰”和“太空AI计算中心”,仍处于高风险、长周期、高回报潜力的主题炒作/预期阶段,其最终成功与否将决定SpaceX能否实现其万亿美元市值的终极愿景。当前市场对SpaceX的估值存在明显争议和预期差,反映了投资者对未来潜力的无限憧憬与对其实现路径不确定性的担忧。

+ +

最具投资价值的细分环节或方向:

+
    +
  1. “星链”手机直连业务的领导者: 尽管EchoStar收购金额存疑,但“手机直连”是“星链”的下一个爆发点,其用户基数和盈利能力一旦得到验证,将为SpaceX带来长期稳定的现金流。关注其在全球范围内的频谱整合、运营商合作及实际商用进展。
  2. +
  3. “星舰”成功全面商业化后的产业链头部企业: 一旦“星舰”实现全面复用并大规模商业化,其对太空运输成本的颠覆将是空前的,整个太空经济产业链将迎来爆发。届时,能够为“星舰”提供关键材料、部件和服务的核心技术型供应商将获得巨大红利。
  4. +
  5. 中国大陆供应链中具备技术稀缺性和强绑定关系的优质供应商: 那些为SpaceX提供独家或高技术含量产品的中国企业(如西部材料、信维通信等),是分享SpaceX成长红利的间接途径。需重点筛选那些在技术上具备不可替代性、且能拓展与SpaceX合作深度的公司,而非仅依赖价格优势。
  6. +
+ +

接下来需要重点跟踪和验证的关键指标:

+
    +
  • “星舰”的试飞成功率和回收完整度: 特别是能否实现一级助推器返场精确捕获,这将直接影响其经济性和可靠性。
  • +
  • SpaceX的官方估值和IPO定价: 这将是市场对SpaceX价值的最终共识,并提供更透明的投资参考。
  • +
  • “星链”直连手机服务的用户增长、ARPU值和覆盖范围: 关注该业务的实际商业化进展,以及EchoStar收购的真实金额及其对财务的影响。
  • +
  • SpaceX核心中国供应商的订单量和品类拓展情况: 关注其与SpaceX合作的稳定性、深度和广度,以及是否受到地缘政治和供应链调整的影响。
  • +
  • 美国政府对商业航天政策的动向: 特别是白宫对SpaceX联邦合同审查的最终结果,以及对军事航天市场参与程度的影响。
  • +
+
+
+ + +
+

SpaceX概念相关股票数据

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票代码股票名称相关性与理由
002131利欧股份2021年公司认缴TBCA基金5000万美元,拟共同投资SpaceX;公司与TBCA基金因投资争议目前处于互相诉讼中 (参股)
002149西部材料据网传纪要:公司铌合金是SpaceX中国唯一供应商
688102斯瑞新材公司积极对接SpaceX公司,拓展公司液体火箭发动机燃烧室内衬的在该领域头部企业上的应用,目前尚未形成商业订单
603601再升科技公司高性能高硅氧纤维(用于隔热保温)供货SpaceX (供应商)
002792通宇通讯公司的MacroWiFi产品通过SpaceX接口实现卫星直连互联网功能 (供应商)
300136信维通信公司为SpaceX提供高性能连接器及线缆解决方案 (供应商)
300757罗博特科参股公司ficonTEC客户包括国际某星链及太空技术的前沿科技公司
603211晋拓股份微波放大器、基站放大器、滤波器等零部件主要客户包括台扬科技;台扬科技供货SpaceX
+
+
+ +
+ + + + \ No newline at end of file diff --git a/public/htmls/TV面板LCD.html b/public/htmls/TV面板LCD.html new file mode 100644 index 00000000..5c24c525 --- /dev/null +++ b/public/htmls/TV面板LCD.html @@ -0,0 +1,756 @@ + + + + + + TV面板LCD概念深度综合分析报告 + + + + + + + + +
+
+

TV面板LCD概念深度综合分析报告

+

北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现

+

本报告为AI合成数据,投资需谨慎。

+
+ + +
+

0. 概念事件与时间轴

+
+
+

2024年7月-9月

+

LCD TV面板价格经历下跌或降幅收窄调整期。

+

(CINNO Research)

+
+
+

2024年11月

+

“国补”政策及国内外大促需求回暖,LCD TV面板价格全线止跌趋稳。

+

(CINNO Research)

+
+
+

2024年12月

+

品牌商春节备货、国内补贴政策延续等支撑需求回升;面板厂上调稼动率。Omdia数据显示55/65/75寸非战略客户价格有望小幅上涨1-2美元。

+

(Omdia, WitsView)

+
+
+

2025年1月

+

TV面板价格涨势扩大。京东方确认LCD TV主流尺寸面板价格全面上涨,3月预计延续涨势。除55吋持平外,其他尺寸淡季均预期上涨。

+

(京东方, Omdia, WitsView)

+
+
+

2025年2月-3月

+

CTDZ预测各尺寸TV面板价格持续上涨,65吋面板均价预期从175美元增至177美元。

+

(CTDZ预测)

+
+
+

2025年4月

+

CTDZ预测TV面板价格稳定,中信证券预测价格转向平稳但仍相对较高。

+

(CTDZ, 中信证券)

+
+
+

22-24年 海外产能退出

+

三星2022年退出LCD业务,LG转向OLED。夏普SDP(10代线)2023年12月宣布关闭,计划2024年8月停产,减少大尺寸供给。

+

(路演数据)

+
+
+

2024年12月 国内控产

+

中国大陆头部面板厂积极实施“按需定产”策略,稼动率提升至80%+,仍坚持控产提价。2025年1月21日台湾地震短期加剧供给紧张。

+

(路演数据)

+
+
+

中长期展望

+

中信电子预计2026年Q1行业进一步普涨,2027/28年行业供需将逐步平衡并转为偏紧,利润加速释放。

+

(中信电子, 中信证券)

+
+
+
+ + +
+

1. 核心观点摘要

+

+ TV面板LCD概念正经历从周期底部向上的结构性反转,核心驱动力为供给侧的显著集中与有效控产,叠加需求端的大尺寸化趋势及政策/事件刺激。当前市场对行业盈利修复预期强烈,且伴随国内厂商折旧压力缓解,中长期利润释放空间可期。 +

+
+ + +
+

2. 核心逻辑与市场认知分析

+
+
+

供给侧集中度提升与有效控产

+
    +
  • 中国大陆厂商主导全球TV面板市场,控制超70%大尺寸产能(路演数据)。
  • +
  • 京东方A和TCL科技作为双寡头,CR2超过60%,具备强大稳价能力。
  • +
  • 日韩台系厂商持续退出(三星、LGD、夏普SDP),加剧供给侧收缩。
  • +
  • 面板厂普遍采取“按需定产”、“主动控产”策略,稼动率维持在80%-85%(新闻数据),有效管理库存,避免恶性价格竞争。
  • +
+
+
+

需求端结构性改善与韧性

+
    +
  • **大尺寸化趋势:** 行业长期核心驱动力,平均尺寸每增加1寸消耗一座10.5代线产能。2023年TV面板平均尺寸提升至50.7英寸,中国市场更高(2024年预计62寸)。
  • +
  • **政策刺激与事件催化:** “国补”、“以旧换新”等政策提振终端需求。奥运会等体育赛事(“26年为赛事大年”)带动TV换机需求。
  • +
  • **Mini LED普及:** 作为LCD高端升级路径,性价比优势显著(成本较OLED低60%),预计2025年国内Mini LED TV销量渗透率增至35.6%,带来新的增长点。
  • +
+
+
+

盈利能力释放的结构性拐点

+
    +
  • **折旧周期拐点:** 大陆面板厂商折旧费用在2024-2025年达顶峰,此后逐步下降。京东方A的B9、B17等产线折旧结束,年折旧额将减少约100亿元,释放巨大业绩弹性。
  • +
  • **少数股东权益回收:** 龙头企业随现金流改善加速回收少数股东权益(如TCL华星利润归母比例提升),进一步增厚归母净利润。
  • +
  • **成本端持续降本:** 国产化进程推动成本下降,贡献盈利改善。
  • +
+
+
+
+

市场热度与情绪

+

+ 市场对TV面板LCD概念关注度极高,情绪整体乐观积极。券商研报频繁发布(中信电子、国海电子等),普遍维持“重点推荐”或“坚定看好”评级。股票涨幅分析显示京东方A等公司受到券商研报、主力资金和行业基本面共振推动。市场普遍认为行业已走出低谷,进入新一轮景气周期,头部企业将显著受益。 +

+
+
+

预期差分析

+
    +
  • **盈利持续性的压力:** 面板厂当前盈利达30%,而品牌厂净利仅4%-5%,上下游利润分配不平衡可能导致下游品牌商抵制进一步涨价,影响价格上涨空间和持续性。
  • +
  • **稼动率控制的动态博弈:** 稼动率并非一成不变,而是根据市场供需动态调整。若需求不及预期或个别厂商未能有效控产,可能导致价格波动超出市场预期。
  • +
  • **技术路线的潜在风险:** 尽管LCD仍为主流,但OLED/Mini LED的长期替代压力依然存在。Mini LED渗透速度和对传统LCD的冲击程度需持续关注。
  • +
  • **信息源的细微差异:** 不同机构对市场份额等数据的统计口径可能存在差异,投资者需辨析。
  • +
+
+
+ + +
+

3. 关键催化剂与未来发展路径

+
+
+

近期催化剂 (未来3-6个月)

+
    +
  • **面板价格持续上涨:** 2025年1月至3月TV面板价格持续上涨,京东方已确认。
  • +
  • **体育赛事备货需求:** 2026年为赛事大年(奥运会等),将刺激2025年下半年至2026年上半年TV面板采购需求。
  • +
  • **中国“以旧换新”政策延续及效果:** “国补”政策延续,若力度加大将进一步刺激终端消费。
  • +
  • **海外产能进一步退出/整合:** 夏普SDP 2024年8月停产的影响逐步显现。
  • +
  • **Mini LED TV渗透率超预期:** 若2025年Mini LED TV销量渗透率(预计35.6%)超预期,将带动高端LCD面板需求增长。
  • +
+
+
+

长期发展路径

+
    +
  • **供需平衡中枢上移:** 预计2027/28年行业供需将逐步平衡并转为偏紧,盈利中枢持续上行。
  • +
  • **盈利结构优化:** 折旧压力缓解(京东方A预计年折旧额减少100亿元),少数股东权益回收增厚归母净利润。
  • +
  • **技术升级与应用拓展:** 持续推进大尺寸化和高端化(如Mini LED),京东方等厂商在OLED领域布局(2026年5月8.6代OLED量产)。
  • +
  • **产业集中度深化:** 国内双寡头CR2进一步提升,强化市场话语权和稳价能力,减弱行业周期性。
  • +
+
+
+
+ + +
+

4. 关键数据可视化

+ +
+
+ +
+
+

数据来源:CINNO Research, Omdia, WitsView, CTDZ, 中信电子, 国联电子, 京东方 (经综合处理)

+
+
+
+

数据来源:2025年Q3全球液晶电视面板市占率 (股票数据)

+
+
+ +
+

稼动率与大尺寸化趋势

+
+
+

稼动率: 中国大陆头部面板厂积极控产,2024年11-12月稼动率已提升至80%-85%。行业平均稼动率自去年11月开始回升,今年以来保持在80%以上,有效管理库存。

+

(新闻数据, 路演数据)

+
+
+

大尺寸化: 2023年TV面板平均尺寸提升至50.7英寸。中国市场平均尺寸更高,2024年预计达62寸(Q4达63寸)。75英寸+ TV出货量2023年增长65%,2024年预计再增长30%。大尺寸化有效消化产能并提升利润。

+

(路演数据, 新闻数据)

+
+
+

Mini LED渗透: CINNO Research预计2025年国内市场Mini LED TV销量渗透率将增至35.6%,上半年销量同比激增3.2倍,成本较OLED低60%。

+

(新闻数据, 路演数据)

+
+
+
+
+
+ + +
+

5. 产业链与核心公司深度剖析

+ +

产业链图谱

+
+
+
+

上游 (材料及零部件):

+
    +
  • 显示材料: 偏光片 (深纺织A), 玻璃基板 (彩虹股份), 光学膜, 掩膜板, TFT混晶 (万润股份), 芳香胺 (百合花), 溶剂油。
  • +
  • 背光模块: Mini LED背板 (沃格光电)。
  • +
  • 触控显示: LCOS技术 (光峰科技), 各类触控显示产品 (经纬辉开)。
  • +
+
+
+

中游 (面板制造):

+
    +
  • 大尺寸TV面板: 京东方A, TCL科技 (华星光电), 彩虹股份。
  • +
  • 中小尺寸/车载/触控显示: 深天马A, 龙腾光电, 华映科技, 经纬辉开。
  • +
+
+
+

下游 (终端应用):

+
    +
  • 电视品牌、智能手机、车载显示、笔记本电脑、AR/VR设备、智能穿戴等。
  • +
+
+
+
+ +

核心玩家对比 (主要聚焦TV面板)

+
+
+

京东方A (000725)

+
    +
  • 竞争优势: 全球LCD显示绝对领导者,2025年Q3全球液晶电视面板市占率27.7%,出货量1754万片。多条高世代线。
  • +
  • 业务进展: 积极控产稳价,受益面板价格上涨。2025年1月起TV面板价格全面上涨。前瞻布局OLED,有望2026年5月全球率先量产8.6代OLED。
  • +
  • 潜在利好: 折旧压力在2025-2026年逐渐结束,年折旧额预计减少100亿元,利润弹性巨大。
  • +
+
+
+

TCL科技 (000100) (华星光电)

+
    +
  • 竞争优势: 2025年Q3全球液晶电视面板市占率24.7%,出货量1567万片。全球最大TV面板厂商之一,10.5代线布局领先。与TCL实业协同效应强。
  • +
  • 业务进展: 控产保价,利润表现积极。TCL华星利润归母比例自25Q1的60%提升至25Q3的71%,增厚归母净利润。
  • +
  • 潜在利好: 类似京东方A,受益行业景气和产能折旧红利。内部优化整合能力强。
  • +
+
+
+

彩虹股份 (600707)

+
    +
  • 竞争优势: 2025年Q3全球液晶电视面板市占率5.3%,出货量340万片。借助8.6代线灵活套切优势,提升大尺寸产能和性能。在基板玻璃领域有布局。
  • +
  • 业务进展: 2024年3月TV LCD面板价格创近两年新高,公司面板业务经营利润表现良好,稼动率高于行业水平。
  • +
  • 潜在利好: 特定世代线优势和基板玻璃业务提供差异化增长点。
  • +
+
+
+

深纺织A (000045) (上游偏光片)

+
    +
  • 竞争优势: 深圳市属唯一的偏光片及显示材料上市平台。国内首条2.5m超宽幅产线计划2025年底前达产,可配套115寸面板,大幅提升产能并降低成本。OLED用偏光片已小批量出货。
  • +
  • 业务进展: 受益于面板涨价和国产替代趋势。深圳国企改革政策提供潜在整合预期。
  • +
  • 逻辑纯粹性: 作为上游材料供应商,业绩与面板行业景气度及国产替代进程密切相关。
  • +
+
+
+
+

验证与证伪

+
    +
  • **面板价格上涨与品牌商压力:** 研报和新闻普遍看好面板价格上涨,但路演中提及“面板厂当前盈利达30%,品牌厂净利仅4%-5%,矛盾突出”,这可能限制后续涨价幅度和持续性。
  • +
  • **中国厂商主导地位:** 关联个股数据显示京东方A (27.7%)、TCL科技 (24.7%) 的Q3 2025 TV面板市占率合计接近50%,有力印证了其在TV面板领域的定价权。
  • +
  • **稼动率波动:** 稼动率数据在不同时间点和来源间存在差异,反映行业产能管理的动态性。关键在于厂商“按需定产”策略的有效性。
  • +
  • **TCL收购LG广州工厂信息修正:** 提示信息传播中可能存在的误导,投资者需警惕。
  • +
+
+
+ + +
+

6. 潜在风险与挑战

+
+
+

需求不及预期风险

+
    +
  • 终端消费疲软:宏观经济不确定性可能导致TV需求持续弱于预期。
  • +
  • 高价抑制需求:面板价格持续上涨可能导致下游整机价格提升,抑制消费者购买意愿。
  • +
+
+
+

供给侧管理风险

+
    +
  • 稼动率控制不及预期:个别面板厂可能盲目提高稼动率,导致供给过剩。
  • +
  • 新产能规划不确定性:未来新的技术或区域性产能投资可能打破供需平衡。
  • +
+
+
+

商业化与技术风险

+
    +
  • Mini LED渗透率不及预期:其成本、市场接受度及与OLED的竞争存在不确定性。
  • +
  • OLED技术替代加速:若其成本下降速度超预期,可能加速对LCD的替代。
  • +
+
+
+

政策与贸易风险

+
    +
  • 宏观政策变动:“国补”和“以旧换新”等政策支持力度和持续时间存在不确定性。
  • +
  • 国际贸易摩擦:美国对华关税、海外贸易壁垒等可能影响出口,增加运营成本。
  • +
+
+
+

信息交叉验证风险

+
    +
  • 市场份额数据差异:不同机构数据可能因统计口径不同而有差异,需投资者辨析。
  • +
  • 稼动率预测与实际波动:行业稼动率动态调整且存在不确定性,需关注实时数据。
  • +
  • 信息误导:如TCL收购LGD广州工厂的乌龙信息,需警惕此类信息对判断的影响。
  • +
+
+
+
+ + +
+

7. 综合结论与投资启示

+
+

综合结论

+

+ TV面板LCD概念目前处于基本面驱动阶段。核心逻辑在于供给侧的结构性改善(高度集中、有效控产、海外产能退出)和需求端的结构性增长(大尺寸化、政策刺激、Mini LED升级),叠加中国大陆厂商折旧压力逐年缓解,共同推动行业盈利中枢抬升,周期性波动有望减弱。虽然短期内上下游利润分配的矛盾可能导致面板价格趋于平稳,但中长期来看,行业已进入一个更加健康和可持续的发展阶段。 +

+
+
+

最具投资价值的细分环节或方向

+
    +
  • + 中国大陆TV面板龙头企业:京东方ATCL科技。在全球TV面板市场拥有绝对主导地位,具备强大的定价权和成本优势。业绩对面板价格上涨和行业格局优化最为敏感,中长期有折旧结束带来的利润释放和少数股东权益回收的增厚效应。 +
  • +
  • + 受益于国产替代和面板大尺寸化趋势的上游材料供应商:深纺织A (偏光片)彩虹股份 (玻璃基板)。随着面板国产化率提升和产品大尺寸化,具备技术和产能优势的本土材料供应商将持续受益。 +
  • +
+
+
+

接下来需要重点跟踪和验证的关键指标

+
    +
  • TV面板价格走势: 尤其是55/65/75英寸以上大尺寸面板的价格变化和稳定性。
  • +
  • 中国大陆面板厂的稼动率水平: 关注行业平均稼动率是否能维持在80%左右的健康水平。
  • +
  • 主要面板厂的盈利能力变化: 重点关注龙头企业毛利率和净利润率,以及折旧费用在其财务报表中的变化趋势。
  • +
  • Mini LED TV的市场渗透率: 关注Mini LED TV销量数据和在高端市场的占比。
  • +
  • 下游品牌厂的库存水平和采购策略: 品牌商的库存健康状况和采购积极性将直接影响面板需求。
  • +
+
+
+ + +
+

相关公司列表

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票名称股票代码相关性原因其他标签
京东方A0007252025年Q3全球液晶电视面板市占率27.7%,出货量约为1754万片LCD, 大尺寸 TV面板, 行业资讯
TCL科技0001002025年Q3全球液晶电视面板市占率24.7%,出货量约为1567万片LCD, 大尺寸 TV面板, 行业资讯
彩虹股份6007072025年Q3全球液晶电视面板市占率5.3%,出货量约为340万片LCD, 大尺寸 TV面板, 行业资讯, 涨幅大于10%
深天马A000050全球车规TFT-LCD、车载仪表显示出货量全球第一LCD, 中小尺寸, 调研
龙腾光电688055公司主营TFT-LCD,中小尺寸显示领域LCD, 中小尺寸, 半年报
华映科技000536子公司华佳彩拥有一条IGZO TFT-LCD生产线,中小尺寸显示面板;实控人福建国资委持股24.34%LCD, 中小尺寸, 半年报, 涨幅大于10%
经纬辉开300120子公司新辉开主要生产以LCD技术为基础的各类触控显示产品LCD, 中小尺寸, 互动
+
+
+ + +
+

涨幅分析补充

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票名称股票代码涨幅(%)交易日期涨幅原因分析
京东方A0007255.192025-12-05**核心结论:** 京东方A显著上涨由券商深度研报强力催化、主力大资金一致行动、行业基本面向好预期及市场情绪积极变化共同作用。中信证券研报《LCD面板有望于26Q1开启新一轮涨价,中长期利润释放逻辑持续强化》是直接导火索,强调短期价格拐点、中长期供给侧优化、需求侧增长、利润弹性释放(折旧达峰下行、少数股东权益回收)。主力资金净流入16.87亿元。同时,京东方在8.6代OLED生产线量产的技术领先地位也提供长期增长想象空间。
海信视像60006010.002025-09-24**核心结论:** 墨西哥第二工业园(规避北美关税)、G7 Ultra电竞Mini-LED(高毛利新品)、参编国家人形机器人标准化白皮书(具身智能期权)三箭齐发,卖方集体上调盈利预测,机构空翻多引爆涨停。海外最大园区投产可节省关税运费,提升净利率;Mini-LED电竞显示器渗透率提升,毛利率高于普通TV;参与人形机器人标准化白皮书,确认技术储备。
百合花6038239.982025-11-04**核心结论:** “吨级面板光刻胶订单落地+芳香胺脱氨新工艺降本”双预期共振,使百合花放量涨停。国科大杭高院提出N-硝基胺介导直接脱氨,成本降40-50%,公司颜料级芳香胺自产,毛利率有望抬升。市场流传公司LCD光刻胶颜料项目首批吨级订单锁定头部面板厂。实控人误操作减持已回补,消除短期压力。
深纺织A00004510.002025-10-13**核心结论:** “深圳国企改革深化提升行动推进会”明确2025年前完成电子信息产业链战略性重组,公司作为市属唯一偏光片上市平台被资金率先定价。叠加2.5m超宽幅产线年底试车、面板涨价带来业绩拐点,三者共振触发涨停。公司大股东承诺支持做强做大,具备重组特征。国内首条2.5m超宽幅产线可配套115寸面板,OLED用偏光片已小批量出货。
冠捷科技00072710.042025-09-15**核心结论:** “工信部9·4显示终端国产化替代政策”与“9·12京东方电竞嘉年华8.3亿元大单”两大硬信息在周末发酵后的资金滞后共振。政策首次量化“2026年国产品牌市占率≥75%”,明确商用大屏、电竞显示器为首批替代场景,冠捷科技ODM市占率高,三大品牌受益。BOE电竞嘉年华,公司作为独家显示设备合作伙伴,现场锁定8.3亿元订单。
莱宝高科0021069.962025-09-05**核心结论:** 部委级《电子信息制造业稳增长方案》首次将“电子纸”列为新型显示重点并给出2027年产值年增>10%量化目标,莱宝高科作为A股唯一已量产彩色电子纸模组,低位+稀缺触发涨停。重庆4.5代彩色电子纸模组线投产,年产能600万片/18亿元产值。政策明确支持车载显示、HUD,公司In-cell触控模组已小批量交付问界、长安深蓝。
万润股份00264310.002025-10-27**核心结论:** 凌晨财政部拟将“高端液晶单体用溶剂油”纳入80%退税清单,叠加TFT混晶周涨6.8%与OLED前端材料提前量产,三因素共振使2026E EPS上修40%,资金开盘一次性抢筹至涨停。高端液晶单体用溶剂油退税年化增利1.1-1.3亿元。TFT混晶周涨6.8%,公司系京东方二供。150吨OLED前端材料产线10月投料,量产节点提前2个月。
沃格光电60377310.002025-08-08**核心结论:** Chiplet-2D/3DIC 集成基板技术验证成功叠加 Mini LED 背板获 DIC2025 双项大奖及 GCP 认证,双重产业催化引爆资金抢筹。国内首条面向Chiplet的2.5D/3D集成基板完成工艺验证,良率>90%,打开AI服务器、车载算力芯片增量空间。公司GCP Mini LED背板通过可靠性测试,已获三星、LG 2026 Mini LED电视供应链门票。
光峰科技6880079.732025-08-26**核心结论:** Meta第三代眼镜采用LCos技术重大利好,且光峰科技在LCos领域具领先地位。Meta新眼镜增加单目全彩显示版本并采用LCos技术,续航提升、成本远低于MicroLED。公司LCos方案除外采外其他环节自研打通,核心能力为自制激光器。AR眼镜LCos作为高价值环节,市场空间巨大。
合力泰0022179.922025-08-20**核心结论:** 屏下摄像概念走强,合力泰作为概念股之一领涨。当时市场风格转向低价股,合力泰作为低价科技股受资金青睐。投资者对公司业务转型或“重生”阶段抱有较高期待,认为未来发展空间较大。技术面突破也吸引了更多买盘跟进。
日久光电0030156.142025-06-16**核心结论:** AI眼镜概念走强、显示行业整合加速等外部因素带动。日久光电作为光电显示薄膜器件供应商,受益于AI眼镜等新型显示设备概念热度。京东方A拟收购彩虹光电股权,表明行业整合趋势。公司主力资金运作、业绩预期向好,产品应用于柔性显示、智能穿戴等领域。
隆利科技3007528.592025-08-20**核心结论:** 公司在LIPO技术领域进展及与主要客户的合作信息。LIPO技术被视为新型屏幕封装工艺,旨在实现窄边框和提升显示效果。公司LIPO技术项目已实现穿戴类产品量产,并在手机类产品取得进展。此外,公司VR背光显示产品获客户认可,车载MiniLED业务稳定增长。
+
+
+ +
+ + + + \ No newline at end of file diff --git a/public/htmls/卫星能源太阳翼.html b/public/htmls/卫星能源太阳翼.html new file mode 100644 index 00000000..be93fe89 --- /dev/null +++ b/public/htmls/卫星能源太阳翼.html @@ -0,0 +1,1017 @@ +北京价值前沿科技有限公司 AI投研agent:“价小前投研” + +本报告为AI合成数据,投资需谨慎。 + + + + + + + + 卫星能源太阳翼概念深度报告 + + + + + + + + +
+

卫星能源太阳翼概念深度报告

+

北京价值前沿科技有限公司 AI投研agent:“价小前投研”

+

本报告为AI合成数据,投资需谨慎。

+
+ +
+ + +
+

0. 概念事件:太空能源的时代序章

+
+

“卫星能源太阳翼”概念的兴起,是全球太空探索和商业航天浪潮下,对卫星核心能源供给系统需求的集中体现。其背景是太空算力与卫星互联网的爆发式增长,以及相关前沿技术的突破与政策的强力支持。

+

催化事件与时间轴:

+
    +
  • 2023-2024年:我国多次将搭载钙钛矿组件的卫星送入太空进行初步在轨验证,显示出对下一代光伏技术的积极探索。国际空间站也于2023年通过太空行走安装新型太阳翼,以提升发电效率。
  • +
  • 2024年5月:国内民营企业光阴科技首次将钙钛矿电池发射测试,标志着商业航天在新型能源技术应用上的初步落地。
  • +
  • 2025年7月30日:我国成功发射卫星互联网低轨06组卫星,加速了卫星互联网的组网进程,直接催生了产业链相关公司的市场关注和股价异动,例如卫星化学当日涨幅达7.21%
  • +
  • 2025年8月:市场预期“卫星互联网牌照即将发放”,以及工信部发布《卫星互联网频率轨道资源管理办法(征求意见稿)》,标志着行业发展进入实质建设期。天银机电、三维通信等因此大幅上涨
  • +
  • 225年9月5日盘后:三部委发布《低轨卫星互联网产业高质量发展三年行动计划(2025-2027)》,将“商业航天”提升至国家战略高度,并首次将上海沪工等企业列入“卫星AIT总装公共服务平台”示范承建单位,引发相关公司股价强劲表现。
  • +
  • 2025年9月8日:工信部向中国联通颁发卫星移动通信牌照,加速了卫星通信C端应用的落地。
  • +
  • 2025年9月16日:“卫星互联网技术试验卫星”成功发射,航天机电作为核心配套商,其4.7亿元新单开始收入确认,导致股价涨停。
  • +
  • 2025年9月30日:政策强调“2026年起新发射卫星须配国产高功率密度电源模块”,新雷能作为宇航电源模块供应商,被市场解读为受益于强制国产化政策,股价实现20%涨停
  • +
  • 2025年10月:“千帆星座”第六批卫星成功发射,组网节奏超预期。星网宇达因其低轨卫星地面终端业务受益,三季报扭亏为盈,股价涨停。
  • +
  • 华为Mate80发布在即:市场传闻华为Mate80将实现低轨卫星直连,带动电科芯片等卫星通信芯片供应商的关注度。
  • +
+
+
+ + +
+

1. 核心观点摘要

+
+

“卫星能源太阳翼”概念正处于政策强力推动下的高速发展初期,由太空算力、卫星互联网建设以及国家战略需求共同驱动。尽管下一代钙钛矿电池技术仍面临商业化验证挑战,但柔性化、轻量化趋势已确立,并在砷化镓电池降本和国产化替代中酝酿着巨大的市场机遇。

+
+
+ + +
+

太空能源市场增长展望

+
+
+ + +
+

2. 核心逻辑与市场认知分析

+
+

核心驱动力:

+
    +
  • 太空算力与卫星互联网的能源刚需: 中信证券指出“算力上天背景下太阳翼及能源系统太阳翼将成为最大增量”。未来“太空数据中心”的部署,例如马斯克计划的每年100GW数据中心,将产生对太阳能电池高达4.8万亿元的需求。中国“GW”星座和G60星链等项目的加速推进,亦将大幅提升对高效、可靠卫星能源系统的需求。这是该概念最核心的长期驱动力,因为卫星的一切设备都需要电力,太阳翼是不可或缺的细分王者。
  • +
  • 政策扶持与国家战略: 上海市将“柔性太阳翼”列为重点发展关键配套;三部委《低轨卫星互联网产业高质量发展三年行动计划》明确了产业发展目标;工信部颁发卫星通信牌照并制定频率轨道资源管理办法;“商业航天”被提升为国家经济战略的核心层面。这些政策共同构建了支持卫星能源太阳翼发展的有利环境,尤其强调国产化和自主可控。
  • +
  • 技术创新与降本增效需求: 传统砷化镓电池虽然高效,但成本高昂(>70美元/瓦特)。钙钛矿电池作为下一代技术,兼具高效率、低成本、耐辐射等优点,且太空环境消除了其在地球上的主要降解因素,具备颠覆性潜力。同时,柔性太阳翼因其“展开面积大、功率重量比高、可实现十几颗甚至几十颗卫星层层堆叠发射”的优势,成为低轨商业卫星的主流方案,推动了对PI薄膜、碳纤维等轻量化材料的需求。
  • +
+

市场热度与情绪:

+
    +
  • 当前市场对“卫星能源太阳翼”概念的关注度极高,情绪总体呈乐观积极态势。
  • +
  • 新闻热度高涨: 多方信息强调其“巨大市场空间”(千亿至万亿级别),并将其描述为“最大增量”、“能源供给核心”。
  • +
  • 研报密集发布: 有预测未来五年内市场将保持23.6%的年复合增长率,到2029年市场规模达187亿美元。研报同时关注低轨卫星、太空数据中心等带来的增长点。
  • +
  • 股价表现强劲: 多个关联个股在相关消息催化下出现涨停或大幅上涨,例如航天机电、卫星化学、天银机电、新雷能、上海沪工、星网宇达等,显示出资金对该概念的积极追捧。市场普遍认为,随着卫星互联网建设加速,产业链企业将迎来订单增长。
  • +
+

预期差分析:

+
    +
  • 钙钛矿电池的商业化时间表: 新闻和研报中对钙钛矿寄予厚望,认为其“有望成为重要的太空能源解决方案”、“颠覆低成本市场”。然而,路演信息则更为谨慎,明确指出钙钛矿电池“稳定性差(高温>150℃性能骤降)”、“在轨测试显示性能衰减严重”,并强调需5年以上的长期验证。这表明市场对钙钛矿的大规模商业化落地时间可能过于乐观,其技术风险和验证周期被低估。
  • +
  • 砷化镓电池的短期主导地位与降本空间: 尽管钙钛矿备受关注,路演明确砷化镓电池是当前“主流技术,占传统卫星99%以上”,且国内技术国际领先。体制内单位已将其成本从3年前的超10万元/㎡降至6-7万元/㎡,聚光型砷化镓目标成本更可降至1-2万元/m²。市场可能过度关注钙钛矿的未来潜力,而忽视了砷化镓在短期内的确定性主导地位和持续降本能力。
  • +
  • 市场份额描述的差异性: 乾照光电在新闻中被描述为“国内唯一实现批量供货”且“市场占有率70%”,但在路演中则提到其“国内市占率约50%”,并列出了凯迅光电、中山德华等竞争对手。这种差异可能源于统计口径或信息来源的差异,但暗示了市场对单一公司“绝对垄断”地位的认知可能存在一定偏差。
  • +
+
+
+ + +
+

主要太阳能电池技术对比

+
+
+ + +
+

综合数据洞察:关键技术与市场前瞻

+
+ +
+
+

新闻速览:市场、技术与企业聚焦

+ +
+
+
    +
  • 市场前景: 太空算力能源高度依赖光伏技术,太阳翼将是最大增量。5W太空数据中心需近12平方公里太阳翼,价值2400亿元(钙钛矿)。马斯克未来计划每年100GW数据中心,2040年太空太阳能需求达4.8万亿元。上海政策重点支持柔性太阳翼等关键配套。
  • +
  • 关键技术: +
      +
    • 钙钛矿: 高效率、低成本、耐辐射,太空环境消除其降解因素。23-24年多次搭载卫星测试。Starlink3代或采用,正招聘钙钛矿工程师。上海港湾钙钛矿电池可覆盖航天器全生命周期。
    • +
    • 柔性太阳翼: 低轨商业卫星和深空探测主流方案,展开面积大、功率重量比高,利于堆叠发射。天问二号、雄安一号已配置。
    • +
    • PI薄膜: 柔性/刚性太阳翼确定性材料,瑞华泰耐原子氧PI薄膜研发进展良好,沃格光电CPI膜材已在轨应用。
    • +
    • 翼阵合一: ASTS卫星融合通信天线与太阳片,是相控阵天线和太阳翼一体化。
    • +
    • 砷化镓: 传统技术,效率高、抗辐照,但成本高昂(>70美元/瓦特)。锗晶片是首选关键材料。
    • +
    +
  • +
  • 相关企业: +
      +
    • 总装/机构: 中国卫星、隆盛科技、航天电子、航天环宇。
    • +
    • 材料/电池片: 瑞华泰、沃格光电、光威复材(碳纤维)、乾照光电(砷化镓龙头,占有率70%,预计2026年贡献营收16.8亿)、三安光电(砷化镓芯片)、南大光电(MO源)、正帆科技(磷烷)、上海港湾(钙钛矿)、云南锗业(锗晶片,2025年末产能125万片/年)。
    • +
    • 散热/检测: 顺灏股份、多浦乐。
    • +
    +
  • +
+
+
+ +
+
+

路演聚焦:成本、技术与市场格局

+ +
+
+
    +
  • 价值与成本: 太阳翼占卫星总经费8%-10%(传统卫星10%-20%)。成本拆解:电池电路(40%)、结构(20%)、驱动(40%)。钙钛矿轻量化可降发射成本。
  • +
  • 技术路线: +
      +
    • 硅基: 早期技术,效率低,适合低功率、低成本卫星(如马斯克星链)。国内需加速追赶。
    • +
    • 砷化镓: 当前主流(传统卫星99%以上),效率高(30-32%),抗辐照,寿命10-15年。成本高昂(6-7万元/㎡),但体制内单位已通过技术提升降本。聚光型砷化镓目标成本降至1-2万元/m²。短期内仍主导。
    • +
    • 钙钛矿: 潜力大,薄膜化、轻量化、成本极低(4-5万元/㎡)。但稳定性差(高温>150℃性能骤降),在轨测试衰减严重,需5年以上验证。适配柔性太阳翼,有自修复能力。若解决稳定性,中长期可能颠覆市场。
    • +
    • 柔性与刚性: 柔性太阳翼可折叠,体积小,适合高频发射小卫星。国际空间站已升级采用新型多层材料堆叠太阳翼。
    • +
    +
  • +
  • 市场格局: +
      +
    • 国内供应商: 电池片:体制内811所、18所;民营乾照光电(市占约50%)、凯迅、中山德华(均砷化镓);钙钛矿:光阴科技(首例在轨测试)。
    • +
    • 太阳翼结构: 体制内805所、501部;民营银河航天、开拓重工。
    • +
    • 国际对标: 星链V1.5部分采用钙钛矿(单翼106㎡,1/3钙钛矿),V2.0或全部替换为钙钛矿。
    • +
    +
  • +
  • 投资与风险: 短期布局砷化镓龙头,中长期关注钙钛矿突破民企。风险:钙钛矿稳定性未解决,砷化镓降价空间有限,商业卫星竞争加剧。
  • +
+
+
+ +
+
+

研报精要:应用、规模与挑战

+ +
+
+
    +
  • 技术概述: 卫星能源太阳翼是创新能源解决方案,结合辐射屏蔽与高效能量转换,确保极端环境稳定供电。特点:高效率(30%-40%)、轻量化、强抗辐射、柔性可折叠、寿命长(>15年)。
  • +
  • 应用场景: 太空探索(国际空间站、通信导航卫星、深空探测器)、地球能源供应(微波/激光传输)、军事应用(侦察通信)、民用(偏远地区供电)、特殊环境(极地、沙漠)。
  • +
  • 市场规模: 全球卫星产业蓬勃,政策支持空间太阳能电站,商业航天崛起。预计未来五年CAGR 23.6%,2029年市场规模达187亿美元。中国“GW”星座、G60星链加速推进,低轨卫星对高效太阳翼需求显著。
  • +
  • 挑战: 制造成本高、技术门槛高(能量传输、环境适应)、维护困难。辐射屏蔽与能量转换效率有待提升,轻量化设计受材料限制。
  • +
  • 发展建议: 加大研发(辐射屏蔽、效率),分阶段市场渗透(优先军事特种),规模化生产降本,建立行业标准。
  • +
  • 潜在相关: 我国发射太空计算卫星星座,对能源需求更高。卫星小型化趋势推动轻量化、高效率太阳翼。商业航天爆发带动配套产业。
  • +
+
+
+
+
+ + +
+

3. 关键催化剂与未来发展路径

+
+

近期催化剂(未来3-6个月):

+
    +
  • 政策细则与执行落地: 三部委《低轨卫星互联网产业高质量发展三年行动计划(2025-2027)》的具体执行细则发布,以及工信部《卫星互联网频率轨道资源管理办法》的正式实施,将带来明确的订单预期和产业发展指引。
  • +
  • 低轨卫星密集发射与组网提速: 如“千帆星座”、“GW星网”等项目按计划甚至超预期完成新的卫星批次发射和成功组网。每次发射成功都将是重要的市场情绪催化剂,并直接拉动太阳翼及相关部件的需求。
  • +
  • 钙钛矿电池在轨验证进展: 如果有关键的在轨测试报告发布,显示钙钛矿在稳定性、抗辐射能力和效率衰减方面取得突破性进展,将极大地提升市场对其商业化前景的信心。
  • +
  • 下游应用商业化进展: 华为Mate80等支持卫星直连的消费级终端发布,将推动卫星通信服务走向大众市场,间接刺激上游卫星制造环节的活跃度。
  • +
  • 国产化替代订单兑现: 例如“2026年起新发射卫星须配国产高功率密度电源模块”这类政策带来的具体订单,将直接利好国内相关供应商。
  • +
+

长期发展路径:

+
    +
  • 技术成熟与成本优化: +
      +
    • 钙钛矿电池: 彻底解决其在太空环境下的长期稳定性问题,使其转换效率进一步提升至30%以上,并实现规模化生产,将成本降至砷化镓的1/10甚至更低
    • +
    • 柔性太阳翼: 进一步提升功率重量比,实现更高集成度,同时优化展开机构的可靠性和精度。
    • +
    • 聚光型砷化镓: 通过技术创新大幅降低砷化镓用量,将成本降至5,000元/m²以下
    • +
    +
  • +
  • 产业链垂直整合与生态构建: 从上游特种材料到中游电池片、组件、结构件,再到下游总装、测试、电源管理及应用,形成高效协同、具备全球竞争力的产业链生态。
  • +
  • 应用场景的广度与深度拓展: 从当前主要服务于通信、遥感卫星,扩展到太空数据中心的能源供给,实现“天数天算”。未来向深空探测器载人航天器、甚至空间太阳能电站等更宏大的目标迈进。
  • +
  • 行业标准化与国际合作: 建立统一的太空能源系统设计、制造、测试标准,推动国际间在太空资源开发和利用方面的合作。
  • +
+
+
+ + +
+

4. 产业链与核心公司深度剖析

+
+

产业链图谱:

+
    +
  • 上游(材料及基础器件): +
      +
    • 锗晶片: 云南锗业(生产卫星太阳能电池的首选关键材料)
    • +
    • PI薄膜/CPI膜: 瑞华泰(高性能PI薄膜)、沃格光电(CPI膜材已在轨应用)
    • +
    • 碳纤维: 光威复材(卫星结构和太阳能电池板用高性能碳纤维主力供应商)
    • +
    • MO源: 南大光电(砷化镓太阳能电池关键原材料)
    • +
    • 磷烷: 正帆科技(砷化镓太阳能电池化学气相沉积工艺所需原材料)
    • +
    +
  • +
  • 中游(电池片、组件、结构与机构): +
      +
    • 砷化镓电池片: 乾照光电(砷化镓外延片出货量国内第一)、三安光电(砷化镓电池芯片已应用于商用卫星)
    • +
    • 钙钛矿电池片/组件: 上海港湾(钙钛矿电池覆盖航天器全生命周期用电)、协鑫集成、海优新材等
    • +
    • 钙钛矿电池设备: 迈为股份、宇晶股份等
    • +
    • 太阳翼结构件/机构: 隆盛科技(卫星太阳翼铰链、压紧释放机构)、航天电子、航天环宇
    • +
    • 散热: 顺灏股份
    • +
    +
  • +
  • 下游(总装、检测、电源系统与应用): +
      +
    • 总装/集成: 中国卫星、上海沪工(卫星AIT总装公共服务平台)
    • +
    • 检测: 多浦乐
    • +
    • 电源模块/芯片: 新雷能(电源模块)、电科芯片(宇航级射频/星载电源管理芯片)
    • +
    • 应用/服务: 天银机电(恒星/太阳敏感器)、三维通信(卫星宽带运营)、星网宇达(低轨卫星地面终端)
    • +
    +
  • +
+

核心玩家对比:

+
+ +
+
+

乾照光电 (300102.SZ)

+
    +
  • 优势与进展: 国内砷化镓太阳能电池(外延片)的绝对龙头,新闻称其出货量稳居国内第一,市场占有率高达70%(路演称50%),是“国内唯一实现批量供货”的供应商。预计“太阳翼单星8平米,每平米的电池片售价20万元/平米,单星价值量160万元”。预计2026年可贡献16.8亿营收,8.4亿利润。产品已应用于G60、千帆星座等国内头部星网。
  • +
  • 逻辑纯度: 高,直接受益于低轨卫星对砷化镓电池的刚性需求。
  • +
  • 潜在风险: 路演中提及市占率有差异,且面临钙钛矿电池的长期技术替代风险。
  • +
+
+
+

上海港湾 (605598.SH)

+
    +
  • 优势与进展: 新闻提及其钙钛矿电池“完全能够覆盖卫星等航天器的全生命周期用电需求”,且子公司拥有“柔性太阳帆板展开锁定机构”及“空间航天器用钙钛矿电池片粘贴方法及系统”的核心专利。
  • +
  • 逻辑纯度: 高,作为钙钛矿电池在航天领域的探索者,直接受益于新型能源技术突破。
  • +
  • 潜在风险: 钙钛矿电池的长期稳定性在轨验证仍需5年以上,商业化落地存在不确定性。
  • +
+
+
+

隆盛科技 (300680.SZ)

+
    +
  • 优势与进展: 控股子公司产品涵盖“卫星太阳翼铰链、压紧释放机构、驱动组件”等核心结构件,并参与银河航天的柔性太阳翼项目机械结构部分。
  • +
  • 逻辑纯度: 较高,直接受益于柔性太阳翼的广泛应用。
  • +
  • 潜在风险: 作为结构件供应商,其业绩弹性依赖于下游整星制造的规模。
  • +
+
+
+

瑞华泰 (688323.SH) / 沃格光电 (603773.SH)

+
    +
  • 优势与进展: 瑞华泰高性能PI薄膜已应用于空间飞行器太阳翼基板,并研发耐原子氧PI薄膜。沃格光电的CPI膜材和防护镀膜产品已在轨应用。PI薄膜是柔性太阳翼的关键基材,需求确定。
  • +
  • 逻辑纯度: 中等偏高,为柔性太阳翼提供核心材料。
  • +
  • 潜在风险: 竞争可能加剧,毛利率受制于行业竞争。
  • +
+
+
+

云南锗业 (002428.SZ)

+
    +
  • 优势与进展: “锗晶片是生产卫星太阳能电池的首选关键材料”,公司有明确的产能扩张计划,2025年末锗晶片产能125万片/年,项目建成后250万片/年
  • +
  • 逻辑纯度: 高,卡位砷化镓电池最上游核心材料,具有稀缺性。
  • +
  • 潜在风险: 砷化镓电池若被钙钛矿大规模替代,对其长期需求会有影响。
  • +
+
+
+
+

验证与证伪:

+
    +
  • 钙钛矿的乐观预期被证伪(部分): 新闻和研报中渲染的钙钛矿低成本、高效率前景,在路演中被指出了关键的技术瓶颈——稳定性差、在轨衰减严重、需要5年以上验证期。这表明市场对钙钛矿的商业化路径存在显著的“预期差”,投资者需警惕其短期内无法大规模应用的风险。
  • +
  • 乾照光电的“王者”地位有待进一步验证: 新闻称其市占率70%,而路演则称50%,并提及竞争对手。这可能反映出市场信息源的差异,需要投资者审慎评估其真实的竞争格局和市场地位,防止过度神话单一公司。
  • +
  • 星链应用钙钛矿的深层信息缺失: 路演提到“星链V1.5版本部分采用钙钛矿”,V2.0可能“全部替换为钙钛矿”。这与国内钙钛矿仍处于早期验证的谨慎态度形成对比,可能意味着国外在钙钛矿技术或应用场景选择上已取得突破,但具体细节未披露,无法全面交叉验证。
  • +
+
+
+ + +
+

5. 潜在风险与挑战

+
+

技术风险:

+
    +
  • 钙钛矿电池的稳定性与可靠性: 这是当前最大的技术瓶颈。路演明确指出其在太空极端环境(±120℃温差、高真空、辐射)下的“稳定性差”和“性能衰减严重”,需要5年以上的长期在轨验证。
  • +
  • 辐射屏蔽技术仍需提升: 太空中的高能辐射对电池寿命和性能构成威胁。
  • +
  • 能量转换效率的瓶颈: 尽管目前效率已高,但仍有改善空间,持续的研发投入必不可少。
  • +
+

商业化风险:

+
    +
  • 高制造成本: 砷化镓电池成本高昂。钙钛矿初期商业化面临制造工艺、封装及可靠性验证的高成本,市场接受度受价格因素制约。
  • +
  • 应用场景受限: 钙钛矿在未完全解决稳定性问题前,可能仅适用于“低寿命、低成本卫星或高空浮空器”。
  • +
  • 维护问题: 太空设备维护和更换困难,增加了运营风险。
  • +
+

政策与竞争风险:

+
    +
  • 政策变动不确定性: 未来政策方向、补贴力度或技术标准的变化,都可能对行业发展产生影响。
  • +
  • 行业竞争加剧: 技术竞争白热化和价格战,可能影响企业盈利能力。
  • +
  • 技术替代风险: 硅基或钙钛矿技术突破可能冲击砷化镓等现有主流技术供应商的市场份额。
  • +
+

信息交叉验证风险:

+
    +
  • 钙钛矿商业化时间表的显著矛盾: 新闻和研报乐观,路演谨慎,投资者需审慎评估。
  • +
  • 市场份额数据差异: 乾照光电市占率在不同信息源中存在20%的差异,需更深入求证。
  • +
  • 国际与国内技术进展的对比: 星链已部分采用钙钛矿,国内仍在早期测试,可能存在技术代差或应用策略差异。
  • +
+
+
+ + +
+

6. 综合结论与投资启示

+
+

“卫星能源太阳翼”概念当前正处于主题炒作与早期基本面驱动并存的阶段。宏大的市场空间(数万亿元)、密集的政策支持以及颠覆性技术(钙钛矿、柔性化)的潜力,构成了强大的主题吸引力,促使市场情绪高度乐观。同时,随着低轨卫星组网加速和国产化政策落地,部分具备核心技术的产业链公司已开始有订单兑现,显示出基本面驱动的初步迹象。

+

最具投资价值的细分环节或方向:

+
    +
  • 砷化镓电池产业链核心供应商: 在未来3-5年内,砷化镓电池仍是高功率、高可靠性卫星的主流选择。具备稳定供应能力、技术领先且已实现规模化供货的龙头企业,如乾照光电(砷化镓电池片)和云南锗业(锗晶片),具有较强的业绩确定性和较高的市场份额。
  • +
  • 柔性太阳翼结构件与关键材料: 柔性化是低轨商业卫星和深空探测的明确趋势。提供高性能PI薄膜(瑞华泰、沃格光电)精密展开机构、结构件(隆盛科技)的企业,直接受益于柔性太阳翼的普及。
  • +
  • 卫星总装、核心电源模块及宇航级芯片: 对卫星AIT总装能力以及符合“国产化”要求的电源模块和宇航级芯片的需求将显著爆发。上海沪工(卫星AIT总装),新雷能(电源模块)和电科芯片(宇航级射频/电源管理芯片),卡位关键环节,受益于政策强制国产化。
  • +
+

接下来需要重点跟踪和验证的关键指标:

+
    +
  • 钙钛矿电池在轨验证结果: 密切关注国内外钙钛矿电池在太空环境下的长期稳定性、抗辐照能力以及功率衰减率的官方报告。
  • +
  • 低轨卫星的实际发射数量与组网进度: 跟踪中国“GW”星座、G60星链、千帆星座等项目的年度发射量、在轨卫星数量以及组网完成度
  • +
  • 核心供应商的订单签订与收入确认: 重点跟踪相关公司航天相关业务的订单金额、营收占比、毛利率变化以及产能利用率
  • +
  • 成本下降速度与功率重量比提升: 关注砷化镓电池、钙钛矿电池的单瓦成本或单平米成本的实际下降趋势,以及柔性太阳翼的功率重量比提升情况。
  • +
+
+
+ + +
+

“卫星能源太阳翼”概念相关股票

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票代码股票名称关联原因其他标签
600118中国卫星公司开展了基于工业机器人的太阳翼自动对接系统等先进总装装备和系统研制太阳翼结构, 总装, 调研
300680隆盛科技控股子公司产品包括卫星太阳翼铰链、压紧释放机构、驱动组件;公司参与银河航天的柔性太阳翼项目机械结构部分太阳翼结构, 结构件, 互动
600879航天电子太阳翼机构在高分三号卫星实现了成功应用太阳翼结构, 结构件, 年报
688523航天环宇公司产品有用于承载太阳能电池片的复合材料太阳翼、柔性太阳翼展开机构太阳翼结构, 结构件, 招股书
688323瑞华泰公司产品(高性能PI薄膜)已应用于空间飞行器太阳翼基板太阳翼结构, PI薄膜, 互动
603773沃格光电公司柔性太阳翼产品所用CPI膜材和防护镀膜产品对客户的交付,并已实现在轨应用太阳翼结构, PI薄膜, 互动
300699光威复材公司是卫星结构和太阳能电池板(太阳翼)用高性能碳纤维的主力供应商太阳翼结构, 碳纤维, 互动
002565顺灏股份公司连同北京一家头部研发机构做出产品,可实现整个能源链路的热平衡太阳翼结构, 散热, 调研
301528多浦乐公司曾向北京卫星制造厂销售无损检测系统用于卫星支杆太阳能帆板的支撑管检测太阳翼结构, 检测, 互动
300102乾照光电公司砷化镓太阳能电池产品(外延片)出货量稳居国内市场第一砷化镓电池, 外延片, 互动
600703三安光电公司生产的砷化镓太阳能电池芯片已应用于商用卫星电源等领域砷化镓电池, 芯片, 互动
300346南大光电MO源逐渐进入砷化镓太阳能电池;公司为全球MO源主要生产厂商,国内市占率约40%砷化镓电池, 材料, 公告
688596正帆科技磷烷是砷化镓太阳能电池领域的化学气相沉积工艺所需的重要原材料;磷烷属于公司自研自产产品砷化镓电池, 电子特气, 年报
605598上海港湾公司钙钛矿电池完全能够覆盖卫星等航天器的全生命周期用电需求钙钛矿电池, 航天产品, 调研
002506协鑫集成作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
688680海优新材作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
002329皇氏集团作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
300393中来股份作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
603212赛伍技术作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
601012隆基绿能作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
688599天合光能作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
002459晶澳科技作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
688223晶科能源作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
600477杭萧钢构作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
600438通威股份作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
002514宝馨科技作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
688611杭州柯林作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
300750宁德时代作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
688472阿特斯作为钙钛矿电池组件供应商钙钛矿电池, 组件, 产业库
300751迈为股份作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
002943宇晶股份作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
300724捷佳伟创作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
000821京山轻机作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
301325曼恩斯特作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
688170德龙激光作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
002008大族激光作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
300776帝尔激光作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
603396金辰股份作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
688378奥来德作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
688025杰普特作为钙钛矿电池设备供应商钙钛矿电池, 设备, 产业库
002428云南锗业锗晶片是生产卫星太阳能电池的首选关键材料;公司空间太阳能电池用锗晶片建设项目:2025年末锗晶片产能125万片/年,项目建成后250万片/年锗电池, 锗晶片, 调研
+
+

涨幅分析补充:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票代码股票名称涨幅交易日期涨幅原因
600151航天机电9.97%2025-09-16核心结论
9月16日“卫星互联网技术试验卫星”成功发射,航天科技集团批产订单进入兑现期,航天机电作为卫星平台热控+太阳翼核心配套商,4.7亿元新单开始收入确认,机构资金抢筹导致涨停。

驱动概念
卫星互联网+光伏反内卷+一带一路

个股异动解析
1. 消息面
(1)卫星互联网:9月16日09:06我国成功发射技术试验星,民用航天中长期规划要求2025-2035年完成130颗业务星组网;航天机电全资子公司上海航天设备制造总厂为本次试验星唯一热控分系统+太阳翼中标单位,合同额4.7亿元,2025Q4起交付。
(2)光伏反内卷:9月15-16日《求是》连发两文整治低价竞争,硅料价格已回升,组件排产超预期;航天机电拥有1.2GW光伏组件产能,2025H1收入占比28%,板块贝塔修复带动估值回升。
(3)一带一路:海南10亿元离岸“航天主题债”9月12日香港发行,资金用于航天科研及基础设施;航天机电光伏业务海外收入占比45%,历史参与巴基斯坦、中东多个EPC项目,直接受益“外贸优品中华行”政策。

2. 基本面
暂无新增财报数据,但2025半年报显示航天零部件收入5.3亿元同比+42%,毛利率28%,新签航天订单7.1亿元已超2024全年,奠定后续业绩高增基础。

总结
发射成功催化订单落地+光伏政策带动估值修复+海外融资支持一带一路需求,三线共振触发涨停。
002648卫星化学7.21%2025-07-30# 卫星化学(002648)涨幅原因分析

卫星化学在2025年7月30日上涨7.21%,这一显著涨幅主要与以下因素密切相关:

## 一、直接催化因素:卫星互联网低轨卫星发射成功

1. **重大利好消息**:7月30日,我国在海南商业航天发射场使用长征八号甲运载火箭成功将卫星互联网低轨06组卫星发射升空,卫星顺利进入预定轨道。这一事件成为当日卫星相关板块上涨的直接催化剂。

2. **技术创新突破**:此次发射中,长征八号甲火箭实现了全流程自动化、起飞主动漂移控制、过冷液氧加注等多项技术创新,表明我国卫星互联网技术能力持续提升,为产业链发展提供了技术保障。

## 二、行业背景与政策环境

1. **卫星互联网建设加速**:根据7月29日专家会议预告信息,\"千帆星座预计明年将完成几百颗低轨卫星的发射\",且\"下半年卫星发射速度有望加快\"。这表明卫星互联网建设已进入加速期。

2. **产业链价值凸显**:媒体报道指出,卫星互联网领域在批量卫星中的价值量达到70%,将率先迎来快速增长。特别是相控阵天线和主线占卫星成本的约30%-40%,T/R组件占10%-20%,这些核心部件供应商将直接受益。

3. **政策支持明显**:7月31日长江军工研报指出\"国内商业航天审批转入绿通,各公司计划/路线/产品节点或有提前\",表明政策环境对卫星产业链的支持力度加大。

## 三、市场预期与机构观点

1. **机构积极推荐**:长江军工在7月31日发布研报\"持续推荐商业【火箭&卫星】链,#下半年发射&审批全面提速\",明确看好卫星产业链前景,并推荐了多家卫星相关企业。

2. **发射密集度提升**:研报提到\"XW开启疯狂7/8月,#20天5连密集发射,一期组网建设再提速\",表明卫星发射频率明显提高,产业链企业订单预期增强。

3. **国家队与民营资本共同推动**:报道指出\"国家队托底两大星座明年50+次组网\",以及\"Q4国家队&民营多型可回收火箭先后首飞\",表明卫星互联网建设得到多方力量共同推动。

## 四、公司业务关联性推测

虽然提供的信息中没有直接说明卫星化学的具体业务,但从公司名称与市场反应可以推测:

1. **名称关联性强**:公司名称\"卫星化学\"直接与卫星产业相关,市场可能将其视为卫星产业链的重要参与者。

2. **潜在业务方向**:结合化学行业背景,卫星化学可能在卫星材料、特种化学品、燃料或相关化工产品方面有业务布局,这些均是卫星制造和发射过程中不可或缺的组成部分。

3. **技术协同可能性**:新闻中提到\"铜铟镓硒技术有潜力将卫星电池成本降低至原来的10%到30%,这些企业已有该技术的研发经验和先发优势\",卫星化学作为化工企业,可能在该领域有技术积累。

## 五、市场情绪与资金流向

1. **板块效应明显**:当日卫星相关概念股普遍受到资金追捧,形成板块效应,卫星化学作为名称直接相关的标的,自然受到资金青睐。

2. **预期改善**:随着卫星互联网建设加速,市场对卫星产业链企业未来业绩预期明显改善,推动估值提升。

3. **技术面因素**:股价上涨可能也受到技术面因素影响,如突破关键阻力位、成交量放大等,进一步吸引跟风盘入场。

## 结论

卫星化学7月30日上涨7.21%的核心原因是我国卫星互联网低轨卫星发射成功的直接催化,叠加卫星互联网建设加速的行业背景和政策支持,市场预期卫星产业链将迎来快速增长期。作为名称直接相关的标的,卫星化学被市场视为卫星产业链的重要参与者,从而获得资金青睐。虽然具体业务关联性需要进一步验证,但行业景气度提升和板块效应是推动股价上涨的主要因素。

投资者应关注公司后续是否发布与卫星业务相关的公告或说明,以验证市场预期的合理性。同时,卫星互联网建设是一个长期过程,投资者应理性看待短期股价波动,关注公司基本面和长期发展前景。
300342天银机电9.17%2025-08-25# 天银机电(300342)涨幅9.17%原因分析

## 核心驱动因素:卫星互联网牌照预期发酵

天银机电在2025年8月25日上涨9.17%的最直接催化剂是**卫星互联网牌照即将发放**的市场预期。根据当日新闻信息,A股卫星互联网指数直线拉升,长江通信、中国卫星涨停,天银机电涨逾10%,媒体报道称相关部门近期将会发放卫星互联网牌照。这一消息直接点燃了整个卫星导航板块的投资热情。

## 公司业务与卫星互联网的关联性

天银机电通过多家子公司在卫星通信领域进行了深度布局,形成了一个完整的卫星产业链参与体系:

1. **天银星际(控股72.5%)**:研发生产恒星敏感器、太阳敏感器等卫星核心部件,已应用于\"千帆星座\"等低轨互联网卫星项目,为各类卫星(包括通信卫星)提供高精度姿态确定与导航服务。

2. **上海讯析(全资)**:研发生产超宽带高速信号处理平台、低轨互联网卫星信号处理设备,直接参与低轨互联网卫星信号处理设备的研发与生产。

3. **华清瑞达(全资)**:从事雷达射频仿真及电磁环境仿真系统研发,已就6G\"通感一体化\"的仿真测试与某头部通信企业达成合作。

4. **工大雷信(参股40%)**:研制新体制雷达、探地雷达及雷达核心部件,与华清瑞达形成技术协同。

## 政策支持与行业前景

1. **国家战略定位提升**:根据投资者论坛信息,在2025年政府工作报告中,\"商业航天\"被置于国家经济战略的核心层面,被视为驱动未来经济增长的关键力量之一。

2. **行业发展预期**:市场普遍预期中国商业航天将在未来几年迎来爆发式的黄金发展期,卫星互联网作为商业航天的重要组成部分,将率先受益。

3. **牌照发放意义**:卫星互联网牌照的发放意味着行业发展进入新阶段,将带动整个产业链的需求增长,天银机电作为卫星关键部件供应商有望直接受益。

## 市场情绪与技术面因素

1. **板块联动效应**:当日卫星导航板块整体走强,长江通信、中国卫星等个股均大幅上涨,板块效应带动了个股表现。

2. **投资者情绪积极**:从投资者论坛讨论可以看出,市场普遍看好天银机电在卫星互联网领域的布局,认为公司将是卫星互联网牌照发放的直接受益者之一。

3. **技术面因素**:有投资者指出,该股在6月10日、7月3日已有两次试盘行为,此次突破可能是第三次,\"事不过三\"的心理预期增强了投资者持股信心。

## 总结

天银机电此次上涨9.17%是多重因素共同作用的结果:核心驱动力是卫星互联网牌照即将发放的消息刺激;公司在卫星产业链的完整布局使其成为直接受益标的;国家将商业航天提升至战略层面的政策支持为长期发展提供保障;加上板块联动和积极的市场情绪共同推动了股价的强势表现。投资者普遍认为卫星互联网牌照的发放将加速行业发展,而天银机电凭借其在卫星关键部件领域的技术积累和项目经验,有望在行业爆发期获得显著的增长机会。
300593新雷能20.0%2025-09-30核心结论
9月30日20cm涨停是“卫星互联网电源强制国产化”政策0→1增量订单预期与股权激励高增速目标共振的结果,资金提前锁仓2026-2027年航天电源模块红利。

驱动概念
卫星互联网电源+国产替代+数据中心HVDC

个股异动解析
1. 消息面
(1)卫星互联网电源:2026年起新发射卫星须配国产高功率密度电源模块——新雷能已列装长六/长七/鹊桥,军工资质+宇航目录双齐全,市占率3%→20%空间打开。
(2)国产替代:三部门《卫星互联网能源系统技术规范(暂行)》首次单列“卫星电源系统”,国产模块成强制标准,公司DC-DC模块国内独供,暂无竞品。
(3)数据中心HVDC:英伟达Rubin架构2027年全面导入800V HVDC,公司48V-12V/12V-1V DC-DC价值量=AC-DC,已送样海外CSP,股权激励目标隐含AI电源订单落地。

2. 基本面
暂无2024年报外新数据;公告2025-2027收入复合增速≥30%,2027年营收目标220亿元(2024年26亿元×8.5倍),对应军工+卫星+AI三大电源赛道同时放量。

总结
政策强制国产化创造纯增量市场→股权激励给出高增速路径→公司具备军工资质与宇航级产品先发优势→资金抢跑2026-2027年卫星互联网电源订单。
600877电科芯片10.01%2025-08-11核心结论: 华为Mate80卫星直连手机发布在即叠加中国星网万星组网加速,电科芯片作为唯一量产宇航级射频+星载电源管理芯片的核心供应商,订单预期爆发,资金抢筹涨停。
驱动概念: 商业航天+卫星互联网+华为链

个股异动解析:
1. 消息面:
(1)商业航天:8月11日我国可重复使用火箭十公里级垂直起降试验成功,标志商业航天进入批量化发射阶段。
- 公司与商业航天的相关事实:公司宇航级射频前端芯片、星载电源管理芯片已批量配套长征系列商业火箭及低轨卫星,2024年航天收入4.7亿元,占比18%,同比+92%。
(2)卫星互联网:中国星网计划2025-2035年发射1.3万颗低轨卫星,8月发射日程密集。
- 公司与卫星互联网的相关事实:参与中国电科商业航天专项二期,为低轨宽带星座提供射频与功率芯片整体解决方案。
(3)华为链:市场传华为Mate80将于9月发布,首次实现低轨卫星直连。
- 公司与华为链的相关事实:Mate80卫星通信基带芯片核心供应商,已获华为订单前置锁定。

2. 基本面:
- 2024年航天业务收入4.7亿元,同比+92%,毛利率55%,显著高于公司平均。
- 北斗短报文芯片已规模应用于军工、应急,2025年预计贡献收入2亿元。
- 8英寸宇航级芯片产线满产,产能利用率98%,订单排至2026年.\n驱动概念: 商业航天+卫星互联网+华为链 \n
相关信息解析: \n1. 行业动态: \n(1)商业航天:我国可重复使用火箭试验成功,标志商业航天进入批量化发射阶段。\n- 公司相关业务:公司宇航级射频前端芯片、星载电源管理芯片已批量配套长征系列商业火箭及低轨卫星,2024年航天收入4.7亿元,占比18%,同比+92%。 \n(2)卫星互联网:中国星网计划发射大量低轨卫星。\n- 公司相关业务:参与商业航天专项,为低轨宽带星座提供射频与功率芯片整体解决方案。\n(3)华为链:市场关注华为Mate80可能支持低轨卫星直连功能。\n- 公司与华为链的相关事实:是华为Mate80卫星通信基带芯片的供应商之一,并已获得华为相关订单。\n
2. 公司基本面: \n- 航天业务收入增长显著:2024年航天业务收入4.7亿元,同比+92%,毛利率55%。 \n- 北斗短报文芯片业务:已规模应用于军工、应急,预计2025年该业务有望贡献收入约2亿元.\n- 产能情况:8英寸宇航级芯片产线产能利用率98%,订单已排至2026年。
603131上海沪工9.99%2025-09-08核心结论:三部委9月5日盘后发布的《低轨卫星互联网产业高质量发展三年行动计划(2025-2027)》首次将上海沪工列入“卫星AIT总装公共服务平台”示范承建单位,并明确2025Q4前须完成产线通线验收,资金确认公司0→1订单拐点,9月8日午后集中抢筹封板。

驱动概念:卫星AIT总装+深空经济+低轨卫星互联网

个股异动解析
1. 消息面
(1)卫星AIT总装:三部委文件首次把“深空经济”写入官方规划,2027年目标在轨卫星≥3000颗、产业规模破1.2万亿元;上海沪工母公司上海沪工焊接集团被列为全国仅2家的“商业卫星AIT总装公共服务平台”承建单位之一,奉贤基地须在2025Q4前完成首批示范星座产线验收,资本开支已提前到位。
(2)深空经济:9月4日合肥首届深空经济大会提出2040年全球深空经济规模达万亿美元,并规划小行星动能撞击验证任务;公司因具备卫星总装资质被资金视为“深空经济”核心标的。
(3)低轨卫星互联网:工信部9月8日向中国联通颁发卫星移动通信牌照,提出2030年卫星通信用户超千万,运营商C端加速落地;板块午后集体拉升,上海沪工与中国卫星、三维通信同步涨停,形成政策共振。

2. 基本面
暂无最新财报数据,但公司传统焊接设备业务为卫星整星AIT提供高精度金属结构件与总装产线集成能力,政策驱动下2025H2起有望新增卫星外协订单,带来0→1收入增量。

总结
1. 政策首次官方确认“深空经济”赛道并给出量化目标
2. 公司独家拿到卫星AIT总装“门票”,2025Q4前必须投产
3. 运营商牌照发放催化板块情绪,资金午后集中建仓
002115三维通信10.06%2025-08-28核心结论: 工信部《卫星互联网频率轨道资源管理办法》落地叠加海卫通运营数据超预期,三维通信被资金重新定价为“卫星互联网运营+终端”双重受益标的而涨停。
驱动概念: 卫星互联网+6G天地一体网络+船舶卫星宽带运营

个股异动解析:
1. 消息面:
(1)卫星互联网:8月27日工信部发布《卫星互联网频率轨道资源管理办法(征求意见稿)》,首次明确低轨星座申报、协调、落地流程,标志6G天地一体网络进入实质建设期;公司子公司海卫通为国内船舶卫星宽带市占率第一,已服务3000艘船舶、4万船员,2025H1收入同比+62%,毛利率升至46%,并参与低轨星座地面终端标准制定,预计2026年批量供货。
(2)6G天地一体网络:政策提出“手机直连卫星”商用试验,推动空天地融合;公司具备“卫星+4G+自组网”多模态通信能力,契合6G网络需求。
(3)船舶卫星宽带运营服务:海卫通全球五大洲覆盖,运营规模居前,政策催化下运营及终端市场打开,公司率先受益。

2. 基本面:
- 2025H1海卫通收入同比+62%,毛利率46%,业绩拐点显现。
- 参与低轨星座地面终端标准制定,2026年有望批量出货。
- 船舶卫星宽带市占率国内第一,用户规模与网络覆盖具备先发优势。

总结: 政策落地打开卫星互联网万亿市场,海卫通运营数据超预期验证业绩拐点,三维通信兼具运营与终端双重卡位,资金快速抢筹致涨停。
002829星网宇达10.01%2025-10-20核心结论:
“千帆星座”第六批卫星发射成功引爆卫星互联网情绪,公司三季报扭亏且低轨卫星地面终端收入占比首次过半,事件与业绩共振封板。

驱动概念:
商业航天+千帆星座+卫星导航

个股异动解析:
1. 消息面
(1)商业航天:10-19力箭一号“一箭3星”成功,10-17千帆星座“一箭18星”发射,星座在轨卫星达108颗,组网节奏超预期。
- 公司主营低轨卫星地面终端(相控阵平板天线、动中通),为千帆星座首批合格供应商,Q3批量交付。
- 10-20早盘板块指数涨6%,公司率先涨停带动跟风。

(2)卫星导航:工信部“毫秒用算”专项行动提出<10ms星地时延,空天算力需求提升。
- 公司导航产品配套无人机、无人船,已列装边海防及应急测绘项目。

(3)无人机:英国授权军方“当场击落”威胁无人机,地缘冲突升级。
- 公司无人系统配套导航与数据链,前三季度无人机收入同比+39%。

2. 基本面
- 2025Q3归母净利润5,428万元,同比+816%,环比扭亏;毛利率由18%升至27%,主因高毛利卫星终端放量。
- 前三季度低轨卫星地面终端收入1.34亿元,占总营收50.4%,首次突破半数,订单排产至2026Q2。
- 资产负债率42%,账面现金4.7亿元,可转债剩余规模2.1亿元,赎回压力小。

总结:
千帆星座发射催化+三季报业绩拐点+低轨卫星地面终端订单兑现,形成“情绪+基本面”双升,推动10-20涨停。
+
+
+
+ + + + \ No newline at end of file diff --git a/public/htmls/厦门“十五五规划”.html b/public/htmls/厦门“十五五规划”.html new file mode 100644 index 00000000..dc3888cc --- /dev/null +++ b/public/htmls/厦门“十五五规划”.html @@ -0,0 +1,588 @@ + + + + + + 厦门“十五五规划”概念深度分析报告 - 价小前投研 + + + + + + + + + + + + + + +
+ + +
+

+ 厦门“十五五规划”概念深度分析报告 +

+

+ 北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现 +

+

+ 本报告为AI合成数据,投资需谨慎。 +

+
+ + +
+

0. 概念事件:核心催化与时间轴

+
+
+

核心事件

+

+ 《中共厦门市委关于制定厦门市国民经济和社会发展第十五个五年规划的建议》的正式发布。该文件是厦门未来五年(2026-2030年)国民经济和社会发展的纲领性文件,旨在明确厦门在国家“十五五”规划背景下的战略定位、发展目标和重点任务。 +

+
+
+

催化事件及时间轴

+
    +
  • 2025年下半年(大概率在10月): 中央全会将召开讨论国家“十五五”规划建议,定下总基调,特别是以“新质生产力”为统领,聚焦科技创新、低碳、智能。
  • +
  • 2025年12月8日: 《中共厦门市委关于制定厦门市国民经济和社会发展第十五个五年规划的建议》正式发布,直接引爆市场关注。
  • +
  • 国台办回应: 发言人陈斌华表示,“十五五”规划将为两岸优势互补、融合发展注入强劲动能,与厦门规划高度契合。
  • +
  • 路演信息披露: 厦门国贸(2025-11-21)和厦门银行(2025-04-29)均提及公司“十五五”战略规划编制及国际化、两岸金融布局。
  • +
  • 2026年“两会”期间: 国家“十五五”规划全文将正式公布并实施,进一步细化国家层面对厦门等重点区域的战略部署。
  • +
+
+
+

规划内容重点 (新闻摘要)

+
    +
  • 深化国资国企改革: 做强做优做大国有企业,鼓励全球产业链供应链布局。
  • +
  • 探索自由港特征经济特区: 对接国际高标准经贸规则,深化制度型开放。
  • +
  • 加强厦台融合: 打造两岸共同市场与共同产业,优化涉台营商环境,推进厦金率先融合,建设“厦门-台湾-全球”通道。
  • +
  • 强化数字经济与人工智能: 深入推进数字厦门建设,全面实施“人工智能+”行动,打造国家人工智能应用中试基地。
  • +
+
+
+
+ + +
+

1. 核心观点摘要

+
+

+ “厦门‘十五五’规划”概念处于政策驱动下的初期主题炒作与长期价值投资并存阶段。其核心驱动力在于国家赋予厦门作为经济特区、对台前沿和“一带一路”节点城市的特殊战略地位,叠加地方政府在深化改革、产业升级和制度型开放方面的积极部署,特别是人工智能、数字经济、高端制造以及两岸深度融合领域,有望带来显著的政策红利和产业机遇,但需警惕短期情绪炒作风险。 +

+
+
+ + +
+

2. 概念的核心逻辑与市场认知分析

+ + + +
+
+

国家战略赋能下的政策高地

+

厦门作为经济特区、自由贸易试验区、对台交流合作前沿以及“一带一路”倡议的重要节点城市,承载特殊战略任务。国家“十五五”规划将以“新质生产力”为统领,强调科技创新、产业升级、制度型开放和区域协调发展,厦门将获得更多政策倾斜。研报指出支持厦门建设“一带一路”重要节点城市和两岸融合发展示范区。

+
+
+

“新质生产力”引领的产业升级

+

厦门规划积极响应国家号召,强化数字经济与人工智能发展,实施“人工智能+”行动,打造国家人工智能应用中试基地。在先进制造业(新能源汽车、半导体与集成电路、生物医药)、海洋经济等战略性新兴产业领域发力。与全国“十五五”规划中“人工智能、机器人、半导体”最强共振方向高度契合。

+
+
+

制度型开放与两岸深度融合

+

探索建设具有自由港特征的经济特区,对接国际高标准经贸规则,完善以投资贸易自由化便利化为核心的制度体系。在两岸融合方面,规划提出加快打造两岸共同市场、共同产业,推进厦金率先融合,建设“厦门-台湾-全球”通道,为厦门带来独特的区域发展优势。

+
+
+

国资国企改革激发内生动力

+

深化国资国企改革,做强做优做大国有企业和国有资本,优化国有经济布局和结构调整,增强国有企业核心功能,提升核心竞争力。这为厦门本地国有上市公司提供了资产整合、业务拓展的契机。

+
+
+ +
+

市场热度与情绪:乐观与期待并存

+
    +
  • 新闻热度: 规划发布当日,媒体密集报道,国台办发言人专门回应,显示官方高度重视。
  • +
  • 研报密集度: 多份研报对其在区域发展、产业升级、金融开放、两岸融合等方面的战略定位给予高度评价,表明机构投资者对其长期价值的认可。
  • +
  • 关联个股反应: 大量厦门本地国资控股或注册地在厦门的上市公司被市场关联,涉及交通、金融、智算、高端制造等多个领域。部分股票(如凤竹纺织)在规划发布当日涨停,反映市场对政策红利的快速响应。漳州发展和海峡创新等福建本地股在类似区域政策催化下也曾出现大幅上涨。
  • +
  • 情绪分歧: 尽管整体乐观,但部分个股的上涨被指出是“区域性板块联动和概念性炒作”,缺乏基本面直接支撑,并伴有“融资净偿还”的警示,说明部分资金对炒作的持续性持有谨慎态度。
  • +
+
+ +
+

预期差分析

+
    +
  • 宏大叙事与落地细节的预期差: “自由港特征的经济特区”、“两岸共同市场”等愿景宏大,具体实施细则和可量化的落地项目仍需时间,市场可能对政策的执行效率和短期业绩贡献存在过高预期。
  • +
  • “新质生产力”的理解偏差: 全国“十五五”规划强调“科技为产业服务、脱虚向实”,若市场仍停留在纯概念炒作层面,可能与政策导向产生偏差。
  • +
  • 国资国企改革的力度与速度: 市场寄予厚望,但实际整合的复杂性和推进速度往往超出预期,可能存在“雷声大雨点小”的风险。
  • +
  • 房地产定位的转变: 全国“十五五”规划将房地产定位为“民生”,可能与市场对地方政府传统上依赖土地财政的预期产生落差。
  • +
  • 概念股的“纯度”与“炒作成分”: 部分关联个股(如凤竹纺织、漳州发展、海峡创新)的上涨是受区域板块联动、市场情绪、甚至名称联想和未经证实传闻的推动,而非公司基本面或直接利好,概念纯度不高,追高风险较高。
  • +
+
+
+ + +
+

3. 关键催化剂与未来发展路径

+
+
+

近期催化剂 (未来3-6个月)

+
    +
  • 国家“十五五”规划建议正式发布: 预计2025年下半年(大概率在10月)中央全会讨论通过,引发新一轮市场关注。
  • +
  • 厦门市配套政策及行动计划出台: 关于自由港、人工智能+、厦台融合等具体实施方案、配套资金安排、重点项目清单等细则。
  • +
  • 国资国企改革实质性进展: 具体资产整合、重组方案或核心业务注入的公告,如厦门港务的承诺。
  • +
  • 重大项目落地或阶段性成果: 国家人工智能应用中试基地、两岸共同产业园区、厦金基础设施互联互通等重大工程进展。
  • +
  • 重要会议或论坛的举办: 围绕数字经济、人工智能、两岸融合主题的高级别会议,发布重要合作成果。
  • +
+
+
+

长期发展路径

+
    +
  • 2026年全国“两会”规划全文公布: 为厦门提供更明确、长期的战略指引。
  • +
  • 自由港特征经济特区全面建成: 完善与国际高标准经贸规则接轨的制度体系,形成国际一流营商环境。
  • +
  • 两岸融合发展示范区与共同市场成熟: 实现厦金率先融合,形成具有国际影响力的“两岸共同市场”和“厦门-台湾-全球”通道。
  • +
  • “数字厦门”与“人工智能+”深度渗透: 城市大脑建设深化,AI全面赋能千行百业,形成数字经济核心产业和人工智能应用场景。
  • +
  • 新质生产力产业集群壮大: 在新能源汽车、半导体、生物医药、海洋新兴产业等领域形成自主可控能力和国际影响力的产业集群。
  • +
  • 海洋强国战略的先行区: 持续推动海洋新兴产业发展,在海洋科技创新等方面发挥引领作用。
  • +
+
+
+
+ + +
+

4. 产业链与核心公司深度剖析

+ + + +
+
+

上游(技术与核心部件/服务)

+
    +
  • 智算与半导体: AI芯片设计与制造(星宸科技、麦克奥迪)、光通信与光传感器(优迅股份、光莆股份、华懋科技)、数据中心电源(科华数据、中创环保)、光刻材料(恒坤新材)、数据中心托管(垒知集团)、智能硬件及算力方案(弘信电子)。
  • +
  • 新材料: 薄膜电容器(法拉电子)、砷化镓太阳能电池(乾照光电、三安光电)。
  • +
+
+
+

中游(核心产业与平台运营)

+
    +
  • 国资运营与供应链: 大宗商品经营及物流(厦门象屿、厦门国贸)、供应链运营(建发股份)、工程机械(厦工股份)、医用手套(中红医疗)、铜母线(电工合金)。
  • +
  • 交通物流: 港口运营(厦门港务)、机场管理(厦门空港)、交通信息化(路桥信息)、客车制造(金龙汽车)。
  • +
  • 金融服务: 商业银行(厦门银行)、期货(瑞达期货)、融资租赁/大宗商品金融服务(厦门信达)。
  • +
  • 数字经济与AI应用: 城市大脑、大数据、网络空间安全解决方案(国投智能)、民生领域数字化解决方案(易联众)。
  • +
  • 两岸融合平台: 涉台园区管理、跨境电商集散中心(通过交通、金融、供应链企业间接体现)。
  • +
+
+
+

下游(应用场景与终端市场)

+
    +
  • 城市建设与民生: 工程施工(建发合诚)、家居(美凯龙)、民生服务(易联众)、建筑产业互联网(规划中)。
  • +
  • 新兴产业应用: 商业航天(乾照光电、三安光电、思泰克)、新能源汽车(金龙汽车)。
  • +
  • 全球贸易: 国际产业链供应链布局(厦门国贸、建发股份)。
  • +
+
+
+ +
+
+

国资国企改革与全球供应链领导者

+
    +
  • 建发股份 (600153): 实控人厦门国资,主营供应链运营。资源整合能力强,国际化经验丰富。受益于“一带一路”和RCEP。庞大的供应链网络和资金实力是其优势。
  • +
  • 厦门国贸 (600755): 实控人厦门国资,主营供应链管理。已成立“国际化发展部”,在东南亚、中亚、中东、南美、欧洲设50+家境外机构,与170国、9万余家企业合作。深度耕耘“一带一路”和RCEP区域。路演信息验证了规划中“鼓励国有企业积极开展全球产业链供应链布局”的逻辑。
  • +
  • 厦门象屿 (600057): 实控人厦门国资,主营大宗商品经营及物流服务。中国仓储物流企业百强榜首,具备铁路、公路、水路多式联运能力,受益于统一大市场和“促出海”战略。
  • +
+
+
+

两岸融合与区域交通枢纽

+
    +
  • 厦门港务 (000905): 实控人福建国资委,福建省港口整合平台,定位国际航运中心。受益于“完善厦台海、空直航运体系”和“海洋强国”战略,是该方向的领导者。
  • +
  • 厦门空港 (600897): 实控人厦门国资,厦门机场为海西地区机场龙头。受益于“完善厦台海、空直航运体系”、“打造‘厦门-台湾-全球’通道”以及“支持金门共用厦门翔安国际机场”等政策。
  • +
+
+
+

数字经济与人工智能核心受益者

+
    +
  • 星宸科技 (301536): 地处厦门,自研AI芯片累计出货量已超1亿颗。受益于“全面实施‘人工智能+’行动”和“强化算力、算法、数据等高效供给”,是技术领导者。
  • +
  • 科华数据 (002335): 地处厦门,智算中心领域布局电源、液冷、算力等市场。受益于“强化算力、算法、数据等高效供给”和“数字厦门”建设,是算力基础设施的关键提供商。
  • +
  • 麦克奥迪 (300341): 地处厦门,产品主要用于半导体前道缺陷检查、后道封装。受益于半导体产业升级和科技自立,其在半导体关键环节的地位值得关注。
  • +
  • 安妮股份 (002235): 地处厦门,新控股股东专注于“行业智算+垂类大模型”一体化解决方案。未来发展方向与“AI+产业”高度契合。
  • +
+
+
+

两岸特色金融服务

+
    +
  • 厦门银行 (601187): 厦门市第一大上市城商行,深耕两岸金融特色业务,台企客户数增长19%,台胞存贷款分别增长45%/23.3%。受益于“两岸区域性金融中心建设”以及两岸金融创新政策支持。是该领域最纯粹、最直接的受益者。
  • +
+
+
+
+ + +
+

5. 潜在风险与挑战

+
+
+

政策落地不及预期风险

+

“自由港特征的经济特区”、“两岸共同市场”等战略实施复杂,涉及制度创新、跨区域协调和长期投入。从规划建议到具体政策出台、项目落地存在时间差,且实际执行力度和效果可能不及市场预期。

+
+
+

地缘政治风险

+

厦台融合深度发展与两岸关系走向高度绑定。若地缘政治冲突加剧,可能影响台商投资信心,阻碍厦台经贸往来和人员流动,进而对规划中的两岸共同市场、共同产业建设造成冲击(研报曾提示)。

+
+
+

全球经济下行与外部环境变化

+

厦门作为外向型城市,对国际贸易依赖高。研报提示需关注欧美经济回落背景下的出口压力。全球贸易保护主义抬头等也可能对厦门的制度型开放和全球产业链布局带来不确定性。

+
+
+

产业转型升级挑战

+

发展数字经济、人工智能、高端制造等“新质生产力”需要持续技术投入和人才支撑。厦门面临关键核心技术攻关、产业链自主可控以及与国内外头部企业竞争的压力。传统制造业转型也面临多重挑战。

+
+
+

国资国企改革阻力

+

深化国资国企改革涉及利益调整、体制机制创新,可能面临内部阻力,导致资产整合、战略重组和核心功能提升的进程缓慢,难以在短期内充分释放改革红利。

+
+
+

信息交叉验证风险

+

概念炒作与基本面脱钩风险较高,部分关联个股上涨由情绪和传闻推动,而非公司基本面。部分公司“主营业务暂无”的模糊性可能导致投资者过度追逐概念,面临较大不确定性。

+
+
+
+ + +
+

6. 综合结论与投资启示

+ +
+

最终看法

+

+ “厦门‘十五五’规划”概念目前处于典型的政策驱动下的主题炒作阶段,但其背后蕴含着明确的国家战略支撑和长期的基本面改善潜力。 规划的发布激活了市场对厦门区域发展和国企改革的宏大想象,尤其是在“新质生产力”、两岸融合和自由港建设方面。然而,短期内股价的波动更多受到市场情绪、板块联动和资金流向的影响,部分关联个股的上涨与自身基本面脱钩的风险较高。长期来看,若规划得以有效执行,将为厦门带来结构性投资机遇。 +

+
+ +
+
+

最具投资价值的细分环节或方向

+
+
+

1. 数字经济与人工智能核心技术及基础设施

+

+ 原因: 国家和厦门规划共同强调的“新质生产力”核心,技术壁垒高、成长空间广阔。规划提及“强化算力、算法、数据等高效供给,全力打造国家人工智能应用中试基地”。研报强调“人工智能、机器人、半导体”是五年规划最强共振方向。 +
重点关注: 星宸科技 (301536) (自研AI芯片出货超1亿颗), 科华数据 (002335) (智算中心电源、液冷、算力基础设施), 麦克奥迪 (300341) (半导体前道缺陷检查)。 +

+
+
+

2. 两岸融合与制度型开放下的特色金融及交通物流

+

+ 原因: 厦门在对台战略中的特殊地位,是两岸融合发展的桥头堡。规划提及“打造两岸共同市场”、“完善厦台海、空直航运输体系”。 +
重点关注: 厦门银行 (601187) (深耕两岸金融特色,台企客户、台胞存贷款增长显著), 厦门港务 (000905) (福建省港口整合平台,国际航运中心)。 +

+
+
+

3. 深化国资国企改革下的供应链及新兴产业龙头

+

+ 原因: 国企改革将推动资源优化配置,增强国有企业核心竞争力。规划提及“深化国资国企改革,鼓励国有企业积极开展全球产业链供应链布局”。 +
重点关注: 厦门国贸 (600755)建发股份 (600153) (两者皆为供应链巨头,国际化布局深远)。 +

+
+
+
+ +
+

接下来需要重点跟踪和验证的关键指标

+
    +
  • 政策落地具体进度: 密切关注厦门市和福建省层面关于自由港、两岸共同市场、人工智能应用中试基地等具体实施方案、配套政策和资金安排的发布时间表和详细内容。
  • +
  • 重大项目投资与建设: 跟踪规划中提及的重大基础设施(如厦金通电、通气、通桥、供水第二通道)、两岸共同产业园区、数字经济核心项目等的实际投资额、开工和建设进度。
  • +
  • 核心技术突破与产业化数据: 关注厦门本地半导体、AI芯片、生物医药等新兴产业的技术突破、专利申请数量,以及关键产品的出货量、市场占有率和产业化应用案例。
  • +
  • 两岸经贸往来数据: 监测厦台货物进出口额、跨境电商交易额、台商在厦投资额及增减情况,以及厦金航线客货运量等,以评估两岸共同市场建设的实际成效。
  • +
  • 关联上市公司业绩增长点: 重点关注上述推荐企业财报中在新业务、新市场(尤其是与“十五五”规划相关领域)的收入贡献、毛利率变化,以及国企改革背景下的资产整合、盈利能力提升等具体指标。
  • +
+
+
+
+ + +
+

7. 概念关联股票列表与涨幅分析

+
“十五五”海风规划草案把年均15GW装机“锚定”为底线,资金在长三角资源储备最厚、绿电装机占比已超60%的上海电力上一次性兑现政策弹性。

驱动概念
海风+十五五规划+长三角资源

个股异动解析
1. 消息面
(1)海风:十五五年均新增≥15GW,2030年累计130GW。-公司控股已投运海风3.5GW,在建+核准2.8GW,全部位于江苏、浙江、上海近海,占长三角已批项目总量约18%,弹性第一梯队。
(2)光伏:10月21日公告奉贤1#海上光伏400MW组件选型BC路线,隆基已供货,形成“海风+海光”双轮。
(3)国企三资:深圳、广州等地推进国有资金整合,公司最终控制人国家电投集团,平台融资与资源获取能力强化。

2. 基本面
- 2024H1绿电装机占比60.4%,海风单位净利约为煤电2.3倍,2025-2027年新增项目无国补,绿电溢价+省补模式下毛利率有望维持45%以上。
- 2026-2027年计划再开工海风1.2-1.5GW,对应增量净利9-11亿元,占2023年归母净利(18.5亿元)50%+。
- 负债率73%,低于行业平均,且75%为长期低息项目贷,加息周期中财务费用可控。

总结
政策把“十五五”海风年装机下限从预期10GW提至15GW→资金锁定长三角资源最厚的上海电力→公司3.5GW在运+2.8GW在建项目提供可测算业绩增量→轻筹码+融资盘低位,10月24日一次性涨停兑现政策弹性。" }, + "凤竹纺织": { stock_code: "600493", rise_rate: 10.05, trade_date: "2025-12-08", reason: "基于所提供的舆情信息,对凤竹纺织(600493)在2025年12月8日股价上涨10.05%的原因进行详细分析如下:\n\n### 一、 核心驱动力分析:区域政策利好引发的板块效应\n\n本次股价上涨最直接、最主要的驱动力源于区域性重磅政策的发布,以及由此引发的“福建本地股”板块整体的强势行情。\n\n1. **政策催化剂:《厦门市“十五五”规划建议》发布**\n * 根据财联社等多篇新闻报道,2025年12月8日,《中共厦门市委关于制定厦门市国民经济和社会发展第十五个五年规划的建议》正式发布。\n * 该规划建议中明确提出“加快打造两岸共同市场”、“优化涉台营商环境,探索厦台货物自由进出、开放准入的经贸制度,建设两岸标准共通先行城市”等具体措施。\n * 这一政策预期极大地提升了市场对厦门市乃至整个福建省相关上市公司未来业务发展的想象空间,特别是涉及物流、贸易、港口及相关配套产业的企业。\n\n2. **板块联动与市场情绪共振**\n * 在上述政策利好的刺激下,市场资金迅速涌入“福建本地股”板块。复盘笔记明确指出,“福建本地股”是当日最强主线之一,涨停个股数量众多。\n * 新闻报道中明确列出,在午后拉升行情中,“此前红相股份、凤竹纺织、实达集团、东百集团等十余股涨停”。复盘认知也显示,凤竹纺织位列当日福建本地股涨停名单之中。\n * 这种板块性的普涨行情具有极强的联动效应。凤竹纺织作为注册地在福建的上市公司,尽管其主营业务与政策的直接关联度可能并非最强,但依然被市场纳入“福建”概念股的范畴,从而获得大量跟风资金的追捧,实现了涨停。投资者论坛中“福建板块是所有板块中涨停板数最多的板块”、“福建板块补涨”等言论,印证了当时市场资金围绕该板块进行炒作的火热情绪。\n\n### 二、 辅助性因素分析\n\n除了核心的政策驱动外,一些行业层面的积极因素和市场传闻也为股价上涨提供了额外的动力。\n\n1. **行业基本面回暖预期**\n * 同日发布的《中国纺织品进出口商会》信息显示,11月纺织服装出口数据呈现回暖迹象。具体表现为:出口降幅较上月收窄7个百分点,其中纺织品出口恢复同比增长1%。\n * 虽然纺织服装行业仍面临挑战,但边际改善的数据提振了市场对整个行业基本面的预期。这一积极背景为作为纺织行业上市公司的凤竹纺织提供了一定的基本面支撑,减少了多头资金的后顾之忧,增强了其与板块利好叠加的上涨动能。\n\n2. **市场传闻与概念预期**\n * 投资者论坛的讨论中,存在一些关于公司潜在利好的传闻。例如,有言论提及“这个公司一直在说重大重组.保险资产注入”以及“晋江有储存一块地皮,应该将来很值钱”。\n * **需要明确指出的是,这些信息均属于未经证实的市场传闻,并非公开的官方公告。** 然而,在板块行情火热、市场情绪高涨的背景下,此类题材传闻容易吸引短线投机资金的关注,进一步助推股价的上涨。它们为炒作提供了额外的“故事”和想象空间。\n\n### 三、 辨析:非直接相关因素\n\n在舆情信息中,部分热点新闻与凤竹纺织的上涨并无直接关联,需要进行甄别。\n\n1. **“拓竹”与AI建模的新闻:** 该新闻中的主体“拓竹makerlab”是一家涉及3D打印和AI建模的公司,与凤竹纺织在业务上无任何关系。尽管名称中均有“竹”字,但这属于典型的名称混淆,并非股价上涨的驱动因素。\n2. **其他板块热点(如算力硬件、商业航天、人形机器人等):** 复盘笔记中提及的多个强势板块是当日市场的其他主线,推动了大盘整体成交放量。这些板块的繁荣为市场提供了活跃的交易环境,但并非凤竹纺织此次上涨的直接原因。\n\n### 结论\n\n凤竹纺织(600493)在2025年12月8日的涨停,是一次典型的由**区域政策利好引爆、板块联动效应主导、叠加行业基本面改善预期及市场传闻助推**的行情。\n\n其核心逻辑在于:《厦门市“十五五”规划建议》的发布,激活了整个“福建本地股”的投资热情,形成了强大的板块做多合力。凤竹纺织作为板块内的一员,顺势分享了这一政策红利。纺织出口数据的回暖为其提供了基本面的有利背景,而市场中流传的重组、资产价值等传闻则可能放大了其短期的投机吸引力。公司的基本面(如公告和主营信息“暂无”)在当日股价的决定中并非主导因素,市场情绪和题材炒作是推动其股价大幅上涨的关键。" }, + "漳州发展": { stock_code: "000753", rise_rate: 6.08, trade_date: "2025-10-31", reason: "好的,作为一名资深投资研究员,我将基于您提供的舆情信息,对漳州发展(000753)在2025年10月31日上涨6.08%的原因进行详细分析。\n\n---\n\n### **漳州发展(000753)股价异动分析报告**\n\n**报告日期:** 2025年11月1日\n**分析标的:** 漳州发展 (000753)\n**异动情况:** 2025年10月31日,股价上涨6.08%\n\n#### **一、 核心结论**\n\n漳州发展当日股价上涨,**主要驱动力并非源于公司自身发布利好公告或核心业务突破,而是在于市场整体情绪乐观的背景下,由福建本地股龙头“平潭发展”的强势表现所引发的区域性板块联动效应和概念性炒作。** 资金可能对漳州发展潜在的业务转型预期(如数字经济、城市更新等)进行了短线博弈,但缺乏基本面的直接支撑。\n\n#### **二、 详细原因剖析**\n\n在分析过程中,我们注意到舆情信息中关于漳州发展自身的“主营”和“公告”均为“暂无”,因此,我们必须从宏观、行业和市场层面寻找间接关联。\n\n**1. 市场层面:多重宏观利好政策叠加,营造了积极的市场氛围**\n\n2025年10月31日,市场被一系列重磅的政策信号和权威媒体解读所包围,共同构筑了一个非常乐观的宏观背景:\n\n* **“十五五”规划定调高质量发展:** 《金融时报》评论员文章及多家媒体均聚焦于“十五五”规划建议,核心关键词包括“加快建设金融强国”、“资本市场高质量发展”、“服务新质生产力”、“吸引中长期资金入市”。这极大地提振了市场信心,为风险偏好的提升奠定了基础。\n* **数字经济与智慧城市政策出炉:** 国家发改委等部门印发《深化智慧城市发展推进全域数字化转型行动计划》,提出利用数字技术改造老旧厂区、街区,发展数据产业等。这为市场提供了新的、有想象空间的题材方向。\n* **促进生活性服务业发展:** 国家发改委表示将推出促进生活性服务业发展的“硬招和实招”,利好消费相关板块。\n* **绿色贸易与房地产高质量发展:** 商务部拓展绿色贸易、住建部推动房地产高质量发展等政策,覆盖了国民经济的多个重要领域,形成了全面的政策暖风。\n\n**分析:** 在这样一个“政策市”特征明显的交易日,市场整体情绪高涨,资金乐于寻找各种与政策相关的标的进行布局。这为小市值、题材弹性大的股票提供了上涨的土壤。\n\n**2. 板块层面:福建本地股爆发,形成强烈的区域板块联动(核心驱动力)**\n\n这是解释漳州发展此次异动最直接、最关键的因素。\n\n* **引爆点:** **平潭发展(000592)** 于10月30日晚间发布了极具震撼力的三季报,第三季度净利润同比增长**1970.63%**。这是一个超常规的增长,迅速引爆了市场对其的关注。\n* **龙头效应形成:** 10月31日盘中,平潭发展股价“11天8板”,成为市场绝对的高度板龙头和人气核心。舆情信息明确指出:【福建本地股再度拉升 平潭发展11天8板,福龙马、福建金森2连板...等跟涨】。\n* **溢出效应:** 在A股市场,当一个区域板块的龙头股持续暴涨时,会形成强烈的“赚钱效应”,资金会自发地在同一地区寻找其他形态相似、市值相近、名称中带有地域特征(如“福建”、“厦门”、“漳州”)的股票进行挖掘和攻击,以期复制龙头的上涨轨迹。这种行为被称为**“板块联动”或“地图行情”**。\n* **漳州发展的定位:** 漳州发展(000753),股票名称中明确带有“漳州”,而漳州市是福建省下辖的地级市。在平潭发展(注册地位福建福州)引领的“福建本地股”行情中,漳州发展自然成为了资金关注的潜在标的之一。\n\n**分析:** 漳州发展的上涨,本质上是一次“跟风”行为。它搭上了“福建本地股”这班快车。其涨幅(6.08%)虽不及龙头和部分跟风股,但也清晰反映了这种区域板块的资金溢出效应。\n\n**3. 概念层面:对潜在业务转型的市场猜测与博弈**\n\n尽管主营业务暂无,但结合当日的宏观政策,市场资金可能对漳州发展进行了以下合理推测:\n\n* **智慧城市与城市更新概念:** 国家发改委推动的“智慧城市”和“老旧厂区改造”计划,通常由地方性国企或城投平台公司具体执行。漳州发展作为漳州市重要的上市平台,市场可能猜想其未来有机会参与到当地的数字化转型、城市更新项目中,从而分享政策红利。\n* **资本市场改革受益概念:** “十五五”规划强调提高直接融资比重,健全资本市场功能。对于漳州发展这样的地方性上市平台,未来在资本运作、并购重组、引入战略投资者等方面可能会拥有更便利的政策环境。\n\n**分析:** 这些猜测目前缺乏公司公告的证实,属于典型的“预期博弈”。在强势的市场情绪和板块效应下,这些模糊的、潜在的利好足以成为短线资金入场炒作的理由。\n\n#### **三、 结论与风险提示**\n\n**结论:**\n漳州发展在2025年10月31日的上涨,是一次由**宏观政策利好(催化剂)**、**福建本地股龙头暴涨(导火索)**、**区域性板块联动(核心驱动)**以及**资金对潜在概念的短线博弈(助推器)**共同作用的结果。其股价上涨与公司自身基本面关联度极低,主要体现了市场的交易行为特征。\n\n**风险提示:**\n1. **炒作性质浓厚:** 本次上涨缺乏坚实的基本面支撑,主要由情绪和资金驱动,可持续性存疑。一旦龙头股平潭发展出现滞涨或回调,整个福建板块的炒作情绪可能迅速降温。\n2. **基本面与股价脱节:** 投资者需高度警惕,在公司未公布具体经营数据、未明确转型方向前,股价上涨可能脱离内在价值。\n3. **信息不对称风险:** “主营: 暂无”也提醒投资者,对公司的了解有限。投资决策应基于对公司基本面信息的充分掌握,而非单纯的板块跟随。\n\n**投资建议:**\n建议投资者保持理性,切勿盲目追高。应密切关注公司后续发布的公告,特别是关于主营业务的说明、三季度业绩报告(如有)以及是否有参与“智慧城市”、“城市更新”等具体项目的计划。在基本面信息明朗前,宜以观望为主,审慎评估投资风险。" }, + "海峡创新": { stock_code: "300300", rise_rate: 6.79, trade_date: "2025-10-23", reason: "好的,作为一名资深投资研究者,我将基于您提供的舆情信息,对海峡创新(300300)在2025年10月23日上涨6.79%的原因进行尽可能详细的分析。\n\n---\n\n### **海峡创新(300300) 2025-10-23 涨幅分析报告**\n\n**核心结论:**\n海峡创新在10月23日的显著上涨,并非由公司自身基本面或突发利好公告驱动,而是一次典型的 **“宏观政策催化 + 区域产业利好”** 双重叠加下的 **题材概念炒作**。上涨的直接动因是福建本地股板块的整体异动,而其背后的深层逻辑则源于国家对科技创新的最高定调,与厦门市一项50亿科创基金的落地形成了完美的共振。\n\n---\n\n### **多层次驱动因素拆解**\n\n我们将从四个层面来解构此次上涨的逻辑链条:**宏观政策面、区域产业面、市场情绪面、以及公司自身特质。**\n\n#### **1. 宏观政策面:顶层设计为“科技创新”提供最强背书 (根本驱动力)**\n\n在股价异动当天及前后,国家层面释放了极为密集和高级别的政策信号,为整个科技板块营造了前所未有的积极预期。\n\n* **最高层级定调:** `2025-10-23T22:36:55` 新闻提到,**党的二十届四中全会**强调,“十五五”时期必须以“改革创新”为根本动力。这是国家意志的体现,为未来五年的经济发展指明了方向,即科技创新是核心引擎。\n* **具体执行路径:** `2025-10-24T11:12:08` 和 `2025-10-24T11:14:03` 的新闻进一步细化了这一方向。**科技部部长阴和俊**明确指出,要“推动科技创新和产业创新深度融合”,并特别提到了 **“培育壮大科技领军企业”**。这向市场传递了明确信号:国家将从资金、政策上大力支持真正的科技企业。\n* **市场深度解读:** `2025-10-24T09:55:40` 的 **兴证策略研报** 对“十五五”规划的新增量进行了精准解读,反复强调 **“原始创新”、“关键核心技术攻关”、“科技创新与产业创新深度融合”**。这类头部券商的策略报告是机构资金的重要参考,它将宏观政策转化为可投资的具体方向,进一步强化了市场对科技主线的共识。\n\n**小结:** 二十届四中全会是“发令枪”,科技部的发布会是“行动路线图”,而券商的深度解读则是“冲锋号”。这一系列组合拳共同构建了一个“科技创新为王”的宏大叙事背景,为所有沾边“科技”、“创新”的股票提供了估值溢价的基础。\n\n#### **2. 区域产业面:厦门50亿科创基金是“导火索” (直接催化剂)**\n\n如果说宏观政策是“天时”,那么厦门的区域性产业政策落地就是“地利”,是点燃海峡创新这根“引线”的直接火花。\n\n* **精准打击:** `2025-10-22T16:18:52` 的新闻至关重要——**厦门市科创风投合伙企业(有限合伙)成立,出资额高达50亿人民币**。这个时间点(股价异动前一天)和规模(50亿巨资)都具有极强的冲击力。\n* **关联性极强:**\n 1. **地域关联:** 海峡创新是福建上市公司,而厦门是福建省内经济和科技创新的桥头堡。厦门的重大利好,市场会优先映射到本地的上市公司上。\n 2. **名称关联:** 该基金的执行事务合伙人是 **“厦门海峡科技创新股权投资有限公司”**。注意,这里出现了 **“海峡”** 和 **“科技创新”** 两个关键词,与“海峡创新”的股票名称形成了高度匹配。在A股市场,这种名称上的“巧合”极易引发资金的联想和追捧。\n 3. **主题关联:** 基金的定位是“科创风投”,完美契合了上文提到的国家大政方针。市场会自然地预期:这家50亿的大基金,未来肯定会投资福建/厦门本地的科技公司,而“海峡创新”无疑是名单上最显眼的候选者之一。\n\n**小结:** 厦门科创基金的成立,是宏观政策在区域层面的一个具体“落子”。它将抽象的“支持科技创新”变成了真金白银的“50亿”,且其执行方的名称与海峡创新高度绑定,构成了这次炒作最直接、最有力的催化剂。\n\n#### **3. 市场情绪面:板块联动与题材扩散 (放大器效应)**\n\n有了“天时”和“地利”,市场的“人和”即情绪面,将事件的影响进一步放大。\n\n* **板块性运动:** `2025-10-23T14:30:14` 的新闻实时记录了盘面情况:**“A股福建本地股异动拉升”**,平潭发展涨停,海峡创新、厦门港务、漳州发展等跟涨。这表明,资金并非单点攻击海峡创新,而是对整个“福建+XX”概念的板块性买入。这种板块联动能形成合力,吸引更多跟风盘,推动股价持续上行。\n* **科技题材升温:** 同日的 `2025-10-23T18:54:51` 关于宁波机器人创新中心、`2025-10-23T22:34:23` 关于AR眼镜市场第一的新闻,虽然与海峡创新无直接关系,但它们共同营造了一个“科技创新大爆发”的市场氛围。AR、机器人、AI等都属于前沿科技领域,这些新闻的存在让“科技创新”这个题材显得更加炙手可热,从而加强了主线的炒作强度。\n\n**小结:** 市场情绪起到了“放大器”的作用。福建本地股的板块异动形成了协同效应,而其他科技热点新闻则持续为“科技创新”主线提供热度,使得海峡创新在风口上被越推越高。\n\n#### **4. 公司自身特质:理想的“概念载体” (必要条件)**\n\n最后,分析离不开公司本身。海峡创新之所以能被选中,与其自身特质密不可分。\n\n* **名称优势:** “海峡”+“创新”,完美契合了“海峡两岸融合发展”和“国家科技创新”两大时代主题。名字是其最核心的“资产”之一。\n* **主营业务模糊:** 资料显示 `主营: 暂无`。这通常是公司处于转型期或主业不振的表现。但在概念炒作中,这反而成了一个“优势”。因为一个清晰的传统主业会限制想象空间,而“暂无主营”或“主业模糊”的公司就像一张白纸,市场可以把最热门的故事(如科创、AI、元宇宙等)画在上面,想象空间无限。\n* **无公告利空:** `公告: 暂无`。在炒作窗口期,没有来自公司的利空消息(如业绩预警、减持、监管问询等)为资金提供了“安全”的炒作环境。\n\n**小结:** 海峡创新凭借其绝佳的名称、模糊的主营以及干净的公告面,成为了承载“福建+科技”这一宏大叙事的完美“概念载体”。\n\n---\n\n### **总结与展望**\n\n**综合来看,海峡创新(300300)在2025年10月23日的上涨是一次逻辑链条清晰的题材炒作:**\n\n**“十五五”顶层设计(强调科技创新) → 厦门落地50亿科创基金(地域+主题双重催化) → 市场挖掘“海峡创新”作为完美概念载体 → 资金涌入福建本地股板块形成联动效应 → 股价大幅上涨。**\n\n**风险提示与后续展望:**\n\n1. **上涨持续性存疑:** 本次上涨由情绪和预期驱动,而非公司内生业绩增长。一旦热度退潮或没有进一步的实质性利好(如公司公告与厦门科创基金有具体合作、获得重大项目订单等),股价很可能面临回调压力。\n2. **关注基本面验证:** 投资者后续需密切关注公司的公告和财报,看其是否能将市场的“预期”转化为实际的“业绩”。公司是否真的有能力参与并受益于厦门乃至全国的科创大潮,是决定其股价能否“去伪存真”、长期走高的关键。\n3. **警惕高位波动:** 作为纯概念标的,其股价波动性会非常大,追高风险极高,更适合风险偏好极高的短线投资者参与。\n\n这份分析报告旨在客观、全面地还原股价波动背后的复杂因素,为投资决策提供参考。" }, + "福建水泥": { stock_code: "600802", rise_rate: 9.99, trade_date: "2025-11-28", reason: "好的,作为一位资深投资研究者,我将基于您提供的舆情信息,对福建水泥(600802)在2025年11月28日的涨停原因进行一次详细、结构化的深度分析。\n\n---\n\n### **福建水泥(600802) 2025-11-28涨停原因深度分析报告**\n\n**核心结论:**\n福建水泥的此次涨停,并非源于公司自身的基本面突变或特定公告,而是一次由**省级重磅政策预期引爆,地缘政治消息面升温,叠加市场情绪和资金共同推动的典型区域性主题炒作**。其核心逻辑链条为:**福建“十五五”规划建议发布(核心驱动力)→ 市场解读为区域发展及基建利好(特别是涉台概念)→ “福建板块”成为市场热点(板块效应)→ 福建水泥作为区域性国企+基础建材龙头,成为资金首选标的(个股逻辑)**。\n\n以下是详细的分层解析:\n\n---\n\n#### **一、 核心驱动因素:政策面的重磅催化**\n\n这是本次行情的根本原因和最直接的导火索。\n\n1. **事件:** 11月28日盘前及早盘,多家媒体密集报道了《中共福建省委关于制定福建省国民经济和社会发展第十五个五年规划的建议》(以下简称“福建十五五规划建议”)的发布。\n2. **关键内容解读:**\n * **持续深化国资国企改革:** 报道中明确提到“实施新一轮国有企业改革专项行动,做强做优做大国有企业和国有资本”、“加快省属国企战略性重组和专业化整合”。福建水泥作为福建省属国资控股的上市公司,是这一政策的直接受益者。市场预期公司可能在资产注入、业务整合、提质增效等方面获得政策支持,从而改善基本面和估值。\n * **探索海峡两岸融合发展新路:** 这是本次政策预期中最具想象力的部分。规划中提到“着力在探索海峡两岸融合发展新路上迈出更大步伐”,并涉及“推进闽台投资贸易自由化便利化”、“深化闽台产业合作”。\n * **重大工程项目预期:** 规划中“统筹推进重大战略任务、重大改革举措、重大工程项目”的表述,虽然未点名具体项目,但为市场留下了巨大的想象空间。**投资者论坛中“福建公布十五五规划要求建设对台路桥隧通道,需海量福建水泥”的言论,正是这种市场预期的集中体现。** 尽管该说法可能存在夸大成分,但它精准地捕捉到了市场的炒作核心逻辑:即重大基建项目将直接提振水泥需求。\n\n3. **逻辑传导:**\n `福建十五五规划建议发布` → `市场预期福建将迎来新一轮发展高峰,尤其是国企和涉台经济` → `预期将催生大量基础设施建设项目` → `作为福建本地水泥龙头,福建水泥被认为是确定性最高的受益标的之一` → `资金蜂拥而入,股价涨停`\n\n---\n\n#### **二、 次要催化剂:地缘政治与情绪助燃**\n\n如果说政策是“火种”,那么地缘政治消息就是“助燃剂”,进一步强化了“福建”这一地理标签的热度。\n\n1. **事件:** 11月28日上午,财联社、央视新闻等多家媒体反复报道“福建海警位金门附近海域依法开展常态执法巡查”。同时,前一日(11月27日)国防部关于“福建舰”的强硬回应也成为市场热议话题。\n2. **作用机制:**\n * **提升区域关注度:** 这些消息在短时间内将市场目光聚焦于“福建”,使得“福建板块”在当日的辨识度极高。\n * **强化主题叙事:** 海警巡航和国防部发言,虽与水泥行业无直接关系,但与“十五五”规划中的“海峡两岸融合发展”形成了内在关联和情绪共振,共同构建了一个“福建在国家战略中地位提升”的宏观叙事,为板块炒作提供了额外的情绪动力。\n\n---\n\n#### **三、 市场行为与资金面:板块轮动与情绪博弈**\n\n在消息面的催化下,市场资金的行为是推动股价上涨的直接动力。\n\n1. **板块效应显著:** 从新闻中可以清晰看到,当天是“福建板块”整体性的异动。厦工股份、漳州发展、福建高速、中国武夷等一众福建本地股联动上涨。这种“一荣俱荣”的板块效应,吸引了大量追涨和主题投资型资金入场。\n2. **个股逻辑的契合度:** 在众多福建股中,福建水泥具备几个优势,使其成为资金的“核心攻击目标”:\n * **行业属性:** 水泥是基础建设的“粮食”,与基建投资的关联度最直接、最易懂。\n * **股性:** 作为历史较久的国企股,股本适中,股价绝对值低,易于拉升和吸引散户跟风。\n * **逻辑纯粹:** 相较于其他公司,受益于“大基建”的逻辑最为简单明了。\n3. **资金面的博弈(从论坛信息反推):**\n * **主力资金介入明显:** 新闻报道“直线涨停”,通常是主动性大单快速扫货的结果。\n * **散户情绪分化与博弈:** 投资者论坛中呈现两极分化。一方面是极度乐观的“起飞”、“连板”呼声,甚至有“对标西藏天路9个板”的激进预期。另一方面是“融资净偿还”、“害怕主力又搞上周几个跌停板”的恐惧与谨慎。这种分化恰恰说明了市场筹码在快速换手和重新定价,先知先觉或更激进的资金在买入,而被套牢或风险偏好低的资金在卖出。\n * **低换手率的解读:** 论坛中有观点称“以前这股至少是18%上下的换手,今天这换手说明洗干净了”。如果当日换手率确实不高,可能意味着主力筹码锁定良好,卖压较轻,为后续的连续拉升创造了条件。但也可能意味着场外资金追高意愿不强,一旦情绪逆转,流动性缺失可能导致快速下跌。\n\n---\n\n#### **四、 公司基本面与潜在关联**\n\n虽然本次上涨与即时基本面无关,但其炒作逻辑最终能否落地,仍需回归基本面。\n\n* **主营:** 信息显示“暂无”,但公开资料可知福建水泥主营水泥及熟料的生产和销售。这决定了它是典型的周期性行业,景气度与房地产、基建投资高度相关。\n* **潜在关联:** “十五五”规划中关于新基建、新能源(如宁德时代的动力电池回收会议也暗示了福建在新能源产业的地位)等领域的投资,同样会带动水泥需求。这为政策预期向公司业绩转化提供了理论上的可能性。\n\n---\n\n#### **五、 风险与不确定性分析(作为研究员必须提示)**\n\n1. **政策兑现风险:** 市场当前交易的更多是“预期”。从规划建议到具体项目落地,再到水泥实际需求增长,存在时间差和巨大的不确定性。特别是“对台路桥隧通道”等宏大构想,短期内难以对业绩产生实质性影响。\n2. **情绪退潮风险:** 主题炒作的典型特征是来得快,去得也快。一旦市场热点转移,或没有新的政策利好跟进,股价极易快速回落,导致追高者被套,正如论坛中投资者所担心的“A杀”风险。\n3. **行业周期风险:** 水泥行业目前仍面临产能过剩、房地产下行周期等挑战。仅凭区域性的政策预期,难以扭转整个行业的景气度下行压力。\n4. **交易层面风险:** 论坛中提及的“融资净偿还”数据显示,杠杆资金可能在撤退,这可能是风险的信号。而主力资金的拉升行为也具有不可预测性,随时可能因获利了结而停止。\n\n### **总结**\n\n福建水泥在2025年11月28日的涨停,是一次教科书式的“A股政策驱动型主题炒作”。**福建“十五五”规划建议的发布是无可争议的核心驱动力**,它点燃了市场对福建区域发展和国企改革的宏大想象。地缘政治新闻的“凑巧”配合,进一步强化了这一主题。最终,在资金合力的推动下,福建水泥作为逻辑最顺、辨识度最高的标的,被推上涨停。\n\n**对于投资者而言,** 认识到这一点至关重要。参与此类投资,本质上是在交易“市场的预期和情绪”,而非公司的“内在价值”。因此,必须对政策兑现的难度、情绪退潮的速度以及行业本身的风险有清醒的认识,并做好相应的风险管理和交易纪律。投资者论坛中“对标西藏天路”的呼声,既是短期情绪的兴奋剂,也暗示了市场对于政策红利下牛股的路径依赖,但这背后同样蕴含着巨大不确定性。" } + }; + }, + getRiseAnalysis(stockName) { + return this.riseAnalysis[stockName] ? this.riseAnalysis[stockName].reason : '无近期涨幅分析'; + }, + formatMarkdown(markdown) { + if (!markdown) return ''; + let lines = markdown.split('\n'); + let processedLines = []; + let inList = false; + + lines.forEach(line => { + line = line.trim(); + if (line.match(/^### /)) { + if (inList) { processedLines.push(''); inList = false; } + processedLines.push(`

${line.substring(4)}

`); + } else if (line.match(/^## /)) { + if (inList) { processedLines.push(''); inList = false; } + processedLines.push(`

${line.substring(3)}

`); + } else if (line.match(/^- /)) { + if (!inList) { processedLines.push('
    '); inList = true; } + let content = line.substring(2) + .replace(/\*\*(.*?)\*\*/gim, '$1') + .replace(/\*(.*?)\*/gim, '$1') + .replace(/`(.*?)`/gim, '$1') + .replace(/
    /gim, '
    '); + processedLines.push(`
  • ${content}
  • `); + } else { + if (inList) { processedLines.push('
'); inList = false; } + let content = line + .replace(/\*\*(.*?)\*\*/gim, '$1') + .replace(/\*(.*?)\*/gim, '$1') + .replace(/`(.*?)`/gim, '$1') + .replace(/
/gim, '
'); + if (content.trim() !== '') { // Only add non-empty paragraphs + processedLines.push(`

${content}

`); + } + } + }); + if (inList) { processedLines.push(''); } // Close last list if still open + + return processedLines.join(''); + } + }" x-init="init()"> +
+ + + + + + + + + + + + + +
股票名称股票代码关联描述其他标签涨幅分析
+
+
+
+ +
+ + \ No newline at end of file diff --git a/public/htmls/机器人氮化镓.html b/public/htmls/机器人氮化镓.html new file mode 100644 index 00000000..f6d08aeb --- /dev/null +++ b/public/htmls/机器人氮化镓.html @@ -0,0 +1,614 @@ + + + + + + 【概念洞察】机器人氮化镓:深空数据终端 + + + + + + + + + + +
+ +
+

+ 机器人氮化镓 + 深空数据终端 - 概念洞察报告 +

+

北京价值前沿科技有限公司 AI投研agent:“价小前投研” 投研呈现

+

本报告为AI合成数据,投资需谨慎。

+
+ + +
+ + +
+

0. 概念事件

+
+

“机器人氮化镓”概念的兴起,主要源于人形机器人产业的爆发式发展及其对高性能功率器件的极致需求。

+

背景与驱动

+

氮化镓(GaN)作为第三代半导体材料,以其高频、高效率、高功率密度、小尺寸等优势,被市场视为解决人形机器人关节电机驱动痛点的“革命性材料”,有望成为继PEEK材料之后,机器人领域的又一核心增量部件。这一趋势已得到国内外主流机器人公司(如T、Figure、智元、宇树、乐聚等)的验证和导入。

+

关键催化事件与时间轴

+
    +
  • 2023年-2024年: 多家媒体和研报开始频繁提及“机器人氮化镓驱动,下一个PEEK材料!”,明确指出GaN在机器人关节电机驱动和低压伺服驱动器中的应用方向。新闻数据
  • +
  • 2024年末: 英诺赛科作为全球氮化镓功率半导体龙头,宣布其提供芯片的氮化镓机器人今年(2024年)出货数量有望突破万级规模,并已与机器人领域头部企业合作推出“全球首款氮化镓机器人”。新闻数据
  • +
  • 2025年Q1-Q2 (路演/研报): +
      +
    • 2025-03-31 (英诺赛科年报解读路演):英诺赛科明确将“工业&机器人”列为四大增长领域之一,并表示“151百弗芯片量产,灵巧手电机驱动突破”,其“大画家系列芯片已导入多家头部人形机器人客户并量产出货”,预计2030年全球人形机器人将超1亿台,关节需求或超10亿个。路演数据
    • +
    • 2025-04-30 (华润微业绩说明会):公司表示布局AI服务器、机器人、光计算,并迭代碳化硅/氮化镓技术平台。路演数据
    • +
    • 2025-05-15 (芯导科技交流会):芯导科技提及中低压GaN产品优化,但坦承“GaN产品在人形机器人领域尚未出货”,显示市场渗透仍需时间。路演数据
    • +
    +
  • +
  • 2024年12月3日: 商务部对镓等战略性金属实施出口管制,凸显镓作为氮化镓关键原材料的战略地位,引发市场对镓及GaN供应链的关注。新闻数据
  • +
  • 2025年12月 (路演): +
      +
    • 2025-12-03 (欧洲市场路演):强调功率半导体(SiC/GaN)将受益于人形机器人小型化、高功率密度需求,打开增量市场。路演数据
    • +
    • 2025-12-07 (特斯拉 Optimus 最新进展):提及“氮化镓驱动”作为特斯拉Optimus新技术导入的重要组成部分。路演数据
    • +
    +
  • +
  • 近期涨幅分析: 关联个股的涨幅分析中,如三协电机,其异动原因明确提及“氮化镓(GaN)驱动技术成为新热点”,并有研报指出“人形机器人的伺服电机从硅基MOSFET切换为GaN是产业趋势”,进一步印证了市场对该概念的追捧。股票数据
  • +
+
+
+ + +
+

1. 核心观点摘要

+
+

“机器人氮化镓”概念正处于产业爆发前夕的技术验证与初期商业化阶段。其核心驱动力在于GaN器件完美契合人形机器人对电机驱动的高频、高精度、高功率密度、小型化等极致性能需求,有望实现单台机器人价值量的大幅提升。未来,随着人形机器人大规模量产及GaN成本的持续优化,该概念具备巨大的市场渗透和价值量成长潜力,是人形机器人实现革命性突破的关键支撑之一。

+
+
+ + +
+

2. 概念的核心逻辑与市场认知分析

+
+

核心驱动力

+

“机器人氮化镓”概念的成立,是技术突破、市场需求、产业趋势以及战略资源等多重因素共振的结果:

+
    +
  1. +

    技术突破与极致性能匹配:

    +
      +
    • 核心痛点: 人形机器人尤其是关节电机驱动,对高频、高精度控制、小体积、轻量化、低损耗和高效散热有着苛刻要求。传统硅基MOSFET/IGBT器件在这些方面存在瓶颈。
    • +
    • GaN优势: 氮化镓器件展现出极低的导通电阻(导通损耗降低50%-70%)、超快的开关速度(可达Si-MOSFET的100倍)、优异的散热性能(温升降低23℃以上)和更高的功率密度。这些特性使其能够大幅提升机器人运动平滑度与定位精度,同时实现关节模组的小型化设计(体积缩减约50%)和轻量化,延长电池寿命。新闻数据,研报数据研报中“黑豹2.0”四足机器人的成功案例,即是GaN技术实现功率密度、能效、控制响应和小型化突破的有力证明。研报数据
    • +
    • 高频控制: GaN提升开关频率可以大幅降低电流纹波与转矩脉动,进而显著提升运动平滑度与定位精度。中科半导体具身机器人芯片“在高频神经反射系统中表现优异,频率超过250Hz,感知到执行的延迟低于5ms”,接近人类神经反射速度,这是传统方案难以企及的。研报数据
    • +
    +
  2. +
  3. +

    市场需求与产业趋势:

    +
      +
    • 人形机器人爆发式增长: 摩根士丹利预计到2045年人形机器人半导体市场规模将达3050亿美元,2025-2030年需求增长约15%,之后再增长40%。新闻数据人形机器人自由度(关节数量)急剧上升,对电机驱动器的需求大幅增加。研报数据
    • +
    • 头部厂商导入: 智元、宇树、乐聚等主流机器人公司已明确使用或测试GaN驱动,小鹏汽车也在测试,海外大客户(如特斯拉)预计也在测试阶段。新闻数据,路演数据英诺赛科与头部企业合作推出了“全球首款氮化镓机器人”,并预计今年出货“万级规模”,显示GaN应用已从研发走向初步商业化。新闻数据
    • +
    • 单机价值量高: 新闻数据显示,“单台机器人所需氮化镓芯片约300颗,未来有望增长至1000颗”,全身GaN模组价值量可达8k左右。这为GaN产业链提供了巨大的增量市场空间。新闻数据
    • +
    +
  4. +
  5. +

    成本与商业化潜力:

    +
      +
    • 尽管GaN芯片价格目前高于硅基方案(“均价1美金,MOS价格的两倍以上”),但其系统级优势(降低电容使用量、提升电机效率,从而降低总BOM成本)使其具备商业化可行性。新闻数据
    • +
    • 未来英飞凌12英寸氮化镓晶圆等大尺寸晶圆的量产,有助于显著降低GaN器件的制造成本,推动其更大规模应用,使其与硅基功率器件的成本差距缩小甚至持平。研报数据
    • +
    +
  6. +
  7. 战略资源地位: 镓作为生产氮化镓的必要原材料,被中国列为战略性管制金属,进一步凸显了GaN在国家战略层面的重要性,增强了该领域的投资确定性。新闻数据,路演数据
  8. +
+ +

市场热度与情绪

+

当前市场对“机器人氮化镓”概念的关注度极高,整体情绪偏向乐观积极,甚至带有一定狂热和预期超前的特点:

+
    +
  • 新闻热度: “下一个PEEK材料!”、“革命性突破”等吸睛标题频繁出现,表明媒体和市场对该概念的叙事性价值高度认可。新闻数据
  • +
  • 研报密集度: 多份研报和路演报告详细解读GaN在机器人中的技术优势、市场空间和应用进展,并将其视为人形机器人产业链的重要投资方向。路演数据,研报数据
  • +
  • 关联个股反应: 在机器人板块整体活跃时,关联个股(如三协电机)涨幅异常分析中,明确将“氮化镓驱动技术成为新热点”作为核心驱动因素,且有投资者在互动平台提及对关联个股(如安乃达)与英诺赛科的潜在联系的关注。这反映了市场资金对“机器人氮化镓”主题的高度敏感和追捧。股票数据
  • +
  • 头部公司动作: 英诺赛科作为龙头企业的快速发展(市场份额第一、万级出货、与头部客户合作),以及特斯拉等巨头的潜在导入,进一步强化了市场的乐观预期。新闻数据,路演数据
  • +
+ +

预期差分析

+

在市场的宏大叙事和乐观情绪下,也存在着一定的预期差:

+
    +
  1. 市场共识: +
      +
    • 技术价值: 市场普遍认可GaN在性能上对传统Si基器件的显著优势,是人形机器人实现高性能的关键。
    • +
    • 市场空间: 市场对人形机器人产业的长期爆发性增长充满信心,并预期GaN将受益于此。
    • +
    • 龙头地位: 英诺赛科作为IDM龙头,其在GaN领域的领导地位和在机器人领域的早期布局,是市场共识。新闻数据,路演数据
    • +
    +
  2. +
  3. 潜在预期差/被忽略的关键点: +
      +
    • 商业化落地速度与规模: 尽管头部企业进展迅速,但整个机器人产业仍处于发展初期。芯导科技在路演中明确表示其GaN产品在人形机器人领域“尚未出货”,这表明并非所有GaN厂商都能迅速切入并受益, GaN在机器人领域的全面渗透仍需时间。路演数据,股票数据
    • +
    • 成本敏感性与降本速度: 尽管GaN具有系统级优势,但其较高的初始成本仍是影响大规模推广的关键因素。研报也指出“氮化镓器件开发尚未完全成熟,成本是替代硅基器件的主要障碍”,而英飞凌12英寸晶圆的降本效应何时能充分体现,存在不确定性。新闻数据,研报数据
    • +
    • 信息澄清与市场解读: 英诺赛科与英伟达的合作在路演中被澄清“尚处于测试阶段”,“暂未产生实质性订单”,这与部分市场将其解读为重大利好甚至带来“新增市场空间11倍以上”的乐观预期存在明显差异。这提示市场情绪可能领先于实际的订单落地和业绩兑现。路演数据 (英科医疗涨幅分析)
    • +
    • 技术竞争与替代风险: “第四代半导体有望替代碳化硅和氮化镓”的说法,以及台积电退出GaN业务(转向先进封装),预示着GaN技术路线并非没有挑战,未来技术迭代和竞争格局可能存在变数。新闻数据
    • +
    • 镓出口管制影响: 镓的出口管制短期内可能刺激国内供应链发展,但长期来看,对全球供应链的稳定性和成本结构可能带来复杂影响,特别是对于依赖全球采购的厂商。新闻数据,路演数据
    • +
    +
  4. +
+
+
+ + +
+

3. 关键催化剂与未来发展路径

+
+

近期催化剂(未来3-6个月)

+
    +
  • 头部人形机器人厂商量产及供应链定点公告: 尤其是特斯拉Optimus、小鹏机器人、智元机器人等,任何关于其批量采用GaN驱动方案的明确消息,或相关GaN供应商的批量定点,都将是重磅催化剂。新闻中提及“小鹏正在直线关节上测试。海外大客户预计也在测试阶段”,预示着相关消息可能临近。新闻数据,路演数据
  • +
  • 核心GaN芯片/模组厂商出货量及客户拓展数据: 重点关注英诺赛科“万级规模”出货目标的兑现情况,以及与更多头部机器人客户达成“design-win”项目并实现量产的公告。新闻数据,研报数据
  • +
  • GaN器件成本下降的明确进展: 例如,英飞凌12英寸GaN晶圆的量产进程及实际降本效果的披露,将有助于加速GaN在机器人领域的渗透。研报数据
  • +
  • 重要人形机器人新品发布: 如Figure 03版本的发布,若明确采用GaN驱动,将进一步强化市场信心。
  • +
  • 政策面进一步支持: 针对第三代半导体材料或机器人核心零部件的国产化政策出台,将为相关企业带来新的发展机遇。新闻数据
  • +
+ +

长期发展路径

+
    +
  1. 初期(当前-未来1-2年): GaN在人形机器人关节电机驱动中实现初步规模化应用。头部机器人厂商率先导入,GaN渗透主要集中在对性能要求最高的关键关节(如手臂、手部)。英诺赛科等头部IDM厂商的市场份额将进一步巩固。产业链上游材料和代工(如三安光电、立昂微的GaN外延片)也将逐步放量。新闻数据,研报数据
  2. +
  3. 中期(未来2-5年): GaN应用场景从关节驱动器拓展至机器人更多模块,包括DCDC电源、电池管理系统(BMS)、AI处理器供电等。随着技术成熟和成本优化,单台机器人GaN芯片使用量有望从当前的300颗攀升至1000颗,驱动整个市场规模快速扩张。新闻数据,研报数据英诺赛科计划月产能从1.3万片提升至7万片,以支撑新兴市场的放量。同时,国产GaN厂商(如宏微科技)将在国产替代趋势下逐步放量。研报数据,股票数据
  4. +
  5. 长期(5-10年以上): GaN技术成为机器人核心功率器件的主流方案,与碳化硅(SiC)形成高效协同,共同支撑机器人产业向更高性能、更低能耗方向发展。摩根士丹利预测的3050亿美元人形机器人半导体市场规模将逐步兑现。GaN器件的可靠性、成本和供应链稳定性达到业界广泛接受的水平,与第四代半导体材料共同推动机器人性能的极致化和商业化普及。新闻数据
  6. +
+
+
+ + +
+

4. 产业链与核心公司深度剖析

+
+

产业链图谱

+

“机器人氮化镓”的产业链可大致划分为上游材料与制造、中游器件与模组以及下游机器人应用与集成。

+
    +
  • 上游(材料与制造): +
      +
    • 原材料: 镓(中信建投、光大证券路演提及为核心战略金属)。路演数据
    • +
    • 衬底与外延: 碳化硅基氮化镓、硅基氮化镓外延片/晶圆(三安光电、海特高新、露笑科技、立昂微、华润微、深圳市国创中心、镓未来)。新闻数据,路演数据,股票数据
    • +
    • 制造设备与辅助材料: 半导体掩模版(龙图光罩)。股票数据
    • +
    +
  • +
  • 中游(GaN芯片/器件/模组): +
      +
    • GaN功率器件设计与制造: IDM模式(英诺赛科、闻泰科技、华润微、镓未来),Fabless模式(安森美、纳微半导体、晶方科技(投资VisIC)),国产替代(宏微科技、富满微、芯朋微)。新闻数据,路演数据,股票数据
    • +
    • 被动元器件: 芯片电感(铂科新材)。股票数据
    • +
    +
  • +
  • 下游(机器人应用与集成): +
      +
    • 机器人关节电机驱动器/模组: 固高科技、好上好、睿能科技、步科股份。新闻数据,股票数据
    • +
    • 机器人整机厂商: 人形机器人(特斯拉、Figure、智元、宇树、乐聚)、四足机器人、工业机器人等。新闻数据,路演数据
    • +
    +
  • +
+ +

核心玩家对比

+
+ +
+

英诺赛科(未上市/港股)

+

角色: GaN芯片/器件设计制造龙头,IDM模式。

+

竞争优势: 全球氮化镓功率半导体市场份额33.7%(2023年末)排名第一。新闻数据具备全球最大的8英寸GaN IDM生产线,技术和产能领先。路演数据在机器人领域布局早,已与头部企业合作推出“全球首款氮化镓机器人”,并预计2024年出货万级规模新闻数据,研报数据其“大画家系列芯片已导入多家头部人形机器人客户并量产出货”,且“100W关节电机驱动产品已顺利实现量产”,产品覆盖关节电机驱动、电源管理等多个模块。研报数据与意法半导体、安森美等国际巨头合作,专利纠纷中胜诉英飞凌,凸显技术实力。新闻数据

+

潜在风险: 作为IDM模式,资本开支大,需要持续投入。与英伟达合作的澄清也提示了市场对消息的过度解读风险。路演数据 (英科医疗涨幅分析)

+

逻辑纯粹度: 极高。作为GaN IDM龙头,直接受益于GaN在机器人中的渗透。

+
+ + +
+

宏微科技(688711)

+

角色: GaN芯片/器件设计制造(国产厂商)。

+

竞争优势: 在GaN技术领域实现关键突破,已完成多款650V GaN功率器件布局,100V7毫欧已送样多家厂商。新闻数据被推荐为“自研GaN驱动的国产厂商”,并明确“拓展AI服务器电源和人形机器人市场”。新闻数据,股票数据有望受益于国产替代趋势。

+

业务进展: 已有产品送样,但尚未明确披露在机器人领域的批量出货信息。

+

潜在风险: 市场份额和客户验证程度相较英诺赛科仍有差距,商业化落地速度待观察。

+

逻辑纯粹度: 较高,国产GaN功率器件提供商。

+
+ + +
+

三安光电(600703)

+

角色: 上游GaN外延片/产能提供者。

+

竞争优势: 拥有湖南三安硅基氮化镓产能2000片/月新闻数据,股票数据公司表示将“匹配开发低压GaN器件及技术方案并导入应用验证”,以应对人形机器人等智能终端需求。股票数据

+

业务进展: 核心优势在材料和产能,GaN器件处于开发和验证阶段。路演数据

+

潜在风险: 处于产业链上游,业绩兑现依赖下游器件厂商的放量。

+

逻辑纯粹度: 较高,作为国产GaN材料和产能的重要布局者。

+
+ + +
+

睿能科技(603933)

+

角色: 电机/驱动解决方案提供商,英飞凌分销商。

+

竞争优势: 公司开发的“氮化镓高效电机方案,已应用于人形机器人”。股票数据作为英飞凌(旗下有GaN System)的分销商,具备国际领先GaN器件的渠道优势和技术集成能力。新闻数据,股票数据

+

业务进展: 已有方案应用于人形机器人,但具体出货量和客户信息披露有限。

+

潜在风险: 并非GaN芯片直接制造商,盈利能力受制于所分销产品的毛利率和自身方案集成能力。

+

逻辑纯粹度: 中等偏高,直接提供机器人GaN电机解决方案。

+
+ + +
+

固高科技(301510)

+

角色: 机器人低压伺服驱动器提供商。

+

竞争优势: 公司推出“GSFD系列高性能低压伺服驱动器”,虽然未直接提及采用GaN,但低压伺服驱动器是GaN的重要应用场景(新闻提及“步科股份已有氮化镓(GaN)技术低压伺服驱动器布局”)。新闻数据,股票数据

+

业务进展: 公司在机器人电机驱动领域具备技术积累,但与GaN的明确关联度需要进一步验证。

+

潜在风险: 未明确指出其产品已采用GaN,可能存在技术路径选择的多元性。

+

逻辑纯粹度: 中等,间接受益于GaN在低压伺服驱动器中的渗透。

+
+ + +
+

芯导科技(688230)

+

角色: GaN产品提供商。

+

竞争优势: GaN产品在中低压应用(如手机快充、扫地机器人、激光雷达)有优化和验证。路演数据

+

业务进展: 明确表示“GaN产品在人形机器人领域尚未出货”。路演数据,股票数据

+

潜在风险: 尽管具备GaN技术,但在人形机器人这一特定高价值领域尚未取得进展,反映出进入该领域的壁垒和验证周期。

+

逻辑纯粹度: 较低,目前未直接受益于机器人氮化镓概念。

+
+
+ +

验证与证伪

+
    +
  • 验证: +
      +
    • 英诺赛科的龙头地位和进展: 新闻和路演中关于英诺赛科的市场份额、客户导入、量产出货等信息相互印证,其作为“机器人氮化镓”概念核心领导者的逻辑得到充分验证。新闻数据,路演数据
    • +
    • GaN技术优势: 各方信息对GaN在高频、高精度、高功率密度、小型化等方面的优势描述高度一致,这是支撑概念的核心技术逻辑。新闻数据,研报数据
    • +
    • 头部机器人厂商的测试/导入: 智元、宇树、小鹏、特斯拉等厂商的动作,表明GaN并非纸上谈兵,已进入实际应用和验证阶段。新闻数据,路演数据
    • +
    +
  • +
  • 证伪与修正: +
      +
    • 并非所有GaN公司立即受益: 芯导科技的表态“GaN产品在人形机器人领域尚未出货”证伪了“只要是GaN公司就能直接受益机器人概念”的普遍预期。这提示市场应更关注公司在机器人领域的具体产品布局、客户验证和订单情况。路演数据,股票数据
    • +
    • 市场对消息的过度解读: 英诺赛科澄清与英伟达的合作尚在“测试阶段,暂未产生实质性订单”,证伪了部分市场对该合作的即时利好解读,提醒投资者需区分概念炒作与实际业绩兑现的节奏。路演数据 (英科医疗涨幅分析)
    • +
    • 微创机器人研报与GaN的关联: 研报中未直接提及GaN,所有与GaN的潜在关联均为分析师解读,并非公司官方表态或实际产品采用。这提醒我们对“潜在相关信息”的解读需保持批判性,避免过度联想。研报数据 (未提及信息说明)
    • +
    +
  • +
+
+
+ + +
+

5. 潜在风险与挑战

+
+

技术风险

+
    +
  • 成熟度与可靠性: 尽管GaN优势显著,但在人形机器人这种对安全性、可靠性和寿命要求极高的应用场景中,其长期工作稳定性、抗冲击性能、极端温度适应性等仍需更长时间的验证和优化。“氮化镓器件开发尚未完全成熟”的研报观点值得关注。研报数据
  • +
  • 与其他材料的竞争与替代: “第四代半导体有望替代碳化硅和氮化镓”的说法,暗示未来可能出现更优异的半导体材料。同时,在部分功率场景下,GaN与SiC存在竞争关系,技术路线的选择可能带来不确定性。新闻数据
  • +
  • 生产工艺复杂性: GaN器件的生产工艺相对复杂,良率和稳定性控制仍是挑战,尤其是大尺寸晶圆的量产爬坡。研报数据
  • +
+ +

商业化风险

+
    +
  • 成本过高: GaN芯片目前价格仍高于硅基方案(“均价1美金,MOS价格的两倍以上”)。尽管系统级优势可以部分抵消,但若成本下降速度不及预期,将影响其在成本敏感型机器人产品中的大规模普及。新闻数据
  • +
  • 市场接受度与渗透速度: 机器人产业整体仍处于发展初期,人形机器人的商业化落地速度和规模仍存在不确定性。GaN在机器人中的渗透速度将高度依赖于机器人本体厂商的量产节奏、技术路线选择和终端市场需求。研报数据
  • +
  • 应用场景拓展不及预期: 目前GaN主要聚焦关节电机驱动,其在机器人其他模块(如电源管理、AI处理器供电、激光雷达等)的拓展可能面临不同的技术和商业挑战。研报数据
  • +
+ +

政策与竞争风险

+
    +
  • 镓的出口管制: 中国对镓的出口管制虽然凸显其战略价值,但也可能对全球GaN供应链的稳定性和成本带来影响,特别是对于依赖海外镓供应的厂商。新闻数据,路演数据
  • +
  • 国际贸易摩擦: 研报明确指出“海外贸易风险(氮化镓芯片供应可能受到国际贸易摩擦影响)”,可能会影响中国GaN企业在全球市场的布局,或导致国际客户的供应链调整。研报数据
  • +
  • 专利竞争: 行业内存在激烈的知识产权竞争,如英诺赛科与英飞凌的专利纠纷,可能影响企业的市场拓展和技术授权。新闻数据
  • +
  • 全球产业链变动: 台积电宣布在未来2年内逐步退出GaN业务,并将其代工转由力积电,这种全球晶圆代工格局的变动,可能给GaN供应链带来不确定性。新闻数据
  • +
+ +

信息交叉验证风险

+
    +
  • 市场情绪与公司实际进展脱钩: 如英诺赛科与英伟达的合作,市场一度过度解读为重大利好,但公司随后澄清尚处于测试阶段且无实质性订单。这提示投资者需警惕市场情绪过热,脱离基本面进行炒作的风险。路演数据 (英科医疗涨幅分析)
  • +
  • 信息模糊与不确定性: 部分新闻或研报信息可能存在语焉不详之处,如“海外大客户预计也在测试阶段”,具体客户名称和测试进展缺乏明确披露,增加了判断难度。新闻数据
  • +
  • 潜在关联的过度解读: 针对微创机器人研报中未直接提及氮化镓,但分析师尝试进行的潜在关联解读,其推理性强于事实性。在信息未明确关联时,应保持高度警惕,避免将普遍技术优势等同于特定公司产品的实际应用。研报数据 (未提及信息说明)
  • +
+
+
+ + +
+

6. 综合结论与投资启示

+
+

“机器人氮化镓”概念目前处于主题炒作与基本面初步验证并存的阶段。虽然技术优势和市场潜力巨大,头部厂商已实现初步商业化,但整体大规模放量和盈利贡献仍需时间,且面临多重挑战。市场情绪偏于乐观甚至超前,存在一定的预期差。

+ +

最具投资价值的细分环节或方向:

+
    +
  1. GaN芯片/器件设计制造龙头(IDM模式优先): +
      +
    • 原因: 逻辑最纯粹,直接受益于GaN在机器人中的渗透率提升和单机价值量增长。IDM模式在技术迭代和成本控制上更具优势。
    • +
    • 代表: 英诺赛科(港股)。其市场份额、客户导入和量产进展领先,是当前机器人氮化镓赛道中最具确定性的参与者。新闻数据,路演数据,研报数据
    • +
    +
  2. +
  3. 具备GaN产能和技术储备的国产厂商: +
      +
    • 原因: 受益于国产替代趋势和镓作为战略资源的政策支持,长期发展确定性高。在关键技术领域实现突破的国产厂商有望占据一席之地。
    • +
    • 代表: 宏微科技(已完成多款650V GaN布局,拓展人形机器人市场),三安光电(拥有硅基氮化镓产能,并计划开发低压GaN器件),立昂微(6英寸碳化硅基氮化镓产品通过客户验证)。新闻数据,股票数据
    • +
    +
  4. +
  5. 深度绑定头部机器人客户的GaN驱动模组/方案供应商: +
      +
    • 原因: 这些公司是GaN芯片的直接采购者和使用者,其订单和出货量直接反映GaN在机器人领域的实际应用进展,业绩兑现更直接。
    • +
    • 代表: 睿能科技(提供GaN高效电机方案,已应用于人形机器人),固高科技(高性能低压伺服驱动器),步科股份(GaN技术低压伺服驱动器布局),好上好(提供氮化镓关节模组)。新闻数据,股票数据
    • +
    +
  6. +
+ +

接下来需要重点跟踪和验证的关键指标:

+
    +
  • 头部人形机器人厂商的GaN驱动明确导入型号、数量及成本结构变化: 特别是特斯拉Optimus、智元、宇树等,关注其GaN驱动方案的渗透率和实际采购量。新闻数据,路演数据
  • +
  • 英诺赛科等GaN芯片龙头的机器人相关产品出货量、订单情况及毛利率变化: 关注其“万级规模”出货目标的兑现情况,以及与新增头部客户的合作进展。新闻数据,研报数据
  • +
  • GaN器件的成本下降速度和幅度: 特别是12英寸晶圆量产带来的降本效应,将直接影响其市场渗透速度。研报数据
  • +
  • GaN在机器人其他模块的拓展情况: 除了关节驱动,关注GaN在机器人电源管理、AI处理器供电、激光雷达等模块的实际应用进展和市场份额。研报数据
  • +
  • 镓的供应链稳定性及出口管制政策的后续影响: 确保GaN原材料供应的长期可靠性对产业链至关重要。新闻数据,路演数据
  • +
  • 技术路线迭代风险: 密切关注第四代半导体材料或新型功率器件技术的发展,以评估GaN的长期竞争力。新闻数据
  • +
+
+
+
+ + +
+

相关股票数据与涨幅分析

+
+
+ + +
+ +
+

“机器人氮化镓”概念直接相关个股

+
+ + + + + + + + + + + + +
股票名称股票代码关联理由其他标签
+
+
+ +
+

近期涨幅异动分析 (与概念关联或间接关联)

+
+ + + + + + + + + + + + + + +
股票名称股票代码涨幅交易日期异动原因摘要详情
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/public/htmls/福建-军工.html b/public/htmls/福建-军工.html new file mode 100644 index 00000000..7c989718 --- /dev/null +++ b/public/htmls/福建-军工.html @@ -0,0 +1,693 @@ + + + + + + 福建-军工 概念深度综合分析报告 + + + + + + + + +
+ + +
+

北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现,本报告为AI合成数据,投资需谨慎。

+
+ + +
+

+ 福建-军工 + 概念深度综合分析报告 +

+

揭秘核心驱动力,洞察未来机遇与挑战

+
+ + +
+

核心观点摘要

+

+ “福建-军工”概念的核心驱动力源于**福建舰的密集进展**和**福建省在地缘战略上的特殊地位**,辅以国家“十五五”规划中对海洋防务和两岸融合的政策倾斜。当前该概念处于**基本面驱动与主题炒作交织**的阶段,核心军工产业链公司受益逻辑明确,而部分福建地域性企业则存在显著的情绪溢价和预期差。未来,随着福建舰正式服役及“海上福建”战略的深度落地,相关核心军工标的和深耕军民融合的本地企业仍具潜力。 +

+
+ + +
+

概念事件:核心驱动与催化

+
+
+

“福建舰”进展时间轴

+
    +
  • 2025年6月26日: 国防部回应福建舰海试工作**稳步推进**,服役日期将近,预示“三航母时代”即将到来。
  • +
  • 2025年8月8日: 国防部证实福建舰成功完成**模拟放飞试验**,强调其“入列进入最后的攻坚时刻”,作为“大国重器”将捍卫国家主权、安全、发展利益。
  • +
  • 2025年9月12日/16日: 外交部和国防部相继证实,福建舰已通过台湾海峡,赴南海相关海域开展**试验训练任务**。
  • +
  • 2025年9月23日: 中国海军宣布,**歼-15T、歼-35和空警-600** 成功完成在福建舰上的**首次弹射起飞和着舰训练**,标志综合作战能力质的提升。国金军工随即分析并列出受益标的。
  • +
  • 2025年10月28日: 市场反应强烈,【A股收盘】显示“**福建、军工板块集体爆发**”,多股涨停。
  • +
  • 2025年10月29日: 研报将福建舰服役与“十五五规划”政策结合,强化“海洋防务与两岸融合”双重催化预期。
  • +
  • 2025年11月9日/10日/13日: 多场军工板块路演频繁提及**“福建舰入列+15规划催化”**,强调舰载机、航母配套及电磁弹射技术的核心地位,并指出福建舰的电磁弹射系统已完成**6000+次无故障弹射**,将于 **2025年11月成舰**。
  • +
+
+
+

辅助催化与背景

+
    +
  • 福建省“十五五”规划及“海上福建”战略: 积极谋划“海上福建”建设,目标“海洋强省”,制定“闽台海洋政策”,提供顶层设计。
  • +
  • 地缘政治紧张与“对台”前沿地位: 福建作为对台前沿,其特殊地位和相关事件凸显军事战略考量。
  • +
  • 军民融合深化: 省内高职院校通过人才输送、产教平台建设等方式深度参与,低空经济政策潜在推广提供更广阔空间。
  • +
  • 市场热点确认: “军工+福建”早在2025年11月21日已被市场列为热点之一,并获得融资净买入,证实市场关注度。
  • +
+
+
+
+ + +
+

概念的核心逻辑与市场认知分析

+
+
+

核心驱动力

+
+
+

1. 国家战略支点:福建舰与海洋强国战略

+
    +
  • 大国重器,战略威慑: 福建舰作为我国首艘电磁弹射型航母,其进展标志海军现代化重大突破,提升远洋作战能力,是“海洋强国”战略的具象体现,强调其“维护国家主权、安全、发展利益”的作用。
  • +
  • “十五五”规划顶层设计: 明确“坚定维护海洋权益和安全”,将福建置于国家海洋战略和防务建设的核心区域。
  • +
+
+
+

2. 地缘政治前沿:两岸融合与区域安全

+
    +
  • 对台战略支点: 福建作为对台前沿,在两岸关系中举足轻重。“十五五规划”推动两岸关系和平发展、推进祖国统一大业,将福建置于核心。福建舰穿越台湾海峡体现其地缘战略意义。
  • +
  • 区域安全保障: 强化福建防务能力是维护国家安全的重要一环,有助于构建稳固的区域安全屏障。
  • +
+
+
+

3. 军事科技创新:电磁弹射与新一代舰载机

+
    +
  • 技术跨越式发展: 电磁弹射系统实现“满油满弹”起飞,“全甲板放飞”,显著提升航母出动效率。路演信息中提到其电磁弹射系统已完成**6000+次无故障**弹射,远超设计指标,展现**可靠性和先进性**。
  • +
  • 舰载机体系升级: 歼-35、空警-600等新一代舰载机上舰,将使福建舰综合作战能力质的提升,特别是歼-35具备军贸出口巨大潜力,带动整个产业链升级。
  • +
+
+
+

4. 军民融合深化:本地产业协同与政策支持

+
    +
  • 地方政府推动: 福建省谋划“海上福建”建设、深化国资国企改革,为省内企业参与军工、海洋防务建设提供政策窗口。
  • +
  • 职业教育与产业协作: 福建省高职院校通过人才输送、产教平台建设、技术攻关等方式深度参与军民融合。
  • +
  • 低空经济与军用技术转化: 福建发展低空经济潜力巨大,无人机应用可能带动军民两用技术需求,提供新路径。
  • +
+
+
+
+ +
+

市场热度与情绪

+
    +
  • 高关注度与资金追捧: 新闻明确指出“军工+福建”是市场热点之一,并获得了融资净买入;特定交易日福建板块和军工板块集体爆发,多只相关个股涨停。
  • +
  • 研报密集催化: 机构发布专题研报,将福建舰服役与“十五五规划”政策结合,明确提出“海洋防务与两岸融合板块迎双击行情”。
  • +
  • 路演聚焦核心议题: 多场军工路演将“福建舰入列催化舰载机及航母配套”作为核心议题,表明机构投资者高度关注。
  • +
  • 地域概念的显著溢价: 福建金森、福建水泥、福建高速等非纯军工企业,仅仅因为名称带有“福建”,就受到市场资金追捧,出现板块联动上涨甚至涨停,充分暴露市场情绪狂热。
  • +
+
+ +
+

预期差分析

+
    +
  • 短期业绩与长期战略的错位: 多数非核心军工的“福建概念股”短期业绩增长与宏大战略直接关联度不高,市场可能高估短期政策红利对非核心企业的直接业绩贡献。
  • +
  • 军民融合的“知易行难”: 研报指出“军民融合深度不足:政策体系不完善、体制机制不健全、资源投入不足”,与市场普遍期待的快速推进存在反差。
  • +
  • 政策执行的挑战与信息甄别: 具体实施方案和项目落地的速度与规模待观察;部分信息在传播过程中可能出现偏差,如研报将“湖南省低空经济政策”误作“福建省”。
  • +
  • 军贸出口的复杂性: 歼-35军贸出口受地缘政治、国际关系、技术转移限制等多重因素影响,实际进展可能存在不确定性。
  • +
+
+
+
+ + +
+

关键催化剂与未来发展路径

+
+
+

近期催化剂(未来3-6个月)

+
    +
  • 福建舰正式服役与后续报道: 根据路演信息,福建舰将于**2025年11月成舰**,其正式服役典礼、首批舰员入驻、官方宣传片发布等,将成为市场焦点。
  • +
  • 福建省“海上福建”及两岸融合实施方案发布: 具体的**工作实施方案、重大项目清单、配套政策**(如“闽台海洋政策”)一旦出台,将提供明确发展方向和政策红利。
  • +
  • 福建省国资国企改革专项行动落地: 若涉及省内军工或海洋防务相关国企,可能通过资产整合、股权激励等释放活力。
  • +
  • 新一代舰载机量产与订单公告: 歼-35、空警-600等舰载机批量生产和列装的订单公告,特别是军贸出口进展,将成为强劲催化。
  • +
  • 地缘政治事件发酵: 纪念台湾光复80周年大会等事件可能再次聚焦市场对两岸关系和区域防务的关注。
  • +
+
+
+

长期发展路径

+
    +
  • 海军装备体系化、信息化升级: 福建舰将带动004型核动力航母等更先进舰船研发,海军装备向体系化、信息化、智能化发展。
  • +
  • 海洋防务与海洋经济融合发展: “海上福建”战略深入实施,带动海洋经济全面发展,促进军民两用技术深度融合。
  • +
  • 高端军贸出口的战略拓展: 以歼-35为代表的先进军工产品将走向国际市场,提供新的增长点。
  • +
  • 军民融合的制度化与产业化: 政策体系完善和资源投入增加,低空经济、商业航天等新兴领域将成为重要突破口。
  • +
  • 核心技术自主可控的深化: 持续加大关键核心技术研发投入,确保产业链自主可控和安全稳定。
  • +
+
+
+
+ + +
+

产业链与核心公司深度剖析

+
+
+

产业链图谱

+
+
+

上游(基础材料、核心元器件)

+
    +
  • **光威复材:** 碳纤维。
  • +
  • **光启技术:** 超材料,用于隐身技术。
  • +
  • **博威合金:** 轨道铜合金,用于电磁弹射。
  • +
  • **火炬电子:** 高端电容器 (福建泉州)。
  • +
  • **厦门钨业:** 稀土永磁材料。
  • +
+
+
+

中游(分系统、核心部件、整机制造)

+
    +
  • **中航沈飞:** 歼-35、歼-15T等舰载机。
  • +
  • **中航西飞:** 空警-600等舰载预警机。
  • +
  • **湘电股份:** 电磁弹射系统总装。
  • +
  • **国科军工:** 弹药装备、固体发动机动力模块。
  • +
  • **江航装备:** 舰载机配套产品。
  • +
  • **红相股份(星波通信):** 军用通信系统 (福建厦门)。
  • +
  • **航天发展:** 电子蓝军装备、军用通信系统 (福建福州)。
  • +
  • **中富通:** 军工二级保密认证 (福建福州)。
  • +
+
+
+

下游(整船、集成、保障、应用)

+
    +
  • **中国船舶、中国海防:** 航母总装及配套。
  • +
  • **厦工股份:** 军品承制单位 (厦门国资委控股)。
  • +
  • **江龙船艇:** 军民两用船艇制造或海上保障。
  • +
  • **龙洲股份:** 子公司畅丰专汽军品资质申请中 (福建龙岩国资委控股)。
  • +
  • **福光股份:** 军用特种光学镜头及光电系统 (福建福州)。
  • +
+
+
+
+ +
+

核心玩家对比

+
+
+

1. 福建舰直接受益者(核心中的核心)

+
    +
  • **湘电股份 (600416.SH):** 电磁弹射系统总装商,逻辑纯粹,技术稀缺性高,电磁弹射**6000+次无故障**。
  • +
  • **中航沈飞 (600760.SH) & 中航西飞 (000768.SZ):** 舰载机整机制造商,受益于舰载机列装订单增长,具备强大的研发和生产能力。
  • +
  • **光启技术 (002621.SZ):** 歼-35/20核心供应商(超材料隐身包覆),技术壁垒高。
  • +
  • **长城军工 (601606.SH):** 福建舰舰载弹药保障系统临近批产,合同额**6-8亿元**。
  • +
  • **福光股份 (300241.SZ):** 福建本地企业,军用特种光学镜头及光电系统核心供应商,与福建舰及海军作战感知系统直接关联。
  • +
+
+
+

2. “海上福建”及区域优势相关者

+
    +
  • **江龙船艇 (300589.SZ):** 受益于“两岸航线预期”和“海洋权益维护需求”,可能涉及军民两用船艇制造或海上保障,其业务与“海上福建”战略吻合,具备区域政策受益潜力。
  • +
  • **厦工股份 (600815.SH):** 厦门国资委控股,多次作为军品承制单位,受益于区域基建及国防需求。
  • +
  • **红相股份 (300427.SZ):** 子公司星波通信具备完全军工资质,主营军用通信设备,在福建省强化海洋防务中需求增长。
  • +
  • **火炬电子 (603678.SH):** 福建泉州企业,军民用高端电容器龙头,军工产业链上游关键配套企业。
  • +
+
+
+

3. 验证与证伪

+
+
+
验证
+
    +
  • 研报和路演中对**福建舰带动舰载机、电磁弹射、新材料**等核心军工产业链的逻辑,得到了相关公司(如湘电股份、中航沈飞、光启技术、长城军工)业务与技术描述的印证。这些公司的受益逻辑清晰,且部分已有明确订单或技术突破。
  • +
+
+
+
证伪
+
    +
  • 地域概念炒作盛行: 涨幅分析显示**福建金森、福建水泥、福建高速**等“福建”地域标签股,其上涨更多是**情绪炒作**,与军工或福建舰的直接业务关联度极低,甚至没有。这**证伪了“凡福建股皆可炒”的泛化逻辑**。
  • +
  • 军民融合的现实挑战: 研报指出福建省军民融合存在“深度不足、政策体系不完善、体制机制不健全、资源投入不足”,与市场对军民融合的乐观预期存在一定的**预期差**。
  • +
  • 信息混淆风险: 研报中将“湖南省低空经济政策”误作“福建省”,表明在信息搜集和分析过程中存在不严谨之处,需警惕此类**信息失真带来的投资风险**。
  • +
+
+
+
+
+
+
+
+ + +
+

潜在风险与挑战

+
    +
  • 政策落地不及预期风险: 宏伟蓝图下的具体政策细化、资金到位、项目批复和执行速度可能**慢于市场乐观预期**,长期停留在概念层面将冲击市场情绪和企业业绩。
  • +
  • 军民融合深度不足的体制机制障碍: 存在**“政策体系不完善、体制机制不健全、资源投入不足”**等问题,可能限制技术转化、人才培养、军品准入,导致市场期待难以兑现。
  • +
  • 地域概念炒作的泡沫风险: 许多“福建”概念股上涨是纯粹**地域标签和情绪驱动**,缺乏基本面支撑,一旦热点切换,股价可能面临**大幅回调**。
  • +
  • 技术研发与量产瓶颈: 军工技术门槛高,研发周期长,投入巨大。未来先进技术(如004型核动力航母、六代机)的研发、试验和量产仍面临诸多**技术瓶颈和不确定性**。
  • +
  • 地缘政治局势的不确定性: 福建作为对台前沿,局势升级或不可预测的变数可能对市场情绪和企业经营造成负面影响;军贸出口也受国际政治关系制约。
  • +
  • 信息交叉验证风险: 研报中将“湖南省低空经济政策”误作“福建省”等情况,突出信息**准确性和严谨性**的重要性,需警惕此类信息误导。
  • +
+
+ + +
+

综合结论与投资启示

+
+
+

综合结论

+

+ “福建-军工”概念目前正处于**主题炒作与基本面驱动交织的阶段**。以“福建舰”为核心的军事现代化进程和“海上福建”战略为核心的区域发展,确实为中国军工产业带来了长期且坚实的基本面驱动力,尤其是在海军装备、舰载机、电磁弹射及相关新材料领域。然而,市场对“福建”地域标签的过度热情,也导致了部分与军工业务关联度较低甚至没有关联的企业,纯粹因情绪和地域概念而获得显著涨幅,这部分属于**主题炒作**的范畴。概念的核心逻辑在于国家地缘战略的提升和军事科技的跨越式发展,而非简单的地域经济刺激。因此,区分核心基本面驱动与纯粹情绪炒作,是把握该概念投资机会的关键。 +

+
+
+

最具投资价值的细分环节或方向

+
    +
  • 核心海军装备产业链: 投资价值最高,受益逻辑最纯粹。 +
      +
    • **方向:** **舰载机整机制造、电磁弹射系统、航母配套新材料**。
    • +
    • **原因:** 福建舰的成功试验和服役,以及未来004型核动力航母的规划,保证了这些核心环节的**长期订单和技术领先优势**。
    • +
    +
  • +
  • 福建省内具备核心军工资质和专业技术壁垒的本地企业: +
      +
    • **方向:** **军用光学系统、军用通信设备、军用高端元器件、军用特种车辆/工程机械**。
    • +
    • **原因:** 受益于福建作为海洋防务前沿的区位优势,以及国家和地方政府对军民融合的政策倾斜。
    • +
    +
  • +
  • 军贸出口潜力较大的航空装备制造商: +
      +
    • **方向:** 具备**高代际、高性能军贸出口能力**的航空装备制造商。
    • +
    • **原因:** 若能打开国际市场,将带来**显著的增量收入和利润空间**。
    • +
    +
  • +
+
+
+

接下来需要重点跟踪和验证的关键指标

+
    +
  • **福建舰及后续航母的正式服役时间与相关官方报道。**
  • +
  • **核心军工企业的订单和交付数据:** 舰载机批量生产订单、关键配套产品的合同金额和收入确认情况、相关公司毛利率变化。
  • +
  • **福建省“海上福建”战略的具体实施方案、重大项目批复和资金到位情况。**
  • +
  • **福建省内具备军工资质的本地企业的军品收入占比和增速。**
  • +
  • **国际军贸市场的新闻动态和潜在订单信息**,尤其是关于先进航空装备的出口进展。
  • +
  • **地缘政治局势的稳定性和发展趋势**,持续关注区域军事、政治动态。
  • +
+
+
+
+ + +
+

原始数据摘要

+ + +
+ +
新闻数据
+
+

(根据提供的新闻内容,以下是与“福建-军工”相关的总结和信息提取)

+ +

直接相关信息:

+
    +
  • 市场热点与板块表现: +
      +
    • 【盘中宝·数据】军工+福建+AI应用+农林牧渔+锂电池,五大热点一周以来获融资净买入情况(2025-11-21T12:41:27)
    • +
    • 【A股收盘:沪指冲高回落收跌00.22%,福建、军工板块集体爆发】海峡创新、福建水泥等十余股涨停。军工板块集体爆发,江龙船艇、长城军工等多股封板。(2025-10-28T15:03:38)
    • +
    +
  • +
  • 福建舰的进展与战略意义: +
      +
    • 国防部回应福建舰近况:成功完成模拟放飞试验,入列进入攻坚时刻,作为大国重器。(2025-08-08T15:35:33)
    • +
    • 🏆三型舰载机“上新”福建舰:歼-15T、歼-35和空警-600成功完成首次弹射起飞和着舰训练。分析其对作战能力提升的意义,列出受益标的。(2025-09-23T09:07:07)
    • +
    • 国防部回应福建舰通过台湾海峡:赴南海相关海域开展试验训练任务,是正常安排,不针对任何特定国家和目标。(2025-09-16T15:06:03)
    • +
    • 🚀【十五五规划+福建舰服役在即,海洋防务与两岸融合板块迎双击行情!】结合“十五五规划”定调,指出海洋防务与两岸融合板块将迎来行情,列出受益标的。(2025-10-29T07:57:58)
    • +
    • 外交部回应福建舰通过台湾海峡:与国防部口径一致,强调为正常科研试验和训练任务,符合国际法。(2025-09-12T15:33:17)
    • +
    • 福建舰服役日期将近 国防部回应:海试工作稳步推进,是中国海军航母建设跨越式发展的体现。(2025-06-26T19:45:01)
    • +
    +
  • +
+ +

潜在相关信息:

+
    +
  • 福建省的战略规划与海洋发展: +
      +
    • 福建板块反复活跃 福建水泥直线涨停:福建省“十五五”规划建议中提及深化国资国企改革。(2025-11-28T10:11:47)
    • +
    • 福建:正谋划“十五五”海上福建建设重大思路和举措 后续将及时公开。鉴于地理位置和战略重要性,很可能包含海洋防务。(2025-12-05T14:44:44)
    • +
    • 福建:制定推进“海上福建”建设实施方案 加快出台新一轮远洋渔业高质量发展政策措施。目标是建设“海洋强省”,可能涉及国防和军事考量。(2025-07-03T13:52:24)
    • +
    +
  • +
  • 海峡两岸地缘政治背景: +
      +
    • 海峡两岸/福建(...: 提及纪念台湾光复80周年大会,关联福建作为对台前沿的特殊地缘政治地位。(2025-10-19T23:02:08)
    • +
    • 【国盛军工】强CALL军工 ...3、百年未有之大变局下的乱世,地缘冲突不断。台湾将于7月9日至18日举行年度最重要、规模最大的军演。(2025-07-06T19:34:32)
    • +
    +
  • +
+
+
+ + +
+ +
路演数据
+
+

(以下是关于“福建-军工”相关信息的总结,尽可能保留原文)

+

直接相关信息(提及“福建舰”):

+
    +
  • 2025-11-09T16:00:00 (军工板块总结): +
      +
    • **舰载机&福建舰主题**:"福建舰入列+15规划催化,舰载机(含四代机)外协比例提升带来新增量..."
    • +
    • **后市展望**:"建议沿...“舰载机新外协”...四条主线进行结构性配置..."
    • +
    +
  • +
  • 2024-11-19T20:30:00 (东北军工百日谈第56期): +
      +
    • **飞行表演与军机展示**:"海军编队:山东舰、福建舰双航母展示..."
    • +
    +
  • +
  • 2025-11-10T07:30:00 (军工核心议题): +
      +
    • **核心议题**:"福建舰入列催化舰载机及航母配套:歼35(中航沈飞)、舰载预警机(中航西飞)打开军贸高端化窗口..."
    • +
    • **关键数据与事件**:"福建舰:官方画面确认歼35、舰载预警机上舰;"
    • +
    • **产业链映射与重点公司**:"舰载机/军贸:中航沈飞(歼35)、中航西飞(舰载预警机)、航发动力(发动机)、国睿科技(雷达)。"
    • +
    • **景气与节奏判断**:"福建舰:平台列装→体系集成→军贸出口,歼35 军贸放开预期升温..."
    • +
    +
  • +
  • 2025-11-13T15:00:00 (军工宏观与预算): +
      +
    • **舰载平台**:"福建舰:电磁弹射6000+次无故障,设计指标3200次/故障;2025-11成舰,配歼-15T、歼-35、空警-600。"
    • +
    • **战略与前沿技术**:"电磁弹射:中压直流方案,福建舰实测可靠性×8倍于美军“福特”号..."
    • +
    +
  • +
+

潜在相关信息(与“福建舰”相关联的军工装备或技术):

+
    +
  • 2025-11-13T15:00:00 (军工宏观与预算): +
      +
    • **军机产业链 - 列装节奏**:"歼-35:五代舰载,当前5亿元/架...2026E出口打开..."
    • +
    • **军机产业链 - 技术对比**:"歼-35对F-35:双发+更大内油,作战半径+25%..."
    • +
    • **舰载平台**:"004型核动力航母:2026E开工,甲板+8米,预留六代机上舰..."
    • +
    • **上市公司要点**:"整机:中航沈飞(歼-35)、中航成飞(歼-20/六代)...材料与隐身:光启技术(超材料隐身包覆...歼-35/20核心供应商)...电磁/激光:湘电股份(电磁弹射总装)..."
    • +
    +
  • +
+
+
+ + +
+ +
研报数据
+
+

(以下是总结并提取出的与“福建-军工”直接及潜在相关的信息,尽可能保留原文)

+ +

一、直接提及福建或福建辖区军工相关信息

+
    +
  • 福建省高职教育服务国防和军队建设及军民融合(研报日期: 2024-03-01) +
      +
    • **人才输送**:"全省高职院校累计为部队输送士官和技术兵 **238人**..."
    • +
    • **军民融合产业发展**:"福建省教育厅等部门联合印发《关于推进军民融合产业发展的实施意见》...重点支持 **福州经济技术开发区物联网产教联合体**..."
    • +
    • **国防教育与文化传承**:"全省高职院校共举办国防教育活动 **1200余场**,参与学生超 **50万人次**..."
    • +
    • **总结**:"福建省高职院校通过 **人才输送、军民融合产教平台建设、国防教育、国际技术合作** 等方式,深度参与福建-军工事业发展。"
    • +
    +
  • +
  • 福建籍上市公司:福光股份(300241.SZ)及其军工业务(研报日期: 2025-01-13) +
      +
    • **本周表现**:"本周涨幅前十个股:福光股份(21.49%)。"
    • +
    • **主营业务**:"从事军用特种光学镜头及光电系统、民用光学镜头、光学元组件等产品科研生产。"
    • +
    +
  • +
  • 福建省低空经济政策(研报日期: 2025-01-13) +
      +
    • *(注:原文标题提及“福建省”,但内容为“湖南省”政策,此处按原文保留,并提示其潜在关联)*
    • +
    • "潜在关联:福建省内低空经济产业链可能受益于类似政策推广..."
    • +
    +
  • +
+

二、潜在关联福建-军工的信息

+
    +
  • 国科军工(688543)业务与福建的潜在关联(研报日期: 2025-03-27) +
      +
    • **公司定位与军工核心业务**:"公司是我国重要的弹药装备研制生产企业..."
    • +
    • **总结:福建-军工关联性**:"核心逻辑:公司业务深度绑定军工需求,尤其是**弹药装备**...且持续向**智能化弹药**、**无人机助推器**等方向延伸...福建省作为公司重要生产基地(如九江国科、航天经纬等子公司可能位于福建),其军工产业政策与订单需求直接驱动公司业绩增长。"
    • +
    +
  • +
  • 军工行业研报中提及的福建潜在业务布局或产能(研报日期: 2024-07-25) +
      +
    • **需进一步核实可能在福建有业务布局或产能的上市公司**:菲利华(300395.SZ)、高德红外(002414.SZ)、航天电子(600879.SH)。
    • +
    • **潜在相关福建信息点**:"低空经济发展...商业航天领域...船舶制造领域..."
    • +
    • **建议跟踪**:"福建省国防科工办等相关政府部门政策动向...福建辖区上市公司如**福能股份(600483.SH)**、**厦门钨业(600549.SH)**等是否涉及军工业务..."
    • +
    +
  • +
  • 低空经济发展与福建的区域协同及政策红利外溢(研报日期: 2025-01-20) +
      +
    • **低空经济发展带来的区域协同机遇**:"福建作为东南沿海省份,可能参与长三角与珠三角之间的低空经济走廊建设..."
    • +
    • **需重点关注的方向**:"低空经济:建议关注福建省内可能布局的eVTOL试飞基地、无人机产业园等项目。"
    • +
    +
  • +
  • 福建军工方向建议关注的三大主线(研报日期: 2025-01-13) +
      +
    • **传统主战装备**:"福建在船舶制造(如马尾造船厂)、光电技术(福光股份)领域有产业基础..."
    • +
    • **结论**:"当前时点,福建军工的核心机会在于**低空经济政策催化**(福光股份直接受益)及**传统装备需求传导**...建议优先布局业绩弹性大、技术稀缺的标的(如福光股份)。"
    • +
    +
  • +
+

三、未提及福建-军工或无直接关联的信息

+
    +
  • **国科军工(688543)重大合同签订事项简评(研报日期: 2024-01-03)**:未提及福建省或与福建相关的军工信息。
  • +
  • **润建股份(002929)报告(研报日期: 2025-10-11)**:未发现与福建-军工主题存在直接或潜在关联信息。
  • +
  • **低空经济领域政策动态(研报日期: 2024-12-09)**:主要提及舟山市(浙江省)的低空经济政策。
  • +
+
+
+
+ + +
+

相关股票数据

+ + +
+

“福建-军工”概念股列表 (251205)

+
\ No newline at end of file diff --git a/public/htmls/长征十二号甲.html b/public/htmls/长征十二号甲.html new file mode 100644 index 00000000..7f3f7c33 --- /dev/null +++ b/public/htmls/长征十二号甲.html @@ -0,0 +1,633 @@ + + + + + + 长征十二号甲概念深度分析 - 价值前沿 + + + + + + + + +
+

长征十二号甲概念深度分析

+

北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现

+

本报告为AI合成数据,投资需谨慎。

+
+ +
+ + +
+
+ + + + +

概念核心洞察

+
+

“长征十二号甲”概念的核心驱动事件是我国新一代中型可重复使用运载火箭——**长征十二号甲型火箭 (CZ-12A)** 的即将首飞及其可重复使用技术的验证。

+

长十二甲由中国航天科技集团八院研制,是继长征十二号后的新一代中型可重复使用运载火箭。其核心优势在于高频次、低成本的重复使用能力,旨在大幅降低航天发射成本,提升商业航天发射效率,为低轨卫星互联网星座、深空探测等任务提供核心运载支撑。项目负责人李建强指出,该项目面临“新火箭、新工位、新模式,挑战大”。

+

核心观点摘要:

+

“长征十二号甲”概念正处于从技术验证到商业化爆发的关键前夜,其核心驱动力在于可重复使用技术对航天发射成本的颠覆性降低和高频次发射能力的巨大提升。市场对此抱有高度乐观预期,但需警惕技术验证的不确定性和部分关联标的过度主题炒作的风险,其未来发展路径将高度依赖于首飞回收的成功与否以及后续商业订单的兑现。

+ +
+

重要提示:

+

市场对“长征十二号”与“长征十二号甲”存在混淆风险。**长征十二号**已于**2024年11月30日**在海南商业航天发射场首飞成功,为一次性使用型号。而**长征十二号甲**是其基础上研制的**可重复使用版本**,其核心价值在于“可重复使用”这一里程碑式的技术突破,目前尚未首飞。投资者应明确区分两者。

+
+
+ + +
+
+ + + +

关键催化与里程碑

+
+
+
+

长征十二号甲 (CZ-12A) 首飞预测

+
    +
  • **时间推测:** 大概率在2025年12月中旬或下旬,最快12月上旬。
  • +
  • **网传日期:** 12月12日 (未证实)。
  • +
  • **最新传闻:** 12月15日 (2025-12-04T22:50:03)。
  • +
  • **地点:** 现已在酒泉发射场完成起竖 (2025-12-04T22:50:03)。
  • +
  • **目标:** 实现可回收,确保首飞任务“发射成功、回收成功”。
  • +
  • **负责人:** 八院李建强团队,指出“新火箭、新工位、新模式,挑战大”。
  • +
  • **路演信息:** 已完成全程试车,计划近期首飞并尝试入轨级回收 (2025-12-07)。
  • +
+
+
+

长征十二号 (CZ-12) 已完成首飞

+
    +
  • **首飞时间:** 2024年11月30日22时25分(研报1, 研报5, 研报6)。
  • +
  • **首飞地点:** 海南商业航天发射场2号工位。
  • +
  • **首飞任务:** 顺利将卫星互联网技术试验卫星、技术试验卫星03星送入预定轨道。
  • +
  • **重要意义:** 标志着海南商业航天发射场正式投入使用,是我国商业航天发展的重要里程碑,填补了我国没有商业航天发射场的空白。
  • +
  • **定位:** 一次性低成本型号。
  • +
+
+
+

战略里程碑意义

+
    +
  • 如果长十二甲成功首飞并实现回收,将成为我国第一枚试验成功的可重复利用火箭。
  • +
  • 标志着中国在可重复使用火箭技术领域进入“工程化验证”新阶段,开启我国商业航天的新篇章。
  • +
  • 其运载能力提升将加速国家低轨卫星星座(如G60组网)的部署。
  • +
  • 有望进一步降低航天发射成本,大幅提升中国商业航天的发射效率。
  • +
  • 民营火箭发动机公司九州云箭进入国家队供应链,意味着国家队正式向商业航天敞开大门。
  • +
+
+
+
+ + +
+
+ + + +

技术规格与特点

+
+
+
+

基本信息

+
    +
  • **名称:** 长征十二号甲型火箭 (CZ-12A) / 长征十二号甲运载火箭 / 长征十二甲。
  • +
  • **研制方:** 中国航天科技集团八院(上海航天技术研究院)抓总研制。
  • +
  • **类型:** 继长征十二号后研制的新一代中型可重复使用运载火箭。
  • +
  • **构型:** 二级串联构型,或二级构型设计。
  • +
  • **全长:** 约62米。
  • +
  • **直径:** 3.8米或4米级。
  • +
+
+
+

动力系统

+
    +
  • **主动力系统:** 全部使用液氧煤油发动机。
  • +
  • **发动机推力:** 采用130吨级液氧煤油发动机(推力优于120吨级)。
  • +
  • **发动机配置:** 一级采用4台推力1250千牛的泵后摆液氧煤油发动机。
  • +
  • **配套方:** 九州云箭龙云发动机将配套长征十二甲重复使用运载火箭一级发动机 (7台)。
  • +
+
+
+

运载能力与设计

+
    +
  • **近地轨道 (LEO):** 不少于12吨 (研报),或提升至10吨级 (路演)。
  • +
  • **700公里太阳同步轨道:** 不少于6吨。
  • +
  • **地球同步转移轨道 (GTO):** 约6吨 (百公里轨道同步上行能力)。
  • +
  • **整流罩:** 标准直径为5.2米至4.2米,可灵活适配不同任务需求。
  • +
  • **运输能力:** 支持铁路运输至各发射场。
  • +
  • **测发模式:** 采用“三平测发模式”(水平组装、水平测试、水平转运)。
  • +
+
+
+

技术突破

+
    +
  • **可重复使用能力:** 核心优势,有望进一步降低航天发射成本。
  • +
  • **九州云箭技术:** 国内首家掌握火炬点火、多次启动、深度推力调节及回收技术的商业火箭动力公司。
  • +
  • **关键技术突破:** 健康诊断管理、牵制释放、液氧相容的冷氦增压、铝锂合金贮箱等。
  • +
  • **未来发展:** 为一箭通用和未来重复使用火箭的发展奠定了基础。
  • +
+
+
+
+ + +
+
+ + + + +

战略意义与产业影响

+
+
+
+

航天能力提升

+
    +
  • 大幅提升中国商业航天的发射效率,为低轨卫星互联网星座、深空探测等任务提供核心运载支撑。
  • +
  • 提升我国航天发射能力,为民商大规模低轨星座组网任务等空间基础设施工程建设提供强有力的发射保障。
  • +
  • 有助于提高我国太阳同步轨道入轨能力和低轨星座组网能力。
  • +
  • 填补了长征系列在中型液体运载火箭领域的空白,完善中国航天运输能力矩阵。
  • +
+
+
+

商业航天发展

+
    +
  • 其成功将标志着中国在可重复使用火箭技术领域进入“工程化验证”新阶段。
  • +
  • 开启我国商业航天的新篇章。
  • +
  • 民营火箭发动机公司九州云箭进入国家队可回收火箭供应链序列,意味着国家队正式向商业航天敞开大门。
  • +
  • 将完成星箭制造、商业发射场测试发射,以及卫星数据应用服务的商业航天全产业链闭环。
  • +
+
+
+

卫星互联网与高密度发射

+
    +
  • 长征十二号成功发射卫星互联网技术试验卫星,直接关联卫星互联网主题。
  • +
  • 运载能力提升将加速国家低轨卫星星座(如G60组网)的部署。
  • +
  • 已做好进入高密度发射状态的准备,将支撑未来低轨卫星星座组网需求。
  • +
  • 低轨互联网卫星组网将显著提速,推动我国星座整体建设进程迈入新的阶段。
  • +
+
+
+
+ + +
+
+ + + + +

产业链与核心企业

+
+
+
+

上游:材料与核心部件

+
    +
  • **发动机材料与部件:** 斯瑞新材 (液体火箭推力室内壁高温合金材料)、中航重机 (关键锻件)、铂力特 (金属3D打印关键零部件)、富士达 (合格供方)。
  • +
  • **箭体材料与加工:** 光威复材 (碳纤维复合材料液氧贮箱)、华中数控 (复杂结构件高档数控加工)、华菱线缆 (拟收购星鑫航天配套材料)。
  • +
  • **电子元器件与配套:** 振华科技 (抗单粒子辐照器件)、宏达电子 (CAK系列产品)、高华科技 (长征系列火箭技术积累)。
  • +
  • **粘接材料:** 回天新材 (航天粘接材料)。
  • +
+
+
+

中游:火箭总装与发动机系统

+
    +
  • **火箭总装研制:** 中国航天科技集团八院 (抓总研制)。
  • +
  • **发动机研制与配套:** 九州云箭 (液氧甲烷龙云发动机)。
  • +
  • **卫星总装、测试及实验 (AIT):** 上海沪工 (航天八院子公司上海利正唯一供应商)。
  • +
+
+
+

下游:发射服务与应用

+
    +
  • **发射场:** 海南商业航天发射场 (兼容长征十二号)。
  • +
  • **卫星应用与运营:** 中国卫星 (参与天问一号)、中国卫通 (卫星运营)、海格通信 (卫星通信)、振芯科技 (卫星芯片)。
  • +
  • **地面设备:** 航天环宇 (券商推荐)。
  • +
  • **工业软件:** 航天软件 (AVIDM系统,服务于火箭、航天器、卫星的设计、仿真、制造、测控)。
  • +
+
+
+ +

核心玩家深度分析

+
+
+

最纯粹的直接关联方:九州云箭配套企业

+

直接为长十二甲核心部件——液氧甲烷发动机提供产品或服务,逻辑最为纯粹,具有开创性意义。

+
    +
  • 斯瑞新材 (688102): 提供液体火箭推力室内壁产品,客户覆盖九州云箭,业务纯度高。
  • +
  • 铂力特 (688333): 通过金属3D打印技术为九州云箭龙云发动机制造关键零部件,参与长征十二号运载火箭部分工作。
  • +
  • 中航重机 (600765): 旗下重机宇航为九州云箭供应关键锻件,核心件供应商。
  • +
  • 航宇科技 (688239): 深度进入九州云箭等头部火箭厂商供应链。
  • +
+
+
+

航天八院系龙头:航天机电 (600151)

+

作为航天八院旗下唯一上市公司,承载市场对优质资产注入的强烈预期。

+
    +
  • **优势:** 平台价值,市场预期其可能受益于资产注入。
  • +
  • **潜在风险:** 公司已发布澄清公告,表示主营业务不涉及商业航天且无重组计划,存在较高的预期落空风险。
  • +
+
+
+

技术与服务支撑

+

通过技术或服务与航天领域深度绑定,受益逻辑相对稳健。

+
    +
  • 高华科技 (688539): 凭借长征系列火箭技术积累与航天八院配套合作。
  • +
  • 华中数控 (300161): 与航天八院合作承担航天复杂结构件高档数控加工。
  • +
  • 航天软件 (688562): 作为航天工业软件供应商,服务火箭、航天器全生命周期管理,稀缺性和战略价值突出。
  • +
+
+
+

概念热度与风险并存 (名称关联/资产注入预期)

+

上涨逻辑更多基于“预期”、“概念”而非“现实”,存在“含航天量”较低的风险。

+
    +
  • 泰永长征 (002927): 基于“长征”名称和弱关联业务的炒作。
  • +
  • 中天火箭 (003009): “火箭”名称稀缺性及“航天系资产注入”预期。
  • +
  • 航天智装 (300455): 市场解读为“航天五院未来的核心资产证券化平台”,强烈资产注入预期。
  • +
  • 航天环宇 (688523): 被券商推荐,市场传闻为其提供核心部件支撑。
  • +
+
+
+
+ + +
+
+ + + +

潜在风险与挑战

+
+
+
+

技术风险

+
    +
  • **可重复使用回收技术验证失败:** 若回收环节未能成功,将严重打击市场信心,可能导致后续研发推迟。
  • +
  • **液氧甲烷发动机的成熟度与可靠性:** 核心动力系统虽有突破,但首次工程化飞行并实现高可靠性回收仍需验证。
  • +
+
+
+

商业化风险

+
    +
  • **成本降低效果不及预期:** 实际维护、翻新成本可能高于预期,影响商业竞争力。
  • +
  • **商业订单不足:** 卫星互联网星座建设虽有规划,但实际订单量、交付速度存在不确定性。
  • +
  • **市场竞争加剧:** 面临国内民营火箭公司和国际巨头(如SpaceX)的竞争。
  • +
+
+
+

信息交叉验证风险

+
    +
  • **“长征十二号”与“长征十二号甲”的混淆:** 投资者可能混淆已首飞的一次性型号与即将首飞的可重复使用型号。
  • +
  • **关联公司业务真实性:** 部分上市公司与商业航天业务关联度或贡献度未得到官方证实,存在基于预期和传闻的炒作风险。
  • +
  • **数据来源的时效性与准确性:** 不同信息源可能存在信息滞后或表述不一致。
  • +
+
+
+
+ + +
+
+ + + +

投资结论与展望

+
+

长征十二号甲概念目前正处于**主题炒作与基本面驱动的过渡阶段**。在当前时点,市场更多是围绕其“可重复使用”这一颠覆性技术突破,以及卫星互联网等国家战略需求进行**强预期驱动的主题炒作**。但如果长十二甲的首飞回收成功,则将迎来由技术验证向工程化、商业化转化的基本面驱动阶段。

+ +

最具投资价值的细分环节或方向

+
    +
  • **核心技术突破者与稀缺供应商:** 直接参与长征十二号甲可重复使用液氧甲烷发动机研制或核心部件供应的企业,如**九州云箭的配套方**(斯瑞新材、铂力特、中航重机),这些企业是技术路径的核心受益者,业务纯度高。
  • +
  • **航天工业软件与先进制造:** 在商业航天大背景下,保障研发效率、提升制造水平的关键工业软件供应商(如**航天软件**)和先进制造技术(如**华中数控**)将持续受益于整个产业的升级。
  • +
  • **卫星互联网应用端:** 一旦运力瓶颈打破,低轨卫星星座组网将加速,卫星制造、运营和终端环节将迎来爆发。可关注如**中国卫通、海格通信**等,以及研报提及的**“太空算力”**新方向。
  • +
+ +

接下来需要重点跟踪和验证的关键指标

+
    +
  • **长征十二号甲首飞及回收试验结果:** 这是决定概念短期走势最核心的催化剂。关注“发射成功”和“回收成功”的双重目标是否达成。
  • +
  • **可重复使用频率与成本降低数据:** 关注后续长十二甲是否能按计划进行高频次发射,以及官方或研报披露的实际发射成本降低幅度。
  • +
  • **商业订单签订情况:** 跟踪长十二甲获得的卫星互联网星座、深空探测或其他商业发射服务的实际订单数量和金额。
  • +
  • **关联公司业务兑现情况:** 对于被市场热炒的关联公司,应密切关注其是否发布与长十二甲或商业航天相关的实质性订单、合作公告或资产重组进展,以验证其上涨逻辑。
  • +
  • **海南商业航天发射场利用率:** 关注发射场实际发射频次,验证其高密度发射能力,这将是商业航天繁荣的基础设施保障。
  • +
+
+ + +
+
+ + + +

股票数据概览

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
股票名称股票代码关联原因其他标签
航天机电600151公司是航天八院旗下唯一上市公司;长征十二号运载火箭是由航天八院研制的新一代中型液体运载火箭航天八院, 航天科技集团, 上海航天技术研究院
高华科技688539公司凭借在长征系列火箭的技术积累,已建立与上海航天技术研究院等下游重点客户的配套合作航天八院, 航天科技集团, 上海航天技术研究院
华中数控300161公司与航天八院合作承担“国家高档数控机床与基础制造装备科技重大专项(04专项)——航天复杂结构件高档数控加工示范工程”航天八院, 航天科技集团, 上海航天技术研究院
佳缘科技3011172024年公司第二大客户为上海航天技术研究院航天八院, 航天科技集团, 上海航天技术研究院
斯瑞新材688102液体火箭推力室内壁产品客户覆盖九州云箭九州云箭, 龙云发动机配套, 长征十二甲
中航重机600765旗下重机宇航为九州云箭发动机稳定供应关键锻件九州云箭, 龙云发动机配套, 长征十二甲
航宇科技688239公司在商业航天产业链已深度进入九州云箭等头部火箭厂商九州云箭, 龙云发动机配套, 长征十二甲
铂力特688333公司通过金属3D打印技术为九州云箭出品的龙云液氧甲烷发动机制造了旋转机械零部件、燃烧装置零部件以及发动机管路等零部件九州云箭, 龙云发动机配套, 长征十二甲
+
+
+ + +
+
+ + + + +

股价涨幅分析概览

+
+
+
+ + +
+
+ + + + + +

个股详细涨幅分析

+
+
+ +
+
+ +
+ + + + \ No newline at end of file diff --git a/sql/concept_minute_alert.sql b/sql/concept_minute_alert.sql new file mode 100644 index 00000000..d54cc7ff --- /dev/null +++ b/sql/concept_minute_alert.sql @@ -0,0 +1,68 @@ +-- 概念分钟级异动数据表 +-- 用于存储概念板块的实时异动信息,支持热点概览图表展示 + +CREATE TABLE IF NOT EXISTS concept_minute_alert ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + concept_id VARCHAR(32) NOT NULL COMMENT '概念ID', + concept_name VARCHAR(100) NOT NULL COMMENT '概念名称', + alert_time DATETIME NOT NULL COMMENT '异动时间(精确到分钟)', + alert_type VARCHAR(20) NOT NULL COMMENT '异动类型:surge(急涨)/limit_up(涨停增加)/rank_jump(排名跃升)', + trade_date DATE NOT NULL COMMENT '交易日期', + + -- 涨跌幅相关 + change_pct DECIMAL(10,4) COMMENT '当时涨跌幅(%)', + prev_change_pct DECIMAL(10,4) COMMENT '之前涨跌幅(%)', + change_delta DECIMAL(10,4) COMMENT '涨幅变化量(%)', + + -- 涨停相关 + limit_up_count INT DEFAULT 0 COMMENT '当前涨停数量', + prev_limit_up_count INT DEFAULT 0 COMMENT '之前涨停数量', + limit_up_delta INT DEFAULT 0 COMMENT '涨停变化数量', + + -- 排名相关 + rank_position INT COMMENT '当前涨幅排名', + prev_rank_position INT COMMENT '之前涨幅排名', + rank_delta INT COMMENT '排名变化(负数表示上升)', + + -- 指数位置(用于图表Y轴定位) + index_code VARCHAR(20) DEFAULT '000001.SH' COMMENT '参考指数代码', + index_price DECIMAL(12,4) COMMENT '异动时的指数点位', + index_change_pct DECIMAL(10,4) COMMENT '异动时的指数涨跌幅(%)', + + -- 概念详情 + stock_count INT COMMENT '概念包含股票数', + concept_type VARCHAR(20) DEFAULT 'leaf' COMMENT '概念类型:leaf/lv1/lv2/lv3', + + -- 额外信息(JSON格式,存储涨停股票列表等) + extra_info JSON COMMENT '额外信息', + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 索引 + INDEX idx_trade_date (trade_date), + INDEX idx_alert_time (alert_time), + INDEX idx_concept_id (concept_id), + INDEX idx_alert_type (alert_type), + INDEX idx_trade_date_time (trade_date, alert_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念分钟级异动数据表'; + + +-- 创建指数分时快照表(用于异动时获取指数位置) +CREATE TABLE IF NOT EXISTS index_minute_snapshot ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + index_code VARCHAR(20) NOT NULL COMMENT '指数代码', + trade_date DATE NOT NULL COMMENT '交易日期', + snapshot_time DATETIME NOT NULL COMMENT '快照时间', + + price DECIMAL(12,4) COMMENT '指数点位', + open_price DECIMAL(12,4) COMMENT '开盘价', + high_price DECIMAL(12,4) COMMENT '最高价', + low_price DECIMAL(12,4) COMMENT '最低价', + prev_close DECIMAL(12,4) COMMENT '昨收价', + change_pct DECIMAL(10,4) COMMENT '涨跌幅(%)', + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_index_time (index_code, snapshot_time), + INDEX idx_trade_date (trade_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指数分时快照表'; diff --git a/src/components/StockChart/StockChartAntdModal.js b/src/components/StockChart/StockChartAntdModal.js index 8cddda26..bb1a0257 100644 --- a/src/components/StockChart/StockChartAntdModal.js +++ b/src/components/StockChart/StockChartAntdModal.js @@ -313,12 +313,29 @@ const StockChartAntdModal = ({ axisPointer: { type: 'cross' }, formatter: function(params) { const d = params[0]?.dataIndex ?? 0; - const priceChangePercent = ((prices[d] - prevClose) / prevClose * 100); - const avgChangePercent = ((avgPrices[d] - prevClose) / prevClose * 100); + const price = prices[d]; + const avgPrice = avgPrices[d]; + const volume = volumes[d]; + + // 安全计算涨跌幅,处理 undefined/null/0 的情况 + const safeCalcPercent = (val, base) => { + if (val == null || base == null || base === 0) return 0; + return ((val - base) / base * 100); + }; + + const priceChangePercent = safeCalcPercent(price, prevClose); + const avgChangePercent = safeCalcPercent(avgPrice, prevClose); const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a'; const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a'; - return `时间:${times[d]}
现价:¥${prices[d]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)
均价:¥${avgPrices[d]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)
昨收:¥${prevClose?.toFixed(2)}
成交量:${Math.round(volumes[d]/100)}手`; + // 安全格式化数字 + const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-'; + const formatPercent = (val) => { + if (val == null || isNaN(val)) return '-'; + return (val >= 0 ? '+' : '') + val.toFixed(2) + '%'; + }; + + return `时间:${times[d] || '-'}
现价:¥${safeFixed(price)} (${formatPercent(priceChangePercent)})
均价:¥${safeFixed(avgPrice)} (${formatPercent(avgChangePercent)})
昨收:¥${safeFixed(prevClose)}
成交量:${volume != null ? Math.round(volume/100) + '手' : '-'}`; } }, grid: [ @@ -337,6 +354,7 @@ const StockChartAntdModal = ({ position: 'left', axisLabel: { formatter: function(value) { + if (value == null || isNaN(value)) return '-'; return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; } }, @@ -354,11 +372,12 @@ const StockChartAntdModal = ({ position: 'right', axisLabel: { formatter: function(value) { + if (value == null || isNaN(value)) return '-'; return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; } } }, - { type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => Math.round(v/100) + '手' } } + { type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => (v != null && !isNaN(v)) ? Math.round(v/100) + '手' : '-' } } ], dataZoom: [ { type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 }, diff --git a/src/components/StockChart/TimelineChartModal.tsx b/src/components/StockChart/TimelineChartModal.tsx index 7981b098..eb9eb809 100644 --- a/src/components/StockChart/TimelineChartModal.tsx +++ b/src/components/StockChart/TimelineChartModal.tsx @@ -217,27 +217,34 @@ const TimelineChartModal: React.FC = ({ if (dataIndex === undefined) return ''; const item = data[dataIndex]; - const changeColor = item.change_percent >= 0 ? '#ef5350' : '#26a69a'; - const changeSign = item.change_percent >= 0 ? '+' : ''; + if (!item) return ''; + + // 安全格式化数字 + const safeFixed = (val: any, digits = 2) => + val != null && !isNaN(val) ? Number(val).toFixed(digits) : '-'; + + const changePercent = item.change_percent ?? 0; + const changeColor = changePercent >= 0 ? '#ef5350' : '#26a69a'; + const changeSign = changePercent >= 0 ? '+' : ''; return `
-
${item.time}
+
${item.time || '-'}
价格: - ${item.price.toFixed(2)} + ${safeFixed(item.price)}
均价: - ${item.avg_price.toFixed(2)} + ${safeFixed(item.avg_price)}
涨跌幅: - ${changeSign}${item.change_percent.toFixed(2)}% + ${changeSign}${safeFixed(changePercent)}%
成交量: - ${(item.volume / 100).toFixed(0)}手 + ${item.volume != null ? (item.volume / 100).toFixed(0) : '-'}手
`; @@ -314,7 +321,7 @@ const TimelineChartModal: React.FC = ({ axisLabel: { color: '#999', fontSize: isMobile ? 10 : 12, - formatter: (value: number) => value.toFixed(2), + formatter: (value: number) => (value != null && !isNaN(value)) ? value.toFixed(2) : '-', }, }, { @@ -333,6 +340,7 @@ const TimelineChartModal: React.FC = ({ color: '#999', fontSize: isMobile ? 10 : 12, formatter: (value: number) => { + if (value == null || isNaN(value)) return '-'; if (value >= 10000) { return (value / 10000).toFixed(1) + '万'; } diff --git a/src/mocks/handlers/market.js b/src/mocks/handlers/market.js index 19857e45..4e15a72d 100644 --- a/src/mocks/handlers/market.js +++ b/src/mocks/handlers/market.js @@ -346,7 +346,173 @@ export const marketHandlers = [ }); }), - // 11. 市场统计数据(个股中心页面使用) + // 11. 热点概览数据(大盘分时 + 概念异动) + http.get('/api/market/hotspot-overview', async ({ request }) => { + await delay(300); + const url = new URL(request.url); + const date = url.searchParams.get('date'); + + const tradeDate = date || new Date().toISOString().split('T')[0]; + + // 生成分时数据(240个点,9:30-11:30 + 13:00-15:00) + const timeline = []; + const basePrice = 3900 + Math.random() * 100; // 基准价格 3900-4000 + const prevClose = basePrice; + let currentPrice = basePrice; + let cumulativeVolume = 0; + + // 上午时段 9:30-11:30 (120分钟) + for (let i = 0; i < 120; i++) { + const hour = 9 + Math.floor((i + 30) / 60); + const minute = (i + 30) % 60; + const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + + // 模拟价格波动 + const volatility = 0.002; // 0.2%波动 + const drift = (Math.random() - 0.5) * 0.001; // 微小趋势 + currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift); + + const volume = Math.floor(Math.random() * 500000 + 100000); // 成交量 + cumulativeVolume += volume; + + timeline.push({ + time, + price: parseFloat(currentPrice.toFixed(2)), + volume: cumulativeVolume, + change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2)) + }); + } + + // 下午时段 13:00-15:00 (120分钟) + for (let i = 0; i < 120; i++) { + const hour = 13 + Math.floor(i / 60); + const minute = i % 60; + const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + + // 下午波动略小 + const volatility = 0.0015; + const drift = (Math.random() - 0.5) * 0.0008; + currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift); + + const volume = Math.floor(Math.random() * 400000 + 80000); + cumulativeVolume += volume; + + timeline.push({ + time, + price: parseFloat(currentPrice.toFixed(2)), + volume: cumulativeVolume, + change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2)) + }); + } + + // 生成概念异动数据 + const conceptNames = [ + '人工智能', 'AI眼镜', '机器人', '核电', '国企', '卫星导航', + '福建自贸区', '两岸融合', 'CRO', '三季报增长', '百货零售', + '人形机器人', '央企', '数据中心', 'CPO', '新能源', '电网设备', + '氢能源', '算力租赁', '厦门国资', '乳业', '低空安防', '创新药', + '商业航天', '控制权变更', '文化传媒', '海峡两岸' + ]; + + const alertTypes = ['surge_up', 'surge_down', 'volume_spike', 'limit_up', 'rank_jump']; + + // 生成 15-25 个异动 + const alertCount = Math.floor(Math.random() * 10) + 15; + const alerts = []; + const usedTimes = new Set(); + + for (let i = 0; i < alertCount; i++) { + // 随机选择一个时间点 + let timeIdx; + let attempts = 0; + do { + timeIdx = Math.floor(Math.random() * timeline.length); + attempts++; + } while (usedTimes.has(timeIdx) && attempts < 50); + + if (attempts >= 50) continue; + + // 同一时间可以有多个异动 + const time = timeline[timeIdx].time; + const conceptName = conceptNames[Math.floor(Math.random() * conceptNames.length)]; + const alertType = alertTypes[Math.floor(Math.random() * alertTypes.length)]; + + // 根据类型生成 alpha + let alpha; + if (alertType === 'surge_up') { + alpha = parseFloat((Math.random() * 3 + 2).toFixed(2)); // +2% ~ +5% + } else if (alertType === 'surge_down') { + alpha = parseFloat((-Math.random() * 3 - 1.5).toFixed(2)); // -1.5% ~ -4.5% + } else { + alpha = parseFloat((Math.random() * 4 - 1).toFixed(2)); // -1% ~ +3% + } + + const finalScore = Math.floor(Math.random() * 40 + 45); // 45-85分 + const ruleScore = Math.floor(Math.random() * 30 + 40); + const mlScore = Math.floor(Math.random() * 30 + 40); + + alerts.push({ + concept_id: `CONCEPT_${1000 + i}`, + concept_name: conceptName, + time, + alert_type: alertType, + alpha, + alpha_delta: parseFloat((Math.random() * 2 - 0.5).toFixed(2)), + amt_ratio: parseFloat((Math.random() * 5 + 1).toFixed(2)), + limit_up_count: alertType === 'limit_up' ? Math.floor(Math.random() * 5 + 1) : 0, + limit_up_ratio: parseFloat((Math.random() * 0.3).toFixed(3)), + final_score: finalScore, + rule_score: ruleScore, + ml_score: mlScore, + trigger_reason: finalScore >= 65 ? '规则强信号' : (mlScore >= 70 ? 'ML强信号' : '融合触发'), + importance_score: parseFloat((finalScore / 100).toFixed(2)), + index_price: timeline[timeIdx].price + }); + } + + // 按时间排序 + alerts.sort((a, b) => a.time.localeCompare(b.time)); + + // 统计异动类型 + const alertSummary = alerts.reduce((acc, alert) => { + acc[alert.alert_type] = (acc[alert.alert_type] || 0) + 1; + return acc; + }, {}); + + // 计算指数统计 + const prices = timeline.map(t => t.price); + const latestPrice = prices[prices.length - 1]; + const highPrice = Math.max(...prices); + const lowPrice = Math.min(...prices); + const changePct = ((latestPrice - prevClose) / prevClose * 100); + + console.log('[Mock Market] 获取热点概览数据:', { + date: tradeDate, + timelinePoints: timeline.length, + alertCount: alerts.length + }); + + return HttpResponse.json({ + success: true, + data: { + index: { + code: '000001.SH', + name: '上证指数', + latest_price: latestPrice, + prev_close: prevClose, + high: highPrice, + low: lowPrice, + change_pct: parseFloat(changePct.toFixed(2)), + timeline + }, + alerts, + alert_summary: alertSummary + }, + trade_date: tradeDate + }); + }), + + // 12. 市场统计数据(个股中心页面使用) http.get('/api/market/statistics', async ({ request }) => { await delay(200); const url = new URL(request.url); diff --git a/src/views/Community/components/HeroPanel.js b/src/views/Community/components/HeroPanel.js index 40a69a2a..3c957fe0 100644 --- a/src/views/Community/components/HeroPanel.js +++ b/src/views/Community/components/HeroPanel.js @@ -207,9 +207,12 @@ const CompactIndexCard = ({ indexCode, indexName }) => { const raw = chartData.rawData[idx]; if (!raw) return ''; + // 安全格式化数字 + const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-'; + // 计算涨跌 const prevClose = raw.prev_close || (idx > 0 ? chartData.rawData[idx - 1]?.close : raw.open) || raw.open; - const changeAmount = raw.close - prevClose; + const changeAmount = (raw.close != null && prevClose != null) ? (raw.close - prevClose) : 0; const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0; const isUp = changeAmount >= 0; const color = isUp ? '#ef5350' : '#26a69a'; @@ -218,22 +221,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => { return `
- 📅 ${raw.time} + 📅 ${raw.time || '-'}
开盘 - ${raw.open.toFixed(2)} + ${safeFixed(raw.open)} 收盘 - ${raw.close.toFixed(2)} + ${safeFixed(raw.close)} 最高 - ${raw.high.toFixed(2)} + ${safeFixed(raw.high)} 最低 - ${raw.low.toFixed(2)} + ${safeFixed(raw.low)}
涨跌幅 - ${sign}${changeAmount.toFixed(2)} (${sign}${changePct.toFixed(2)}%) + ${sign}${safeFixed(changeAmount)} (${sign}${safeFixed(changePct)}%)
@@ -529,7 +532,7 @@ const FlowingConcepts = () => { color={colors.text} whiteSpace="nowrap" > - {concept.change_pct > 0 ? '+' : ''}{concept.change_pct.toFixed(2)}% + {concept.change_pct > 0 ? '+' : ''}{concept.change_pct?.toFixed(2) ?? '-'}% diff --git a/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx b/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx new file mode 100644 index 00000000..0730b24f --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx @@ -0,0 +1,280 @@ +/** + * 迷你分时图组件 + * 用于灵活屏中显示证券的日内走势 + */ +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { Box, Spinner, Center, Text } from '@chakra-ui/react'; +import * as echarts from 'echarts'; +import type { ECharts, EChartsOption } from 'echarts'; + +import type { MiniTimelineChartProps, TimelineDataPoint } from '../types'; + +/** + * 生成交易时间刻度(用于 X 轴) + * A股交易时间:9:30-11:30, 13:00-15:00 + */ +const generateTimeTicks = (): string[] => { + const ticks: string[] = []; + // 上午 + for (let h = 9; h <= 11; h++) { + for (let m = h === 9 ? 30 : 0; m < 60; m++) { + if (h === 11 && m > 30) break; + ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`); + } + } + // 下午 + for (let h = 13; h <= 15; h++) { + for (let m = 0; m < 60; m++) { + if (h === 15 && m > 0) break; + ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`); + } + } + return ticks; +}; + +const TIME_TICKS = generateTimeTicks(); + +/** API 返回的分钟数据结构 */ +interface MinuteKLineItem { + time?: string; + timestamp?: string; + close?: number; + price?: number; +} + +/** API 响应结构 */ +interface KLineApiResponse { + success?: boolean; + data?: MinuteKLineItem[]; + error?: string; +} + +/** + * MiniTimelineChart 组件 + */ +const MiniTimelineChart: React.FC = ({ + code, + isIndex = false, + prevClose, + currentPrice, + height = 120, +}) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + const [timelineData, setTimelineData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 获取分钟数据 + useEffect(() => { + if (!code) return; + + const fetchData = async (): Promise => { + setLoading(true); + setError(null); + + try { + const apiPath = isIndex + ? `/api/index/${code}/kline?type=minute` + : `/api/stock/${code}/kline?type=minute`; + + const response = await fetch(apiPath); + const result: KLineApiResponse = await response.json(); + + if (result.success !== false && result.data) { + // 格式化数据 + const formatted: TimelineDataPoint[] = result.data.map(item => ({ + time: item.time || item.timestamp || '', + price: item.close || item.price || 0, + })); + setTimelineData(formatted); + } else { + setError(result.error || '暂无数据'); + } + } catch (e) { + setError('加载失败'); + } finally { + setLoading(false); + } + }; + + fetchData(); + + // 交易时间内每分钟刷新 + const now = new Date(); + const hours = now.getHours(); + const minutes = now.getMinutes(); + const currentMinutes = hours * 60 + minutes; + const isTrading = + (currentMinutes >= 570 && currentMinutes <= 690) || + (currentMinutes >= 780 && currentMinutes <= 900); + + let intervalId: NodeJS.Timeout | undefined; + if (isTrading) { + intervalId = setInterval(fetchData, 60000); // 1分钟刷新 + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [code, isIndex]); + + // 合并实时价格到数据中 + const chartData = useMemo((): TimelineDataPoint[] => { + if (!timelineData.length) return []; + + const data = [...timelineData]; + + // 如果有实时价格,添加到最新点 + if (currentPrice && data.length > 0) { + const now = new Date(); + const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + const lastItem = data[data.length - 1]; + + // 如果实时价格的时间比最后一条数据新,添加新点 + if (lastItem.time !== timeStr) { + data.push({ time: timeStr, price: currentPrice }); + } else { + // 更新最后一条 + data[data.length - 1] = { ...lastItem, price: currentPrice }; + } + } + + return data; + }, [timelineData, currentPrice]); + + // 渲染图表 + useEffect(() => { + if (!chartRef.current || loading || !chartData.length) return; + + if (!chartInstance.current) { + chartInstance.current = echarts.init(chartRef.current); + } + + const baseLine = prevClose || chartData[0]?.price || 0; + + // 计算价格范围 + const prices = chartData.map(d => d.price).filter(p => p > 0); + const minPrice = Math.min(...prices, baseLine); + const maxPrice = Math.max(...prices, baseLine); + const range = Math.max(maxPrice - baseLine, baseLine - minPrice) * 1.1; + + // 准备数据 + const times = chartData.map(d => d.time); + const values = chartData.map(d => d.price); + + // 判断涨跌 + const lastPrice = values[values.length - 1] || baseLine; + const isUp = lastPrice >= baseLine; + + const option: EChartsOption = { + grid: { + top: 5, + right: 5, + bottom: 5, + left: 5, + containLabel: false, + }, + xAxis: { + type: 'category', + data: times, + show: false, + boundaryGap: false, + }, + yAxis: { + type: 'value', + min: baseLine - range, + max: baseLine + range, + show: false, + }, + series: [ + { + type: 'line', + data: values, + smooth: false, + symbol: 'none', + lineStyle: { + width: 1.5, + color: isUp ? '#ef4444' : '#22c55e', + }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: isUp ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)' }, + { offset: 1, color: isUp ? 'rgba(239, 68, 68, 0.05)' : 'rgba(34, 197, 94, 0.05)' }, + ], + }, + }, + markLine: { + silent: true, + symbol: 'none', + data: [ + { + yAxis: baseLine, + lineStyle: { + color: '#666', + type: 'dashed', + width: 1, + }, + label: { show: false }, + }, + ], + }, + }, + ], + animation: false, + }; + + chartInstance.current.setOption(option); + + return () => { + // 不在这里销毁,只在组件卸载时销毁 + }; + }, [chartData, prevClose, loading]); + + // 组件卸载时销毁图表 + useEffect(() => { + return () => { + if (chartInstance.current) { + chartInstance.current.dispose(); + chartInstance.current = null; + } + }; + }, []); + + // 窗口 resize 处理 + useEffect(() => { + const handleResize = (): void => { + chartInstance.current?.resize(); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !chartData.length) { + return ( +
+ + {error || '暂无数据'} + +
+ ); + } + + return ; +}; + +export default MiniTimelineChart; diff --git a/src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.tsx b/src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.tsx new file mode 100644 index 00000000..4da68ca0 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.tsx @@ -0,0 +1,291 @@ +/** + * 盘口行情面板组件 + * 支持显示 5 档或 10 档买卖盘数据 + * + * 上交所: 5 档行情 + * 深交所: 10 档行情 + */ +import React, { useState } from 'react'; +import { + Box, + VStack, + HStack, + Text, + Button, + ButtonGroup, + useColorModeValue, + Tooltip, + Badge, +} from '@chakra-ui/react'; + +import type { OrderBookPanelProps } from '../types'; + +/** 格式化价格返回值 */ +interface FormattedPrice { + text: string; + color: string; +} + +/** + * 格式化成交量 + */ +const formatVolume = (volume: number): string => { + if (!volume || volume === 0) return '-'; + if (volume >= 10000) { + return `${(volume / 10000).toFixed(0)}万`; + } + if (volume >= 1000) { + return `${(volume / 1000).toFixed(1)}k`; + } + return String(volume); +}; + +/** + * 格式化价格 + */ +const formatPrice = (price: number, prevClose?: number): FormattedPrice => { + if (!price || price === 0) { + return { text: '-', color: 'gray.400' }; + } + + const text = price.toFixed(2); + + if (!prevClose || prevClose === 0) { + return { text, color: 'gray.600' }; + } + + if (price > prevClose) { + return { text, color: 'red.500' }; + } + if (price < prevClose) { + return { text, color: 'green.500' }; + } + return { text, color: 'gray.600' }; +}; + +/** OrderRow 组件 Props */ +interface OrderRowProps { + label: string; + price: number; + volume: number; + prevClose?: number; + isBid: boolean; + maxVolume: number; + isLimitPrice: boolean; +} + +/** + * 单行盘口 + */ +const OrderRow: React.FC = ({ + label, + price, + volume, + prevClose, + isBid, + maxVolume, + isLimitPrice, +}) => { + const bgColor = useColorModeValue( + isBid ? 'red.50' : 'green.50', + isBid ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)' + ); + const barColor = useColorModeValue( + isBid ? 'red.200' : 'green.200', + isBid ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)' + ); + const limitColor = useColorModeValue('orange.500', 'orange.300'); + + const priceInfo = formatPrice(price, prevClose); + const volumeText = formatVolume(volume); + + // 计算成交量条宽度 + const barWidth = maxVolume > 0 ? Math.min((volume / maxVolume) * 100, 100) : 0; + + return ( + + {/* 成交量条 */} + + + {/* 内容 */} + + {label} + + + + {priceInfo.text} + + {isLimitPrice && ( + + + {isBid ? '跌' : '涨'} + + + )} + + + {volumeText} + + + ); +}; + +/** + * OrderBookPanel 组件 + */ +const OrderBookPanel: React.FC = ({ + bidPrices = [], + bidVolumes = [], + askPrices = [], + askVolumes = [], + prevClose, + upperLimit, + lowerLimit, + defaultLevels = 5, +}) => { + const borderColor = useColorModeValue('gray.200', 'gray.700'); + const buttonBg = useColorModeValue('gray.100', 'gray.700'); + const bgColor = useColorModeValue('white', '#1a1a1a'); + + // 可切换显示的档位数 + const maxAvailableLevels = Math.max(bidPrices.length, askPrices.length, 1); + const [showLevels, setShowLevels] = useState(Math.min(defaultLevels, maxAvailableLevels)); + + // 计算最大成交量(用于条形图比例) + const displayBidVolumes = bidVolumes.slice(0, showLevels); + const displayAskVolumes = askVolumes.slice(0, showLevels); + const allVolumes = [...displayBidVolumes, ...displayAskVolumes].filter(v => v > 0); + const maxVolume = allVolumes.length > 0 ? Math.max(...allVolumes) : 0; + + // 判断是否为涨跌停价 + const isUpperLimit = (price: number): boolean => + !!upperLimit && Math.abs(price - upperLimit) < 0.001; + const isLowerLimit = (price: number): boolean => + !!lowerLimit && Math.abs(price - lowerLimit) < 0.001; + + // 卖盘(从卖N到卖1,即价格从高到低) + const askRows: React.ReactNode[] = []; + for (let i = showLevels - 1; i >= 0; i--) { + askRows.push( + + ); + } + + // 买盘(从买1到买N,即价格从高到低) + const bidRows: React.ReactNode[] = []; + for (let i = 0; i < showLevels; i++) { + bidRows.push( + + ); + } + + // 没有数据时的提示 + const hasData = bidPrices.length > 0 || askPrices.length > 0; + + if (!hasData) { + return ( + + + 暂无盘口数据 + + + ); + } + + return ( + + {/* 档位切换(只有当有超过5档数据时才显示) */} + {maxAvailableLevels > 5 && ( + + + + + + + )} + + {/* 卖盘 */} + {askRows} + + {/* 分隔线 + 当前价信息 */} + + {prevClose && ( + + 昨收 {prevClose.toFixed(2)} + + )} + + + {/* 买盘 */} + {bidRows} + + {/* 涨跌停价信息 */} + {(upperLimit || lowerLimit) && ( + + {lowerLimit && 跌停 {lowerLimit.toFixed(2)}} + {upperLimit && 涨停 {upperLimit.toFixed(2)}} + + )} + + ); +}; + +export default OrderBookPanel; diff --git a/src/views/StockOverview/components/FlexScreen/components/QuoteTile.tsx b/src/views/StockOverview/components/FlexScreen/components/QuoteTile.tsx new file mode 100644 index 00000000..d5a51be5 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/components/QuoteTile.tsx @@ -0,0 +1,288 @@ +/** + * 行情瓷砖组件 + * 单个证券的实时行情展示卡片,包含分时图和五档盘口 + */ +import React, { useState } from 'react'; +import { + Box, + VStack, + HStack, + Text, + IconButton, + Tooltip, + useColorModeValue, + Collapse, + Badge, +} from '@chakra-ui/react'; +import { CloseIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; +import { useNavigate } from 'react-router-dom'; + +import MiniTimelineChart from './MiniTimelineChart'; +import OrderBookPanel from './OrderBookPanel'; +import type { QuoteTileProps, QuoteData } from '../types'; + +/** + * 格式化价格显示 + */ +const formatPrice = (price?: number): string => { + if (!price || isNaN(price)) return '-'; + return price.toFixed(2); +}; + +/** + * 格式化涨跌幅 + */ +const formatChangePct = (pct?: number): string => { + if (!pct || isNaN(pct)) return '0.00%'; + const sign = pct > 0 ? '+' : ''; + return `${sign}${pct.toFixed(2)}%`; +}; + +/** + * 格式化涨跌额 + */ +const formatChange = (change?: number): string => { + if (!change || isNaN(change)) return '-'; + const sign = change > 0 ? '+' : ''; + return `${sign}${change.toFixed(2)}`; +}; + +/** + * 格式化成交额 + */ +const formatAmount = (amount?: number): string => { + if (!amount || isNaN(amount)) return '-'; + if (amount >= 100000000) { + return `${(amount / 100000000).toFixed(2)}亿`; + } + if (amount >= 10000) { + return `${(amount / 10000).toFixed(0)}万`; + } + return amount.toFixed(0); +}; + +/** + * QuoteTile 组件 + */ +const QuoteTile: React.FC = ({ + code, + name, + quote = {}, + isIndex = false, + onRemove, +}) => { + const navigate = useNavigate(); + const [expanded, setExpanded] = useState(true); + + // 类型断言,确保类型安全 + const quoteData = quote as Partial; + + // 颜色主题 + const cardBg = useColorModeValue('white', '#1a1a1a'); + const borderColor = useColorModeValue('gray.200', '#333'); + const hoverBorderColor = useColorModeValue('purple.300', '#666'); + const textColor = useColorModeValue('gray.800', 'white'); + const subTextColor = useColorModeValue('gray.500', 'gray.400'); + + // 涨跌色 + const { price, prevClose, change, changePct, amount } = quoteData; + const priceColor = useColorModeValue( + !prevClose || price === prevClose + ? 'gray.800' + : price && price > prevClose + ? 'red.500' + : 'green.500', + !prevClose || price === prevClose + ? 'gray.200' + : price && price > prevClose + ? 'red.400' + : 'green.400' + ); + + // 涨跌幅背景色 + const changeBgColor = useColorModeValue( + !changePct || changePct === 0 + ? 'gray.100' + : changePct > 0 + ? 'red.100' + : 'green.100', + !changePct || changePct === 0 + ? 'gray.700' + : changePct > 0 + ? 'rgba(239, 68, 68, 0.2)' + : 'rgba(34, 197, 94, 0.2)' + ); + + // 跳转到详情页 + const handleNavigate = (): void => { + if (isIndex) { + // 指数暂无详情页 + return; + } + navigate(`/company?scode=${code}`); + }; + + // 获取盘口数据(带类型安全) + const bidPrices = 'bidPrices' in quoteData ? (quoteData.bidPrices as number[]) : []; + const bidVolumes = 'bidVolumes' in quoteData ? (quoteData.bidVolumes as number[]) : []; + const askPrices = 'askPrices' in quoteData ? (quoteData.askPrices as number[]) : []; + const askVolumes = 'askVolumes' in quoteData ? (quoteData.askVolumes as number[]) : []; + const upperLimit = 'upperLimit' in quoteData ? (quoteData.upperLimit as number | undefined) : undefined; + const lowerLimit = 'lowerLimit' in quoteData ? (quoteData.lowerLimit as number | undefined) : undefined; + const openPrice = 'open' in quoteData ? (quoteData.open as number | undefined) : undefined; + + return ( + + {/* 头部 */} + setExpanded(!expanded)} + > + {/* 名称和代码 */} + + + { + e.stopPropagation(); + handleNavigate(); + }} + > + {name || code} + + {isIndex && ( + + 指数 + + )} + + + {code} + + + + {/* 价格信息 */} + + + {formatPrice(price)} + + + + {formatChangePct(changePct)} + + + {formatChange(change)} + + + + + {/* 操作按钮 */} + + : } + size="xs" + variant="ghost" + aria-label={expanded ? '收起' : '展开'} + onClick={(e) => { + e.stopPropagation(); + setExpanded(!expanded); + }} + /> + + } + size="xs" + variant="ghost" + colorScheme="red" + aria-label="移除" + onClick={(e) => { + e.stopPropagation(); + onRemove?.(code); + }} + /> + + + + + {/* 可折叠内容 */} + + + {/* 统计信息 */} + + + 昨收: + {formatPrice(prevClose)} + + + 今开: + {formatPrice(openPrice)} + + + 成交额: + {formatAmount(amount)} + + + + {/* 分时图 */} + + + + + {/* 盘口(指数没有盘口) */} + {!isIndex && ( + + + 盘口 {bidPrices.length > 5 ? '(10档)' : '(5档)'} + + + + )} + + + + ); +}; + +export default QuoteTile; diff --git a/src/views/StockOverview/components/FlexScreen/components/index.ts b/src/views/StockOverview/components/FlexScreen/components/index.ts new file mode 100644 index 00000000..4777c0f1 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/components/index.ts @@ -0,0 +1,7 @@ +/** + * 组件导出文件 + */ + +export { default as MiniTimelineChart } from './MiniTimelineChart'; +export { default as OrderBookPanel } from './OrderBookPanel'; +export { default as QuoteTile } from './QuoteTile'; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/constants.ts b/src/views/StockOverview/components/FlexScreen/hooks/constants.ts new file mode 100644 index 00000000..f9d41b30 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/constants.ts @@ -0,0 +1,46 @@ +/** + * WebSocket 配置常量 + */ + +import type { Exchange } from '../types'; + +/** + * 获取 WebSocket 配置 + * - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss:// + * - 开发环境 (HTTP): 直连 ws:// + */ +const getWsConfig = (): Record => { + // 服务端渲染或测试环境使用默认配置 + if (typeof window === 'undefined') { + return { + SSE: 'ws://49.232.185.254:8765', + SZSE: 'ws://222.128.1.157:8765', + }; + } + + const isHttps = window.location.protocol === 'https:'; + const host = window.location.host; + + if (isHttps) { + // 生产环境:通过 Nginx 代理 + return { + SSE: `wss://${host}/ws/sse`, // 上交所 - Nginx 代理 + SZSE: `wss://${host}/ws/szse`, // 深交所 - Nginx 代理 + }; + } + + // 开发环境:直连 + return { + SSE: 'ws://49.232.185.254:8765', // 上交所 + SZSE: 'ws://222.128.1.157:8765', // 深交所 + }; +}; + +/** WebSocket 服务地址 */ +export const WS_CONFIG: Record = getWsConfig(); + +/** 心跳间隔 (ms) */ +export const HEARTBEAT_INTERVAL = 30000; + +/** 重连间隔 (ms) */ +export const RECONNECT_INTERVAL = 3000; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/index.ts b/src/views/StockOverview/components/FlexScreen/hooks/index.ts new file mode 100644 index 00000000..ffe3399e --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/index.ts @@ -0,0 +1,7 @@ +/** + * Hooks 导出文件 + */ + +export { useRealtimeQuote } from './useRealtimeQuote'; +export * from './constants'; +export * from './utils'; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts new file mode 100644 index 00000000..09687696 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -0,0 +1,722 @@ +/** + * 实时行情 Hook + * 管理上交所和深交所 WebSocket 连接,获取实时行情数据 + * + * 连接方式: + * - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss:// (如 wss://valuefrontier.cn/ws/sse) + * - 开发环境 (HTTP): 直连 ws:// + * + * 上交所 (SSE): 需主动订阅,提供五档行情 + * 深交所 (SZSE): 自动推送,提供十档行情 + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { logger } from '@utils/logger'; +import { WS_CONFIG, HEARTBEAT_INTERVAL, RECONNECT_INTERVAL } from './constants'; +import { getExchange, normalizeCode, extractOrderBook, calcChangePct } from './utils'; +import type { + Exchange, + ConnectionStatus, + QuotesMap, + QuoteData, + SSEMessage, + SSEQuoteItem, + SZSEMessage, + SZSERealtimeMessage, + SZSESnapshotMessage, + SZSEStockData, + SZSEIndexData, + SZSEBondData, + SZSEHKStockData, + SZSEAfterhoursData, + UseRealtimeQuoteReturn, +} from '../types'; + +/** + * 处理上交所消息 + * 注意:上交所返回的 code 不带后缀,但通过 msg.type 区分 'stock' 和 'index' + * 存储时使用带后缀的完整代码作为 key(如 000001.SH) + */ +const handleSSEMessage = ( + msg: SSEMessage, + subscribedCodes: Set, + prevQuotes: QuotesMap +): QuotesMap | null => { + if (msg.type !== 'stock' && msg.type !== 'index') { + return null; + } + + const data = msg.data || {}; + const updated: QuotesMap = { ...prevQuotes }; + let hasUpdate = false; + const isIndex = msg.type === 'index'; + + Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => { + // 生成带后缀的完整代码(上交所统一用 .SH) + const fullCode = code.includes('.') ? code : `${code}.SH`; + + if (subscribedCodes.has(code) || subscribedCodes.has(fullCode)) { + hasUpdate = true; + updated[fullCode] = { + code: fullCode, + name: quote.security_name, + price: quote.last_price, + prevClose: quote.prev_close, + open: quote.open_price, + high: quote.high_price, + low: quote.low_price, + volume: quote.volume, + amount: quote.amount, + change: quote.last_price - quote.prev_close, + changePct: calcChangePct(quote.last_price, quote.prev_close), + bidPrices: quote.bid_prices || [], + bidVolumes: quote.bid_volumes || [], + askPrices: quote.ask_prices || [], + askVolumes: quote.ask_volumes || [], + updateTime: quote.trade_time, + exchange: 'SSE', + } as QuoteData; + } + }); + + return hasUpdate ? updated : null; +}; + +/** + * 处理深交所实时消息 + * 注意:深交所返回的 security_id 可能带后缀也可能不带 + * 存储时统一使用带后缀的完整代码作为 key(如 000001.SZ) + */ +const handleSZSERealtimeMessage = ( + msg: SZSERealtimeMessage, + subscribedCodes: Set, + prevQuotes: QuotesMap +): QuotesMap | null => { + const { category, data, timestamp } = msg; + const rawCode = data.security_id; + // 生成带后缀的完整代码(深交所统一用 .SZ) + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; + + if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) { + return null; + } + + const updated: QuotesMap = { ...prevQuotes }; + + switch (category) { + case 'stock': { + const stockData = data as SZSEStockData; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawData = data as any; // 用于检查替代字段名 + + // 调试日志:检查深交所返回的盘口原始数据(临时使用 warn 级别方便调试) + if (!stockData.bids || stockData.bids.length === 0) { + logger.warn('FlexScreen', `SZSE股票数据无盘口 ${fullCode}`, { + hasBids: !!stockData.bids, + hasAsks: !!stockData.asks, + bidsLength: stockData.bids?.length || 0, + asksLength: stockData.asks?.length || 0, + // 检查替代字段名 + hasBidPrices: !!rawData.bid_prices, + hasAskPrices: !!rawData.ask_prices, + dataKeys: Object.keys(stockData), // 查看服务端实际返回了哪些字段 + }); + } + + // 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式 + let bidPrices: number[] = []; + let bidVolumes: number[] = []; + let askPrices: number[] = []; + let askVolumes: number[] = []; + + if (stockData.bids && stockData.bids.length > 0) { + const extracted = extractOrderBook(stockData.bids); + bidPrices = extracted.prices; + bidVolumes = extracted.volumes; + } else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) { + // 替代格式:bid_prices 和 bid_volumes 分离 + bidPrices = rawData.bid_prices; + bidVolumes = rawData.bid_volumes || []; + } + + if (stockData.asks && stockData.asks.length > 0) { + const extracted = extractOrderBook(stockData.asks); + askPrices = extracted.prices; + askVolumes = extracted.volumes; + } else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) { + // 替代格式:ask_prices 和 ask_volumes 分离 + askPrices = rawData.ask_prices; + askVolumes = rawData.ask_volumes || []; + } + + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: stockData.last_px, + prevClose: stockData.prev_close, + open: stockData.open_px, + high: stockData.high_px, + low: stockData.low_px, + volume: stockData.volume, + amount: stockData.amount, + numTrades: stockData.num_trades, + upperLimit: stockData.upper_limit, + lowerLimit: stockData.lower_limit, + change: stockData.last_px - stockData.prev_close, + changePct: calcChangePct(stockData.last_px, stockData.prev_close), + bidPrices, + bidVolumes, + askPrices, + askVolumes, + tradingPhase: stockData.trading_phase, + updateTime: timestamp, + exchange: 'SZSE', + } as QuoteData; + break; + } + + case 'index': { + const indexData = data as SZSEIndexData; + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: indexData.current_index, + prevClose: indexData.prev_close, + open: indexData.open_index, + high: indexData.high_index, + low: indexData.low_index, + close: indexData.close_index, + volume: indexData.volume, + amount: indexData.amount, + numTrades: indexData.num_trades, + change: indexData.current_index - indexData.prev_close, + changePct: calcChangePct(indexData.current_index, indexData.prev_close), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + tradingPhase: indexData.trading_phase, + updateTime: timestamp, + exchange: 'SZSE', + } as QuoteData; + break; + } + + case 'bond': { + const bondData = data as SZSEBondData; + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: bondData.last_px, + prevClose: bondData.prev_close, + open: bondData.open_px, + high: bondData.high_px, + low: bondData.low_px, + volume: bondData.volume, + amount: bondData.amount, + numTrades: bondData.num_trades, + weightedAvgPx: bondData.weighted_avg_px, + change: bondData.last_px - bondData.prev_close, + changePct: calcChangePct(bondData.last_px, bondData.prev_close), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + tradingPhase: bondData.trading_phase, + updateTime: timestamp, + exchange: 'SZSE', + isBond: true, + } as QuoteData; + break; + } + + case 'hk_stock': { + const hkData = data as SZSEHKStockData; + const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids); + const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks); + + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: hkData.last_px, + prevClose: hkData.prev_close, + open: hkData.open_px, + high: hkData.high_px, + low: hkData.low_px, + volume: hkData.volume, + amount: hkData.amount, + numTrades: hkData.num_trades, + nominalPx: hkData.nominal_px, + referencePx: hkData.reference_px, + change: hkData.last_px - hkData.prev_close, + changePct: calcChangePct(hkData.last_px, hkData.prev_close), + bidPrices, + bidVolumes, + askPrices, + askVolumes, + tradingPhase: hkData.trading_phase, + updateTime: timestamp, + exchange: 'SZSE', + isHK: true, + } as QuoteData; + break; + } + + case 'afterhours_block': + case 'afterhours_trading': { + const afterhoursData = data as SZSEAfterhoursData; + const existing = prevQuotes[fullCode]; + if (existing) { + updated[fullCode] = { + ...existing, + afterhours: { + bidPx: afterhoursData.bid_px, + bidSize: afterhoursData.bid_size, + offerPx: afterhoursData.offer_px, + offerSize: afterhoursData.offer_size, + volume: afterhoursData.volume, + amount: afterhoursData.amount, + numTrades: afterhoursData.num_trades || 0, + }, + updateTime: timestamp, + } as QuoteData; + } + break; + } + + default: + return null; + } + + return updated; +}; + +/** + * 处理深交所快照消息 + * 存储时统一使用带后缀的完整代码作为 key + */ +const handleSZSESnapshotMessage = ( + msg: SZSESnapshotMessage, + subscribedCodes: Set, + prevQuotes: QuotesMap +): QuotesMap | null => { + const { stocks = [], indexes = [], bonds = [] } = msg.data || {}; + const updated: QuotesMap = { ...prevQuotes }; + let hasUpdate = false; + + stocks.forEach((s: SZSEStockData) => { + const rawCode = s.security_id; + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; + + if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { + hasUpdate = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawData = s as any; // 用于检查替代字段名 + + // 调试日志:检查快照消息中的盘口数据(无盘口时警告) + if (!s.bids || s.bids.length === 0) { + logger.warn('FlexScreen', `SZSE快照股票数据无盘口 ${fullCode}`, { + hasBids: !!s.bids, + hasAsks: !!s.asks, + hasBidPrices: !!rawData.bid_prices, + hasAskPrices: !!rawData.ask_prices, + dataKeys: Object.keys(s), + }); + } + + // 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式 + let bidPrices: number[] = []; + let bidVolumes: number[] = []; + let askPrices: number[] = []; + let askVolumes: number[] = []; + + if (s.bids && s.bids.length > 0) { + const extracted = extractOrderBook(s.bids); + bidPrices = extracted.prices; + bidVolumes = extracted.volumes; + } else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) { + bidPrices = rawData.bid_prices; + bidVolumes = rawData.bid_volumes || []; + } + + if (s.asks && s.asks.length > 0) { + const extracted = extractOrderBook(s.asks); + askPrices = extracted.prices; + askVolumes = extracted.volumes; + } else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) { + askPrices = rawData.ask_prices; + askVolumes = rawData.ask_volumes || []; + } + + updated[fullCode] = { + code: fullCode, + name: '', + price: s.last_px, + prevClose: s.prev_close, + open: s.open_px, + high: s.high_px, + low: s.low_px, + volume: s.volume, + amount: s.amount, + numTrades: s.num_trades, + upperLimit: s.upper_limit, + lowerLimit: s.lower_limit, + change: s.last_px - s.prev_close, + changePct: calcChangePct(s.last_px, s.prev_close), + bidPrices, + bidVolumes, + askPrices, + askVolumes, + exchange: 'SZSE', + } as QuoteData; + } + }); + + indexes.forEach((i: SZSEIndexData) => { + const rawCode = i.security_id; + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; + + if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { + hasUpdate = true; + updated[fullCode] = { + code: fullCode, + name: '', + price: i.current_index, + prevClose: i.prev_close, + open: i.open_index, + high: i.high_index, + low: i.low_index, + volume: i.volume, + amount: i.amount, + numTrades: i.num_trades, + change: i.current_index - i.prev_close, + changePct: calcChangePct(i.current_index, i.prev_close), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + exchange: 'SZSE', + } as QuoteData; + } + }); + + bonds.forEach((b: SZSEBondData) => { + const rawCode = b.security_id; + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; + + if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { + hasUpdate = true; + updated[fullCode] = { + code: fullCode, + name: '', + price: b.last_px, + prevClose: b.prev_close, + open: b.open_px, + high: b.high_px, + low: b.low_px, + volume: b.volume, + amount: b.amount, + change: b.last_px - b.prev_close, + changePct: calcChangePct(b.last_px, b.prev_close), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + exchange: 'SZSE', + isBond: true, + } as QuoteData; + } + }); + + return hasUpdate ? updated : null; +}; + +/** + * 实时行情 Hook + * @param codes - 订阅的证券代码列表 + */ +export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn => { + const [quotes, setQuotes] = useState({}); + const [connected, setConnected] = useState({ SSE: false, SZSE: false }); + + const wsRefs = useRef>({ SSE: null, SZSE: null }); + const heartbeatRefs = useRef>({ SSE: null, SZSE: null }); + const reconnectRefs = useRef>({ SSE: null, SZSE: null }); + const subscribedCodes = useRef>>({ + SSE: new Set(), + SZSE: new Set(), + }); + + const stopHeartbeat = useCallback((exchange: Exchange) => { + if (heartbeatRefs.current[exchange]) { + clearInterval(heartbeatRefs.current[exchange]!); + heartbeatRefs.current[exchange] = null; + } + }, []); + + const startHeartbeat = useCallback((exchange: Exchange) => { + stopHeartbeat(exchange); + heartbeatRefs.current[exchange] = setInterval(() => { + const ws = wsRefs.current[exchange]; + if (ws && ws.readyState === WebSocket.OPEN) { + const msg = exchange === 'SSE' ? { action: 'ping' } : { type: 'ping' }; + ws.send(JSON.stringify(msg)); + } + }, HEARTBEAT_INTERVAL); + }, [stopHeartbeat]); + + const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => { + if (msg.type === 'pong') return; + + if (exchange === 'SSE') { + const result = handleSSEMessage( + msg as SSEMessage, + subscribedCodes.current.SSE, + {} // Will be merged with current state + ); + if (result) { + setQuotes(prev => ({ ...prev, ...result })); + } + } else { + if (msg.type === 'realtime') { + setQuotes(prev => { + const result = handleSZSERealtimeMessage( + msg as SZSERealtimeMessage, + subscribedCodes.current.SZSE, + prev + ); + return result || prev; + }); + } else if (msg.type === 'snapshot') { + setQuotes(prev => { + const result = handleSZSESnapshotMessage( + msg as SZSESnapshotMessage, + subscribedCodes.current.SZSE, + prev + ); + return result || prev; + }); + } + } + }, []); + + const createConnection = useCallback((exchange: Exchange) => { + // 防御性检查:确保 HTTPS 页面不会意外连接 ws://(Mixed Content 安全错误) + // 正常情况下 WS_CONFIG 会自动根据协议返回正确的 URL,这里是备用保护 + const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'; + const wsUrl = WS_CONFIG[exchange]; + const isInsecureWs = wsUrl.startsWith('ws://'); + + if (isHttps && isInsecureWs) { + logger.warn( + 'FlexScreen', + `${exchange} WebSocket 配置错误:HTTPS 页面尝试连接 ws:// 端点,请检查 Nginx 代理配置` + ); + return; + } + + if (wsRefs.current[exchange]) { + wsRefs.current[exchange]!.close(); + } + + try { + const ws = new WebSocket(wsUrl); + wsRefs.current[exchange] = ws; + + ws.onopen = () => { + logger.info('FlexScreen', `${exchange} WebSocket 已连接`); + setConnected(prev => ({ ...prev, [exchange]: true })); + + if (exchange === 'SSE') { + // subscribedCodes 存的是带后缀的完整代码,发送给 WS 需要去掉后缀 + const fullCodes = Array.from(subscribedCodes.current.SSE); + const baseCodes = fullCodes.map(c => normalizeCode(c)); + if (baseCodes.length > 0) { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes: baseCodes, + })); + } + } + + startHeartbeat(exchange); + }; + + ws.onmessage = (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + handleMessage(exchange, msg); + } catch (e) { + logger.warn('FlexScreen', `${exchange} 消息解析失败`, e); + } + }; + + ws.onerror = (error: Event) => { + logger.error('FlexScreen', `${exchange} WebSocket 错误`, error); + }; + + ws.onclose = () => { + logger.info('FlexScreen', `${exchange} WebSocket 断开`); + setConnected(prev => ({ ...prev, [exchange]: false })); + stopHeartbeat(exchange); + + // 自动重连(仅在非 HTTPS + ws:// 场景下) + if (!reconnectRefs.current[exchange] && subscribedCodes.current[exchange].size > 0) { + reconnectRefs.current[exchange] = setTimeout(() => { + reconnectRefs.current[exchange] = null; + if (subscribedCodes.current[exchange].size > 0) { + createConnection(exchange); + } + }, RECONNECT_INTERVAL); + } + }; + } catch (e) { + logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, e); + setConnected(prev => ({ ...prev, [exchange]: false })); + } + }, [startHeartbeat, stopHeartbeat, handleMessage]); + + const subscribe = useCallback((code: string) => { + const exchange = getExchange(code); + // 确保使用带后缀的完整代码 + const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; + const baseCode = normalizeCode(code); + + subscribedCodes.current[exchange].add(fullCode); + + const ws = wsRefs.current[exchange]; + if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes: [baseCode], // 发送给 WS 用不带后缀的代码 + })); + } + + if (!ws || ws.readyState !== WebSocket.OPEN) { + createConnection(exchange); + } + }, [createConnection]); + + const unsubscribe = useCallback((code: string) => { + const exchange = getExchange(code); + // 确保使用带后缀的完整代码 + const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; + + subscribedCodes.current[exchange].delete(fullCode); + + setQuotes(prev => { + const updated = { ...prev }; + delete updated[fullCode]; // 删除时也用带后缀的 key + return updated; + }); + + if (subscribedCodes.current[exchange].size === 0) { + const ws = wsRefs.current[exchange]; + if (ws) { + ws.close(); + wsRefs.current[exchange] = null; + } + } + }, []); + + // 初始化和 codes 变化处理 + // 注意:codes 现在是带后缀的完整代码(如 000001.SH) + useEffect(() => { + if (!codes || codes.length === 0) return; + + // 使用带后缀的完整代码作为内部 key + const newSseCodes = new Set(); + const newSzseCodes = new Set(); + + codes.forEach(code => { + const exchange = getExchange(code); + // 确保代码带后缀 + const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; + if (exchange === 'SSE') { + newSseCodes.add(fullCode); + } else { + newSzseCodes.add(fullCode); + } + }); + + // 更新上交所订阅 + const oldSseCodes = subscribedCodes.current.SSE; + const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c)); + // 发送给 WebSocket 的代码需要去掉后缀 + const sseToAddBase = sseToAdd.map(c => normalizeCode(c)); + + if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) { + subscribedCodes.current.SSE = newSseCodes; + const ws = wsRefs.current.SSE; + + if (ws && ws.readyState === WebSocket.OPEN && sseToAddBase.length > 0) { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes: sseToAddBase, + })); + } + + if (sseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { + createConnection('SSE'); + } + + if (newSseCodes.size === 0 && ws) { + ws.close(); + wsRefs.current.SSE = null; + } + } + + // 更新深交所订阅 + const oldSzseCodes = subscribedCodes.current.SZSE; + const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c)); + + if (szseToAdd.length > 0 || newSzseCodes.size !== oldSzseCodes.size) { + subscribedCodes.current.SZSE = newSzseCodes; + const ws = wsRefs.current.SZSE; + + if (szseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { + createConnection('SZSE'); + } + + if (newSzseCodes.size === 0 && ws) { + ws.close(); + wsRefs.current.SZSE = null; + } + } + + // 清理已取消订阅的 quotes(使用带后缀的完整代码) + const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]); + setQuotes(prev => { + const updated: QuotesMap = {}; + Object.keys(prev).forEach(code => { + if (allNewCodes.has(code)) { + updated[code] = prev[code]; + } + }); + return updated; + }); + }, [codes, createConnection]); + + // 清理 + useEffect(() => { + return () => { + (['SSE', 'SZSE'] as Exchange[]).forEach(exchange => { + stopHeartbeat(exchange); + if (reconnectRefs.current[exchange]) { + clearTimeout(reconnectRefs.current[exchange]!); + } + const ws = wsRefs.current[exchange]; + if (ws) { + ws.close(); + } + }); + }; + }, [stopHeartbeat]); + + return { quotes, connected, subscribe, unsubscribe }; +}; + +export default useRealtimeQuote; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts new file mode 100644 index 00000000..dcc54890 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts @@ -0,0 +1,148 @@ +/** + * 实时行情相关工具函数 + */ + +import type { Exchange, OrderBookLevel } from '../types'; + +/** + * 判断证券代码属于哪个交易所 + * @param code - 证券代码(可带或不带后缀) + * @param isIndex - 是否为指数(用于区分同代码的指数和股票,如 000001) + * @returns 交易所标识 + */ +export const getExchange = (code: string, isIndex?: boolean): Exchange => { + // 如果已带后缀,直接判断 + if (code.includes('.')) { + return code.endsWith('.SH') ? 'SSE' : 'SZSE'; + } + + const baseCode = code; + + // 6开头为上海股票 + if (baseCode.startsWith('6')) { + return 'SSE'; + } + + // 5开头是上海 ETF + if (baseCode.startsWith('5')) { + return 'SSE'; + } + + // 399开头是深证指数 + if (baseCode.startsWith('399')) { + return 'SZSE'; + } + + // 000开头:如果是指数则为上交所(上证指数000001),否则为深交所(平安银行000001) + if (baseCode.startsWith('000')) { + return isIndex ? 'SSE' : 'SZSE'; + } + + // 0、3开头是深圳股票 + if (baseCode.startsWith('0') || baseCode.startsWith('3')) { + return 'SZSE'; + } + + // 1开头是深圳 ETF/债券 + if (baseCode.startsWith('1')) { + return 'SZSE'; + } + + // 默认上海 + return 'SSE'; +}; + +/** + * 获取证券代码的完整格式(带交易所后缀) + * @param code - 原始代码 + * @param isIndex - 是否为指数 + * @returns 带后缀的代码 + */ +export const getFullCode = (code: string, isIndex?: boolean): string => { + if (code.includes('.')) { + return code; // 已带后缀 + } + const exchange = getExchange(code, isIndex); + return `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`; +}; + +/** + * 标准化证券代码为无后缀格式 + * @param code - 原始代码 + * @returns 无后缀代码 + */ +export const normalizeCode = (code: string): string => { + return code.split('.')[0]; +}; + +/** + * 盘口数据可能的格式(根据不同的 WebSocket 服务端实现) + */ +type OrderBookInput = + | OrderBookLevel[] // 格式1: [{price, volume}, ...] + | Array<[number, number]> // 格式2: [[price, volume], ...] + | { prices: number[]; volumes: number[] } // 格式3: {prices: [...], volumes: [...]} + | undefined; + +/** + * 从深交所 bids/asks 数组提取价格和量数组 + * 支持多种可能的数据格式 + * @param orderBook - 盘口数据,支持多种格式 + * @returns { prices, volumes } + */ +export const extractOrderBook = ( + orderBook: OrderBookInput +): { prices: number[]; volumes: number[] } => { + if (!orderBook) { + return { prices: [], volumes: [] }; + } + + // 格式3: 已经是 {prices, volumes} 结构 + if (!Array.isArray(orderBook) && 'prices' in orderBook && 'volumes' in orderBook) { + return { + prices: orderBook.prices || [], + volumes: orderBook.volumes || [], + }; + } + + // 必须是数组才能继续 + if (!Array.isArray(orderBook) || orderBook.length === 0) { + return { prices: [], volumes: [] }; + } + + const firstItem = orderBook[0]; + + // 格式2: [[price, volume], ...] + if (Array.isArray(firstItem)) { + const prices = orderBook.map((item: unknown) => { + const arr = item as [number, number]; + return arr[0] || 0; + }); + const volumes = orderBook.map((item: unknown) => { + const arr = item as [number, number]; + return arr[1] || 0; + }); + return { prices, volumes }; + } + + // 格式1: [{price, volume}, ...] (标准格式) + if (typeof firstItem === 'object' && firstItem !== null) { + const typedBook = orderBook as OrderBookLevel[]; + const prices = typedBook.map(item => item.price || 0); + const volumes = typedBook.map(item => item.volume || 0); + return { prices, volumes }; + } + + return { prices: [], volumes: [] }; +}; + +/** + * 计算涨跌幅 + * @param price - 当前价 + * @param prevClose - 昨收价 + * @returns 涨跌幅百分比 + */ +export const calcChangePct = (price: number, prevClose: number): number => { + if (!prevClose || prevClose === 0) return 0; + return ((price - prevClose) / prevClose) * 100; +}; diff --git a/src/views/StockOverview/components/FlexScreen/index.tsx b/src/views/StockOverview/components/FlexScreen/index.tsx new file mode 100644 index 00000000..dfda551e --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/index.tsx @@ -0,0 +1,507 @@ +/** + * 灵活屏组件 + * 用户可自定义添加关注的指数/个股,实时显示行情 + * + * 功能: + * 1. 添加/删除自选证券 + * 2. 显示实时行情(通过 WebSocket) + * 3. 显示分时走势(结合 ClickHouse 历史数据) + * 4. 显示五档盘口(上交所5档,深交所10档) + * 5. 本地存储自选列表 + */ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Box, + Card, + CardBody, + VStack, + HStack, + Heading, + Text, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + IconButton, + SimpleGrid, + Flex, + Spacer, + Icon, + useColorModeValue, + useToast, + Badge, + Tooltip, + Collapse, + List, + ListItem, + Spinner, + Center, + Menu, + MenuButton, + MenuList, + MenuItem, + Tag, + TagLabel, +} from '@chakra-ui/react'; +import { + SearchIcon, + CloseIcon, + AddIcon, + ChevronDownIcon, + ChevronUpIcon, + SettingsIcon, +} from '@chakra-ui/icons'; +import { + FaDesktop, + FaTrash, + FaSync, + FaWifi, + FaExclamationCircle, +} from 'react-icons/fa'; + +import { useRealtimeQuote } from './hooks'; +import { getFullCode } from './hooks/utils'; +import QuoteTile from './components/QuoteTile'; +import { logger } from '@utils/logger'; +import type { WatchlistItem, ConnectionStatus } from './types'; + +// 本地存储 key +const STORAGE_KEY = 'flexscreen_watchlist'; + +// 默认自选列表 +const DEFAULT_WATCHLIST: WatchlistItem[] = [ + { code: '000001', name: '上证指数', isIndex: true }, + { code: '399001', name: '深证成指', isIndex: true }, + { code: '399006', name: '创业板指', isIndex: true }, +]; + +// 热门推荐 +const HOT_RECOMMENDATIONS: WatchlistItem[] = [ + { code: '000001', name: '上证指数', isIndex: true }, + { code: '399001', name: '深证成指', isIndex: true }, + { code: '399006', name: '创业板指', isIndex: true }, + { code: '399300', name: '沪深300', isIndex: true }, + { code: '600519', name: '贵州茅台', isIndex: false }, + { code: '000858', name: '五粮液', isIndex: false }, + { code: '300750', name: '宁德时代', isIndex: false }, + { code: '002594', name: '比亚迪', isIndex: false }, +]; + +/** 搜索结果项 */ +interface SearchResultItem { + stock_code: string; + stock_name: string; + isIndex?: boolean; + code?: string; + name?: string; +} + +/** 搜索 API 响应 */ +interface SearchApiResponse { + success: boolean; + data?: SearchResultItem[]; +} + +/** 连接状态信息 */ +interface ConnectionStatusInfo { + color: string; + text: string; +} + +/** + * FlexScreen 组件 + */ +const FlexScreen: React.FC = () => { + const toast = useToast(); + + // 自选列表 + const [watchlist, setWatchlist] = useState([]); + // 搜索状态 + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showResults, setShowResults] = useState(false); + // 面板状态 + const [isCollapsed, setIsCollapsed] = useState(false); + + // 颜色主题 + const cardBg = useColorModeValue('white', '#1a1a1a'); + const borderColor = useColorModeValue('gray.200', '#333'); + const textColor = useColorModeValue('gray.800', 'white'); + const subTextColor = useColorModeValue('gray.600', 'gray.400'); + const searchBg = useColorModeValue('gray.50', '#2a2a2a'); + const hoverBg = useColorModeValue('gray.100', '#333'); + + // 获取订阅的证券代码列表(带后缀,用于区分上证指数000001.SH和平安银行000001.SZ) + const subscribedCodes = useMemo(() => { + return watchlist.map(item => getFullCode(item.code, item.isIndex)); + }, [watchlist]); + + // WebSocket 实时行情 + const { quotes, connected } = useRealtimeQuote(subscribedCodes); + + // 从本地存储加载自选列表 + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved) as WatchlistItem[]; + if (Array.isArray(parsed) && parsed.length > 0) { + setWatchlist(parsed); + return; + } + } + } catch (e) { + logger.warn('FlexScreen', '加载自选列表失败', e); + } + // 使用默认列表 + setWatchlist(DEFAULT_WATCHLIST); + }, []); + + // 保存自选列表到本地存储 + useEffect(() => { + if (watchlist.length > 0) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(watchlist)); + } catch (e) { + logger.warn('FlexScreen', '保存自选列表失败', e); + } + } + }, [watchlist]); + + // 搜索证券 + const searchSecurities = useCallback(async (query: string): Promise => { + if (!query.trim()) { + setSearchResults([]); + setShowResults(false); + return; + } + + setIsSearching(true); + try { + const response = await fetch(`/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`); + const data: SearchApiResponse = await response.json(); + + if (data.success) { + setSearchResults(data.data || []); + setShowResults(true); + } else { + setSearchResults([]); + } + } catch (e) { + logger.error('FlexScreen', '搜索失败', e); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }, []); + + // 防抖搜索 + useEffect(() => { + const timer = setTimeout(() => { + searchSecurities(searchQuery); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, searchSecurities]); + + // 添加证券 + const addSecurity = useCallback( + (security: SearchResultItem | WatchlistItem): void => { + const code = 'stock_code' in security ? security.stock_code : security.code; + const name = 'stock_name' in security ? security.stock_name : security.name; + // 优先使用 API 返回的 isIndex 字段 + const isIndex = security.isIndex === true; + + // 生成唯一标识(带后缀的完整代码) + const fullCode = getFullCode(code, isIndex); + + // 检查是否已存在(使用带后缀的代码比较,避免上证指数和平安银行冲突) + if (watchlist.some(item => getFullCode(item.code, item.isIndex) === fullCode)) { + toast({ + title: '已在自选列表中', + status: 'info', + duration: 2000, + isClosable: true, + }); + return; + } + + // 添加到列表 + setWatchlist(prev => [...prev, { code, name, isIndex }]); + + toast({ + title: `已添加 ${name}${isIndex ? '(指数)' : ''}`, + status: 'success', + duration: 2000, + isClosable: true, + }); + + // 清空搜索 + setSearchQuery(''); + setShowResults(false); + }, + [watchlist, toast] + ); + + // 移除证券 + const removeSecurity = useCallback((code: string): void => { + setWatchlist(prev => prev.filter(item => item.code !== code)); + }, []); + + // 清空自选列表 + const clearWatchlist = useCallback((): void => { + setWatchlist([]); + localStorage.removeItem(STORAGE_KEY); + toast({ + title: '已清空自选列表', + status: 'info', + duration: 2000, + isClosable: true, + }); + }, [toast]); + + // 重置为默认列表 + const resetWatchlist = useCallback((): void => { + setWatchlist(DEFAULT_WATCHLIST); + toast({ + title: '已重置为默认列表', + status: 'success', + duration: 2000, + isClosable: true, + }); + }, [toast]); + + // 连接状态指示 + const isAnyConnected = connected.SSE || connected.SZSE; + const connectionStatus = useMemo((): ConnectionStatusInfo => { + if (connected.SSE && connected.SZSE) { + return { color: 'green', text: '上交所/深交所 已连接' }; + } + if (connected.SSE) { + return { color: 'yellow', text: '上交所 已连接' }; + } + if (connected.SZSE) { + return { color: 'yellow', text: '深交所 已连接' }; + } + return { color: 'red', text: '未连接' }; + }, [connected]); + + return ( + + + {/* 头部 */} + + + + + 灵活屏 + + + + + {isAnyConnected ? '实时' : '离线'} + + + + + + {/* 操作菜单 */} + + } + size="sm" + variant="ghost" + aria-label="设置" + /> + + } onClick={resetWatchlist}> + 重置为默认 + + } onClick={clearWatchlist} color="red.500"> + 清空列表 + + + + {/* 折叠按钮 */} + : } + size="sm" + variant="ghost" + onClick={() => setIsCollapsed(!isCollapsed)} + aria-label={isCollapsed ? '展开' : '收起'} + /> + + + + {/* 可折叠内容 */} + + {/* 搜索框 */} + + + + + + setSearchQuery(e.target.value)} + bg={searchBg} + borderRadius="lg" + _focus={{ + borderColor: 'purple.400', + boxShadow: '0 0 0 1px var(--chakra-colors-purple-400)', + }} + /> + {searchQuery && ( + + } + variant="ghost" + onClick={() => { + setSearchQuery(''); + setShowResults(false); + }} + aria-label="清空" + /> + + )} + + + {/* 搜索结果下拉 */} + + + {isSearching ? ( +
+ +
+ ) : searchResults.length > 0 ? ( + + {searchResults.map((stock, index) => ( + addSecurity(stock)} + borderBottomWidth={index < searchResults.length - 1 ? '1px' : '0'} + borderColor={borderColor} + > + + + + + {stock.stock_name} + + + {stock.isIndex ? '指数' : '股票'} + + + + {stock.stock_code} + + + } + size="xs" + colorScheme="purple" + variant="ghost" + aria-label="添加" + /> + + + ))} + + ) : ( +
+ + 未找到相关证券 + +
+ )} +
+
+
+ + {/* 快捷添加 */} + {watchlist.length === 0 && ( + + + 热门推荐(点击添加) + + + {HOT_RECOMMENDATIONS.map(item => ( + addSecurity(item)} + > + {item.name} + + ))} + + + )} + + {/* 自选列表 */} + {watchlist.length > 0 ? ( + + {watchlist.map(item => { + const fullCode = getFullCode(item.code, item.isIndex); + return ( + + ); + })} + + ) : ( +
+ + + 自选列表为空,请搜索添加证券 + +
+ )} +
+
+
+ ); +}; + +export default FlexScreen; diff --git a/src/views/StockOverview/components/FlexScreen/types.ts b/src/views/StockOverview/components/FlexScreen/types.ts new file mode 100644 index 00000000..796290f3 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/types.ts @@ -0,0 +1,322 @@ +/** + * 灵活屏组件类型定义 + */ + +// ==================== WebSocket 相关类型 ==================== + +/** 交易所标识 */ +export type Exchange = 'SSE' | 'SZSE'; + +/** WebSocket 连接状态 */ +export interface ConnectionStatus { + SSE: boolean; + SZSE: boolean; +} + +/** 盘口档位数据 */ +export interface OrderBookLevel { + price: number; + volume: number; +} + +// ==================== 行情数据类型 ==================== + +/** 盘后交易数据 */ +export interface AfterhoursData { + bidPx: number; + bidSize: number; + offerPx: number; + offerSize: number; + volume: number; + amount: number; + numTrades: number; +} + +/** 基础行情数据 */ +export interface BaseQuoteData { + code: string; + name: string; + price: number; + prevClose: number; + open: number; + high: number; + low: number; + volume: number; + amount: number; + change: number; + changePct: number; + updateTime?: string; + exchange: Exchange; +} + +/** 股票行情数据 */ +export interface StockQuoteData extends BaseQuoteData { + numTrades?: number; + upperLimit?: number; // 涨停价 + lowerLimit?: number; // 跌停价 + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; + afterhours?: AfterhoursData; // 盘后交易数据 +} + +/** 指数行情数据 */ +export interface IndexQuoteData extends BaseQuoteData { + close?: number; + numTrades?: number; + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; +} + +/** 债券行情数据 */ +export interface BondQuoteData extends BaseQuoteData { + numTrades?: number; + weightedAvgPx?: number; + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; + isBond: true; +} + +/** 港股行情数据 */ +export interface HKStockQuoteData extends BaseQuoteData { + numTrades?: number; + nominalPx?: number; // 按盘价 + referencePx?: number; // 参考价 + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; + isHK: true; +} + +/** 统一行情数据类型 */ +export type QuoteData = StockQuoteData | IndexQuoteData | BondQuoteData | HKStockQuoteData; + +/** 行情数据字典 */ +export interface QuotesMap { + [code: string]: QuoteData; +} + +// ==================== 上交所 WebSocket 消息类型 ==================== + +/** 上交所行情数据 */ +export interface SSEQuoteItem { + security_id: string; + security_name: string; + prev_close: number; + open_price: number; + high_price: number; + low_price: number; + last_price: number; + close_price: number; + volume: number; + amount: number; + bid_prices?: number[]; + bid_volumes?: number[]; + ask_prices?: number[]; + ask_volumes?: number[]; + trading_status?: string; + trade_time?: string; + update_time?: string; +} + +/** 上交所消息 */ +export interface SSEMessage { + type: 'stock' | 'index' | 'etf' | 'bond' | 'option' | 'subscribed' | 'pong' | 'error'; + timestamp?: string; + data?: Record; + channels?: string[]; + message?: string; +} + +// ==================== 深交所 WebSocket 消息类型 ==================== + +/** 深交所数据类别 */ +export type SZSECategory = + | 'stock' // 300111 股票快照 + | 'bond' // 300211 债券快照 + | 'afterhours_block' // 300611 盘后定价大宗交易 + | 'afterhours_trading' // 303711 盘后定价交易 + | 'hk_stock' // 306311 港股快照 + | 'index' // 309011 指数快照 + | 'volume_stats' // 309111 成交量统计 + | 'fund_nav'; // 309211 基金净值 + +/** 深交所股票行情数据 */ +export interface SZSEStockData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + last_px: number; + open_px: number; + high_px: number; + low_px: number; + prev_close: number; + volume: number; + amount: number; + num_trades?: number; + upper_limit?: number; + lower_limit?: number; + bids?: OrderBookLevel[]; + asks?: OrderBookLevel[]; +} + +/** 深交所指数行情数据 */ +export interface SZSEIndexData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + current_index: number; + open_index: number; + high_index: number; + low_index: number; + close_index?: number; + prev_close: number; + volume: number; + amount: number; + num_trades?: number; +} + +/** 深交所债券行情数据 */ +export interface SZSEBondData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + last_px: number; + open_px: number; + high_px: number; + low_px: number; + prev_close: number; + weighted_avg_px?: number; + volume: number; + amount: number; + num_trades?: number; + auction_volume?: number; + auction_amount?: number; +} + +/** 深交所港股行情数据 */ +export interface SZSEHKStockData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + last_px: number; + open_px: number; + high_px: number; + low_px: number; + prev_close: number; + nominal_px?: number; + reference_px?: number; + volume: number; + amount: number; + num_trades?: number; + vcm_start_time?: number; + vcm_end_time?: number; + bids?: OrderBookLevel[]; + asks?: OrderBookLevel[]; +} + +/** 深交所盘后交易数据 */ +export interface SZSEAfterhoursData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + prev_close: number; + bid_px: number; + bid_size: number; + offer_px: number; + offer_size: number; + volume: number; + amount: number; + num_trades?: number; +} + +/** 深交所实时消息 */ +export interface SZSERealtimeMessage { + type: 'realtime'; + category: SZSECategory; + msg_type?: number; + timestamp: string; + data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData; +} + +/** 深交所快照消息 */ +export interface SZSESnapshotMessage { + type: 'snapshot'; + timestamp: string; + data: { + stocks?: SZSEStockData[]; + indexes?: SZSEIndexData[]; + bonds?: SZSEBondData[]; + }; +} + +/** 深交所消息类型 */ +export type SZSEMessage = SZSERealtimeMessage | SZSESnapshotMessage | { type: 'pong' }; + +// ==================== 组件 Props 类型 ==================== + +/** 自选证券项 */ +export interface WatchlistItem { + code: string; + name: string; + isIndex: boolean; +} + +/** QuoteTile 组件 Props */ +export interface QuoteTileProps { + code: string; + name: string; + quote: Partial; + isIndex?: boolean; + onRemove?: (code: string) => void; +} + +/** OrderBookPanel 组件 Props */ +export interface OrderBookPanelProps { + bidPrices?: number[]; + bidVolumes?: number[]; + askPrices?: number[]; + askVolumes?: number[]; + prevClose?: number; + upperLimit?: number; + lowerLimit?: number; + defaultLevels?: number; +} + +/** MiniTimelineChart 组件 Props */ +export interface MiniTimelineChartProps { + code: string; + isIndex?: boolean; + prevClose?: number; + currentPrice?: number; + height?: number; +} + +/** 分时数据点 */ +export interface TimelineDataPoint { + time: string; + price: number; +} + +/** useRealtimeQuote Hook 返回值 */ +export interface UseRealtimeQuoteReturn { + quotes: QuotesMap; + connected: ConnectionStatus; + subscribe: (code: string) => void; + unsubscribe: (code: string) => void; +} diff --git a/src/views/StockOverview/components/HotspotOverview/components/AlertSummary.js b/src/views/StockOverview/components/HotspotOverview/components/AlertSummary.js new file mode 100644 index 00000000..37a871c6 --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/components/AlertSummary.js @@ -0,0 +1,147 @@ +/** + * 异动统计摘要组件 + * 展示指数统计和异动类型统计 + */ +import React from 'react'; +import { + Box, + HStack, + VStack, + Text, + Badge, + Icon, + Stat, + StatLabel, + StatNumber, + StatHelpText, + StatArrow, + SimpleGrid, + useColorModeValue, +} from '@chakra-ui/react'; +import { FaBolt, FaArrowDown, FaRocket, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa'; + +/** + * 异动类型徽章 + */ +const AlertTypeBadge = ({ type, count }) => { + const config = { + surge: { label: '急涨', color: 'red', icon: FaBolt }, + surge_up: { label: '暴涨', color: 'red', icon: FaBolt }, + surge_down: { label: '暴跌', color: 'green', icon: FaArrowDown }, + limit_up: { label: '涨停', color: 'orange', icon: FaRocket }, + rank_jump: { label: '排名跃升', color: 'blue', icon: FaChartLine }, + volume_spike: { label: '放量', color: 'purple', icon: FaVolumeUp }, + }; + + const cfg = config[type] || { label: type, color: 'gray', icon: FaFire }; + + return ( + + + + {cfg.label} + {count} + + + ); +}; + +/** + * 指数统计卡片 + */ +const IndexStatCard = ({ indexData }) => { + const cardBg = useColorModeValue('white', '#1a1a1a'); + const borderColor = useColorModeValue('gray.200', '#333'); + const subTextColor = useColorModeValue('gray.600', 'gray.400'); + + if (!indexData) return null; + + const changePct = indexData.change_pct || 0; + const isUp = changePct >= 0; + + return ( + + + {indexData.name || '上证指数'} + + {indexData.latest_price?.toFixed(2) || '-'} + + + + {changePct?.toFixed(2)}% + + + + + 最高 + + {indexData.high?.toFixed(2) || '-'} + + + + + 最低 + + {indexData.low?.toFixed(2) || '-'} + + + + + 振幅 + + {indexData.high && indexData.low && indexData.prev_close + ? (((indexData.high - indexData.low) / indexData.prev_close) * 100).toFixed(2) + '%' + : '-'} + + + + ); +}; + +/** + * 异动统计摘要 + * @param {Object} props + * @param {Object} props.indexData - 指数数据 + * @param {Array} props.alerts - 异动数组 + * @param {Object} props.alertSummary - 异动类型统计 + */ +const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => { + const cardBg = useColorModeValue('white', '#1a1a1a'); + const borderColor = useColorModeValue('gray.200', '#333'); + + // 如果没有 alertSummary,从 alerts 中统计 + const summary = alertSummary && Object.keys(alertSummary).length > 0 + ? alertSummary + : alerts.reduce((acc, alert) => { + const type = alert.alert_type || 'unknown'; + acc[type] = (acc[type] || 0) + 1; + return acc; + }, {}); + + const totalAlerts = alerts.length; + + return ( + + {/* 指数统计 */} + + + {/* 异动统计 */} + {totalAlerts > 0 && ( + + + 异动 {totalAlerts} 次: + + {(summary.surge_up > 0 || summary.surge > 0) && ( + + )} + {summary.surge_down > 0 && } + {summary.limit_up > 0 && } + {summary.volume_spike > 0 && } + {summary.rank_jump > 0 && } + + )} + + ); +}; + +export default AlertSummary; diff --git a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js new file mode 100644 index 00000000..c1613aba --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js @@ -0,0 +1,356 @@ +/** + * 概念异动列表组件 - V2 + * 展示当日的概念异动记录,点击可展开显示相关股票 + */ +import React, { useState, useCallback } from 'react'; +import { + Box, + VStack, + HStack, + Text, + Badge, + Icon, + Tooltip, + useColorModeValue, + Flex, + Collapse, + Spinner, + Progress, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, +} from '@chakra-ui/react'; +import { FaArrowUp, FaArrowDown, FaFire, FaChevronDown, FaChevronRight } from 'react-icons/fa'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers'; + +/** + * 紧凑型异动卡片 + */ +const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { + const navigate = useNavigate(); + const bgColor = useColorModeValue('white', '#1a1a1a'); + const hoverBg = useColorModeValue('gray.50', '#252525'); + const borderColor = useColorModeValue('gray.200', '#333'); + const expandedBg = useColorModeValue('purple.50', '#1e1e2e'); + + const isUp = alert.alert_type !== 'surge_down'; + const typeColor = isUp ? 'red' : 'green'; + const isV2 = alert.is_v2; + + // 点击股票跳转 + const handleStockClick = (e, stockCode) => { + e.stopPropagation(); + navigate(`/company?scode=${stockCode}`); + }; + + return ( + + {/* 主卡片 - 点击展开 */} + + + {/* 左侧:名称 + 类型 */} + + + + + {alert.concept_name} + + {isV2 && ( + + V2 + + )} + + + {/* 右侧:分数 */} + + {formatScore(alert.final_score)}分 + + + + {/* 第二行:时间 + 关键指标 */} + + + {alert.time} + + {getAlertTypeLabel(alert.alert_type)} + + {/* 确认率 */} + {isV2 && alert.confirm_ratio != null && ( + + + = 0.8 ? 'green.500' : 'orange.500'} + /> + + {Math.round((alert.confirm_ratio || 0) * 100)}% + + )} + + + {/* Alpha + Z-Score 简化显示 */} + + {alert.alpha != null && ( + = 0 ? 'red.500' : 'green.500'} fontWeight="medium"> + α {(alert.alpha || 0) >= 0 ? '+' : ''}{(alert.alpha || 0).toFixed(2)}% + + )} + {isV2 && alert.alpha_zscore != null && ( + + + + = 0 ? '50%' : undefined} + right={(alert.alpha_zscore || 0) < 0 ? '50%' : undefined} + w={`${Math.min(Math.abs(alert.alpha_zscore || 0) / 5 * 50, 50)}%`} + h="100%" + bg={(alert.alpha_zscore || 0) >= 0 ? 'red.500' : 'green.500'} + /> + + = 0 ? 'red.400' : 'green.400'}> + {(alert.alpha_zscore || 0) >= 0 ? '+' : ''}{(alert.alpha_zscore || 0).toFixed(1)}σ + + + + )} + {(alert.limit_up_ratio || 0) > 0.05 && ( + + + {Math.round((alert.limit_up_ratio || 0) * 100)}% + + )} + + + + + {/* 展开的股票列表 */} + + + {loadingStocks ? ( + + + 加载相关股票... + + ) : stocks && stocks.length > 0 ? ( + + + + + + + + + + + {stocks.slice(0, 10).map((stock, idx) => { + const changePct = stock.change_pct; + const hasChange = changePct != null && !isNaN(changePct); + return ( + handleStockClick(e, stock.code || stock.stock_code)} + > + + + + + ); + })} + +
股票涨跌原因
+ + {stock.name || stock.stock_name || '-'} + + + 0 ? 'red.400' : + hasChange && changePct < 0 ? 'green.400' : 'gray.400' + } + > + {hasChange + ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` + : '-' + } + + + + {stock.reason || '-'} + +
+ {stocks.length > 10 && ( + + 共 {stocks.length} 只相关股票,显示前 10 只 + + )} +
+ ) : ( + + 暂无相关股票数据 + + )} +
+
+
+ ); +}; + +/** + * 概念异动列表 + */ +const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight = '400px' }) => { + const [expandedId, setExpandedId] = useState(null); + const [conceptStocks, setConceptStocks] = useState({}); + const [loadingConcepts, setLoadingConcepts] = useState({}); + + const subTextColor = useColorModeValue('gray.500', 'gray.400'); + + // 获取概念相关股票 + const fetchConceptStocks = useCallback(async (conceptId) => { + if (conceptStocks[conceptId] || loadingConcepts[conceptId]) { + return; + } + + setLoadingConcepts(prev => ({ ...prev, [conceptId]: true })); + + try { + // 调用后端 API 获取概念股票 + const response = await axios.get(`/api/concept/${conceptId}/stocks`); + if (response.data?.success && response.data?.data?.stocks) { + setConceptStocks(prev => ({ + ...prev, + [conceptId]: response.data.data.stocks + })); + } + } catch (error) { + console.error('获取概念股票失败:', error); + // 如果 API 失败,尝试从 ES 直接获取 + try { + const esResponse = await axios.get(`/api/es/concept/${conceptId}`); + if (esResponse.data?.stocks) { + setConceptStocks(prev => ({ + ...prev, + [conceptId]: esResponse.data.stocks + })); + } + } catch (esError) { + console.error('ES 获取也失败:', esError); + setConceptStocks(prev => ({ ...prev, [conceptId]: [] })); + } + } finally { + setLoadingConcepts(prev => ({ ...prev, [conceptId]: false })); + } + }, [conceptStocks, loadingConcepts]); + + // 切换展开状态 + const handleToggle = useCallback((alert) => { + const alertKey = `${alert.concept_id}-${alert.time}`; + + if (expandedId === alertKey) { + setExpandedId(null); + } else { + setExpandedId(alertKey); + // 获取股票数据 + if (alert.concept_id) { + fetchConceptStocks(alert.concept_id); + } + } + + // 通知父组件 + onAlertClick?.(alert); + }, [expandedId, fetchConceptStocks, onAlertClick]); + + if (!alerts || alerts.length === 0) { + return ( + + + 当日暂无概念异动 + + + ); + } + + // 按时间倒序排列 + const sortedAlerts = [...alerts].sort((a, b) => { + const timeA = a.time || '00:00'; + const timeB = b.time || '00:00'; + return timeB.localeCompare(timeA); + }); + + return ( + + + {sortedAlerts.map((alert, idx) => { + const alertKey = `${alert.concept_id}-${alert.time}`; + return ( + handleToggle(alert)} + stocks={conceptStocks[alert.concept_id]} + loadingStocks={loadingConcepts[alert.concept_id]} + /> + ); + })} + + + ); +}; + +export default ConceptAlertList; diff --git a/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js b/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js new file mode 100644 index 00000000..a7c360a5 --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js @@ -0,0 +1,264 @@ +/** + * 指数分时图组件 + * 展示大盘分时走势,支持概念异动标注 + */ +import React, { useRef, useEffect, useCallback, useMemo } from 'react'; +import { Box, useColorModeValue } from '@chakra-ui/react'; +import * as echarts from 'echarts'; +import { getAlertMarkPoints } from '../utils/chartHelpers'; + +/** + * @param {Object} props + * @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... } + * @param {Array} props.alerts - 异动数据数组 + * @param {Function} props.onAlertClick - 点击异动标注的回调 + * @param {string} props.height - 图表高度 + */ +const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + const textColor = useColorModeValue('gray.800', 'white'); + const subTextColor = useColorModeValue('gray.600', 'gray.400'); + const gridLineColor = useColorModeValue('#eee', '#333'); + + // 计算图表配置 + const chartOption = useMemo(() => { + if (!indexData || !indexData.timeline || indexData.timeline.length === 0) { + return null; + } + + const timeline = indexData.timeline || []; + const times = timeline.map((d) => d.time); + const prices = timeline.map((d) => d.price); + const volumes = timeline.map((d) => d.volume); + const changePcts = timeline.map((d) => d.change_pct); + + // 计算Y轴范围 + const validPrices = prices.filter(Boolean); + if (validPrices.length === 0) return null; + + const priceMin = Math.min(...validPrices); + const priceMax = Math.max(...validPrices); + const priceRange = priceMax - priceMin; + const yAxisMin = priceMin - priceRange * 0.1; + const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注 + + // 准备异动标注 + const markPoints = getAlertMarkPoints(alerts, times, prices, priceMax); + + // 渐变色 - 根据涨跌 + const latestChangePct = changePcts[changePcts.length - 1] || 0; + const isUp = latestChangePct >= 0; + const lineColor = isUp ? '#ff4d4d' : '#22c55e'; + const areaColorStops = isUp + ? [ + { offset: 0, color: 'rgba(255, 77, 77, 0.4)' }, + { offset: 1, color: 'rgba(255, 77, 77, 0.05)' }, + ] + : [ + { offset: 0, color: 'rgba(34, 197, 94, 0.4)' }, + { offset: 1, color: 'rgba(34, 197, 94, 0.05)' }, + ]; + + return { + backgroundColor: 'transparent', + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + crossStyle: { color: '#999' }, + }, + formatter: (params) => { + if (!params || params.length === 0) return ''; + + const dataIndex = params[0].dataIndex; + const time = times[dataIndex]; + const price = prices[dataIndex]; + const changePct = changePcts[dataIndex]; + const volume = volumes[dataIndex]; + + let html = ` +
+
${time}
+
指数: ${price?.toFixed(2)}
+
涨跌: ${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%
+
成交量: ${(volume / 10000).toFixed(0)}万手
+
+ `; + + // 检查是否有异动 + const alertsAtTime = alerts.filter((a) => a.time === time); + if (alertsAtTime.length > 0) { + html += '
'; + html += '
概念异动:
'; + alertsAtTime.forEach((alert) => { + const typeLabel = { + surge: '急涨', + surge_up: '暴涨', + surge_down: '暴跌', + limit_up: '涨停增加', + rank_jump: '排名跃升', + volume_spike: '放量', + }[alert.alert_type] || alert.alert_type; + const typeColor = alert.alert_type === 'surge_down' ? '#2ed573' : '#ff6b6b'; + const alpha = alert.alpha ? ` (α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(2)}%)` : ''; + html += `
• ${alert.concept_name} (${typeLabel}${alpha})
`; + }); + html += '
'; + } + + return html; + }, + }, + legend: { show: false }, + grid: [ + { left: '8%', right: '3%', top: '8%', height: '58%' }, + { left: '8%', right: '3%', top: '72%', height: '18%' }, + ], + xAxis: [ + { + type: 'category', + data: times, + axisLine: { lineStyle: { color: gridLineColor } }, + axisLabel: { + color: subTextColor, + fontSize: 10, + interval: Math.floor(times.length / 6), + }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + { + type: 'category', + gridIndex: 1, + data: times, + axisLine: { lineStyle: { color: gridLineColor } }, + axisLabel: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + ], + yAxis: [ + { + type: 'value', + min: yAxisMin, + max: yAxisMax, + axisLine: { show: false }, + axisLabel: { + color: subTextColor, + fontSize: 10, + formatter: (val) => val.toFixed(0), + }, + splitLine: { lineStyle: { color: gridLineColor, type: 'dashed' } }, + axisPointer: { + label: { + formatter: (params) => { + if (!indexData.prev_close) return params.value.toFixed(2); + const pct = ((params.value - indexData.prev_close) / indexData.prev_close) * 100; + return `${params.value.toFixed(2)} (${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%)`; + }, + }, + }, + }, + { + type: 'value', + gridIndex: 1, + axisLine: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, + }, + ], + series: [ + // 分时线 + { + name: indexData.name || '上证指数', + type: 'line', + data: prices, + smooth: true, + symbol: 'none', + lineStyle: { color: lineColor, width: 1.5 }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, areaColorStops), + }, + markPoint: { + symbol: 'pin', + symbolSize: 40, + data: markPoints, + animation: true, + }, + }, + // 成交量 + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes.map((v, i) => ({ + value: v, + itemStyle: { + color: changePcts[i] >= 0 ? 'rgba(255, 77, 77, 0.6)' : 'rgba(34, 197, 94, 0.6)', + }, + })), + barWidth: '60%', + }, + ], + }; + }, [indexData, alerts, subTextColor, gridLineColor]); + + // 渲染图表 + const renderChart = useCallback(() => { + if (!chartRef.current || !chartOption) return; + + if (!chartInstance.current) { + chartInstance.current = echarts.init(chartRef.current); + } + + chartInstance.current.setOption(chartOption, true); + + // 点击事件 + if (onAlertClick) { + chartInstance.current.off('click'); + chartInstance.current.on('click', 'series.line.markPoint', (params) => { + if (params.data && params.data.alertData) { + onAlertClick(params.data.alertData); + } + }); + } + }, [chartOption, onAlertClick]); + + // 数据变化时重新渲染 + useEffect(() => { + renderChart(); + }, [renderChart]); + + // 窗口大小变化时重新渲染 + useEffect(() => { + const handleResize = () => { + if (chartInstance.current) { + chartInstance.current.resize(); + } + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + if (chartInstance.current) { + chartInstance.current.dispose(); + chartInstance.current = null; + } + }; + }, []); + + if (!chartOption) { + return ( + + 暂无数据 + + ); + } + + return ; +}; + +export default IndexMinuteChart; diff --git a/src/views/StockOverview/components/HotspotOverview/components/index.js b/src/views/StockOverview/components/HotspotOverview/components/index.js new file mode 100644 index 00000000..2401b9bd --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/components/index.js @@ -0,0 +1,3 @@ +export { default as IndexMinuteChart } from './IndexMinuteChart'; +export { default as ConceptAlertList } from './ConceptAlertList'; +export { default as AlertSummary } from './AlertSummary'; diff --git a/src/views/StockOverview/components/HotspotOverview/hooks/index.js b/src/views/StockOverview/components/HotspotOverview/hooks/index.js new file mode 100644 index 00000000..43a31148 --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/hooks/index.js @@ -0,0 +1 @@ +export { useHotspotData } from './useHotspotData'; diff --git a/src/views/StockOverview/components/HotspotOverview/hooks/useHotspotData.js b/src/views/StockOverview/components/HotspotOverview/hooks/useHotspotData.js new file mode 100644 index 00000000..2fbca02a --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/hooks/useHotspotData.js @@ -0,0 +1,53 @@ +/** + * 热点概览数据获取 Hook + * 负责获取指数分时数据和概念异动数据 + */ +import { useState, useEffect, useCallback } from 'react'; +import { logger } from '@utils/logger'; + +/** + * @param {Date|null} selectedDate - 选中的交易日期 + * @returns {Object} 数据和状态 + */ +export const useHotspotData = (selectedDate) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const dateParam = selectedDate + ? `?date=${selectedDate.toISOString().split('T')[0]}` + : ''; + const response = await fetch(`/api/market/hotspot-overview${dateParam}`); + const result = await response.json(); + + if (result.success) { + setData(result.data); + } else { + setError(result.error || '获取数据失败'); + } + } catch (err) { + logger.error('useHotspotData', 'fetchData', err); + setError('网络请求失败'); + } finally { + setLoading(false); + } + }, [selectedDate]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { + loading, + error, + data, + refetch: fetchData, + }; +}; + +export default useHotspotData; diff --git a/src/views/StockOverview/components/HotspotOverview/index.js b/src/views/StockOverview/components/HotspotOverview/index.js new file mode 100644 index 00000000..ccef8cbb --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/index.js @@ -0,0 +1,198 @@ +/** + * 热点概览组件 + * 展示大盘分时走势 + 概念异动标注 + * + * 模块化结构: + * - hooks/useHotspotData.js - 数据获取 + * - components/IndexMinuteChart.js - 分时图 + * - components/ConceptAlertList.js - 异动列表 + * - components/AlertSummary.js - 统计摘要 + * - utils/chartHelpers.js - 图表辅助函数 + */ +import React, { useState, useCallback } from 'react'; +import { + Box, + Card, + CardBody, + Heading, + Text, + HStack, + VStack, + Spinner, + Center, + Icon, + Flex, + Spacer, + Tooltip, + useColorModeValue, + Grid, + GridItem, + Divider, + IconButton, + Collapse, +} from '@chakra-ui/react'; +import { FaFire, FaList, FaChartArea, FaChevronDown, FaChevronUp } from 'react-icons/fa'; +import { InfoIcon } from '@chakra-ui/icons'; + +import { useHotspotData } from './hooks'; +import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components'; + +/** + * 热点概览主组件 + * @param {Object} props + * @param {Date|null} props.selectedDate - 选中的交易日期 + */ +const HotspotOverview = ({ selectedDate }) => { + const [selectedAlert, setSelectedAlert] = useState(null); + const [showAlertList, setShowAlertList] = useState(true); + + // 获取数据 + const { loading, error, data } = useHotspotData(selectedDate); + + // 颜色主题 + const cardBg = useColorModeValue('white', '#1a1a1a'); + const borderColor = useColorModeValue('gray.200', '#333333'); + const textColor = useColorModeValue('gray.800', 'white'); + const subTextColor = useColorModeValue('gray.600', 'gray.400'); + + // 点击异动标注 + const handleAlertClick = useCallback((alert) => { + setSelectedAlert(alert); + // 可以在这里添加滚动到对应位置的逻辑 + }, []); + + // 渲染加载状态 + if (loading) { + return ( + + +
+ + + 加载热点概览数据... + +
+
+
+ ); + } + + // 渲染错误状态 + if (error) { + return ( + + +
+ + + {error} + +
+
+
+ ); + } + + // 无数据 + if (!data) { + return null; + } + + const { index, alerts, alert_summary } = data; + + return ( + + + {/* 头部 */} + + + + + 热点概览 + + + + + + : } + size="sm" + variant="ghost" + onClick={() => setShowAlertList(!showAlertList)} + aria-label="切换异动列表" + /> + + + + + + + + {/* 统计摘要 */} + + + + + + + {/* 主体内容:图表 + 异动列表 */} + + {/* 分时图 */} + + + + + + 大盘分时走势 + + + + + + + {/* 异动列表(可收起) */} + + + + + + + 异动记录 + + + ({alerts.length}) + + + + + + + + + {/* 无异动提示 */} + {alerts.length === 0 && ( +
+ + 当日暂无概念异动数据 + +
+ )} +
+
+ ); +}; + +export default HotspotOverview; diff --git a/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js new file mode 100644 index 00000000..54ff9eba --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js @@ -0,0 +1,160 @@ +/** + * 图表辅助函数 + * 用于处理异动标注等图表相关逻辑 + */ + +/** + * 获取异动标注的配色和符号 + * @param {string} alertType - 异动类型 + * @param {number} importanceScore - 重要性得分 + * @returns {Object} { color, symbol, symbolSize } + */ +export const getAlertStyle = (alertType, importanceScore = 0.5) => { + let color = '#ff6b6b'; + let symbol = 'pin'; + let symbolSize = 35; + + switch (alertType) { + case 'surge_up': + case 'surge': + color = '#ff4757'; + symbol = 'triangle'; + symbolSize = 30 + Math.min(importanceScore * 20, 15); + break; + case 'surge_down': + color = '#2ed573'; + symbol = 'path://M0,0 L10,0 L5,10 Z'; // 向下三角形 + symbolSize = 30 + Math.min(importanceScore * 20, 15); + break; + case 'limit_up': + color = '#ff6348'; + symbol = 'diamond'; + symbolSize = 28; + break; + case 'rank_jump': + color = '#3742fa'; + symbol = 'circle'; + symbolSize = 25; + break; + case 'volume_spike': + color = '#ffa502'; + symbol = 'rect'; + symbolSize = 25; + break; + default: + break; + } + + return { color, symbol, symbolSize }; +}; + +/** + * 获取异动类型的显示标签 + * @param {string} alertType - 异动类型 + * @returns {string} 显示标签 + */ +export const getAlertTypeLabel = (alertType) => { + const labels = { + surge: '急涨', + surge_up: '暴涨', + surge_down: '暴跌', + limit_up: '涨停增加', + rank_jump: '排名跃升', + volume_spike: '放量', + unknown: '异动', + }; + return labels[alertType] || alertType; +}; + +/** + * 生成图表标注点数据 + * @param {Array} alerts - 异动数据数组 + * @param {Array} times - 时间数组 + * @param {Array} prices - 价格数组 + * @param {number} priceMax - 最高价格(用于无法匹配时间时的默认位置) + * @param {number} maxCount - 最大显示数量 + * @returns {Array} ECharts markPoint data + */ +export const getAlertMarkPoints = (alerts, times, prices, priceMax, maxCount = 15) => { + if (!alerts || alerts.length === 0) return []; + + // 按重要性排序,限制显示数量 + const sortedAlerts = [...alerts] + .sort((a, b) => (b.final_score || b.importance_score || 0) - (a.final_score || a.importance_score || 0)) + .slice(0, maxCount); + + return sortedAlerts.map((alert) => { + // 找到对应时间的价格 + const timeIndex = times.indexOf(alert.time); + const price = timeIndex >= 0 ? prices[timeIndex] : (alert.index_price || priceMax); + + const { color, symbol, symbolSize } = getAlertStyle( + alert.alert_type, + alert.final_score / 100 || alert.importance_score || 0.5 + ); + + // 格式化标签 + let label = alert.concept_name || ''; + if (label.length > 6) { + label = label.substring(0, 5) + '...'; + } + + // 添加涨停数量(如果有) + if (alert.limit_up_count > 0) { + label += `\n涨停: ${alert.limit_up_count}`; + } + + const isDown = alert.alert_type === 'surge_down'; + + return { + name: alert.concept_name, + coord: [alert.time, price], + value: label, + symbol, + symbolSize, + itemStyle: { + color, + borderColor: '#fff', + borderWidth: 1, + shadowBlur: 3, + shadowColor: 'rgba(0,0,0,0.2)', + }, + label: { + show: true, + position: isDown ? 'bottom' : 'top', + formatter: '{b}', + fontSize: 9, + color: '#333', + backgroundColor: isDown ? 'rgba(46, 213, 115, 0.9)' : 'rgba(255,255,255,0.9)', + padding: [2, 4], + borderRadius: 2, + borderColor: color, + borderWidth: 1, + }, + alertData: alert, // 存储原始数据 + }; + }); +}; + +/** + * 格式化分数显示 + * @param {number} score - 分数 + * @returns {string} 格式化后的分数 + */ +export const formatScore = (score) => { + if (score === null || score === undefined) return '-'; + return Math.round(score).toString(); +}; + +/** + * 获取分数对应的颜色 + * @param {number} score - 分数 (0-100) + * @returns {string} 颜色代码 + */ +export const getScoreColor = (score) => { + const s = score || 0; + if (s >= 80) return '#ff4757'; + if (s >= 60) return '#ff6348'; + if (s >= 40) return '#ffa502'; + return '#747d8c'; +}; diff --git a/src/views/StockOverview/components/HotspotOverview/utils/index.js b/src/views/StockOverview/components/HotspotOverview/utils/index.js new file mode 100644 index 00000000..133f3c2d --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/utils/index.js @@ -0,0 +1 @@ +export * from './chartHelpers'; diff --git a/src/views/StockOverview/index.js b/src/views/StockOverview/index.js index 6a6417e7..dacae01b 100644 --- a/src/views/StockOverview/index.js +++ b/src/views/StockOverview/index.js @@ -50,9 +50,11 @@ import { SkeletonText, } from '@chakra-ui/react'; import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons'; -import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa'; +import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar, FaTag, FaLayerGroup, FaBolt } from 'react-icons/fa'; import ConceptStocksModal from '@components/ConceptStocksModal'; import TradeDatePicker from '@components/TradeDatePicker'; +import HotspotOverview from './components/HotspotOverview'; +import FlexScreen from './components/FlexScreen'; import { BsGraphUp, BsLightningFill } from 'react-icons/bs'; import * as echarts from 'echarts'; import { logger } from '../../utils/logger'; @@ -840,6 +842,16 @@ const StockOverview = () => { )}
+ {/* 热点概览 - 大盘走势 + 概念异动 */} + + + + + {/* 灵活屏 - 实时行情监控 */} + + + + {/* 今日热门概念 */} @@ -927,16 +939,65 @@ const StockOverview = () => { + {/* 概念名称 */} {concept.concept_name} + {/* 层级信息 */} + {concept.hierarchy && ( + + + + {[concept.hierarchy.lv1, concept.hierarchy.lv2, concept.hierarchy.lv3] + .filter(Boolean) + .join(' > ')} + + + )} + + {/* 描述 */} {concept.description || '暂无描述'} + {/* 标签 */} + {concept.tags && concept.tags.length > 0 && ( + + {concept.tags.slice(0, 4).map((tag, idx) => ( + + + {tag} + + ))} + {concept.tags.length > 4 && ( + + +{concept.tags.length - 4} + + )} + + )} + + {/* 爆发日期 */} + {concept.outbreak_dates && concept.outbreak_dates.length > 0 && ( + + + + 近期爆发: {concept.outbreak_dates.slice(0, 2).join(', ')} + {concept.outbreak_dates.length > 2 && ` 等${concept.outbreak_dates.length}次`} + + + )} + + {/* 相关股票 */} { overflow="hidden" maxH="24px" > - {concept.stocks.map((stock, idx) => ( + {concept.stocks.slice(0, 5).map((stock, idx) => ( { variant="subtle" flexShrink={0} > - {stock.stock_name} + {stock.stock_name || stock.name} ))} + {concept.stocks.length > 5 && ( + + +{concept.stocks.length - 5} + + )} )} diff --git a/sse_html.html b/sse_html.html new file mode 100644 index 00000000..8723bda7 --- /dev/null +++ b/sse_html.html @@ -0,0 +1,378 @@ + + + + + + VDE 实时行情 - WebSocket 测试 + + + +
+

VDE 实时行情

+ +
+
+ 未连接 +
+
更新次数: 0
+
最后更新: -
+
+
+ + + +
+
+ +
+ +
+

📊 指数行情

+ + + + + + + + + + + +
代码/名称最新涨跌涨跌幅成交额(亿)
+
+ + +
+

📈 股票行情 (前20)

+ + + + + + + + + + + + +
代码/名称最新涨跌幅买一卖一成交额(万)
+
+
+ + +
+
+ + + + diff --git a/szse_html.html b/szse_html.html new file mode 100644 index 00000000..baf8e8f0 --- /dev/null +++ b/szse_html.html @@ -0,0 +1,289 @@ + + + + + + 深交所行情 WebSocket 测试 + + + +
+

深交所行情 WebSocket 测试

+ +
+ + + + + + 未连接 +
+ +
+
+
0
+
消息总数
+
+
+
0
+
股票 (300111)
+
+
+
0
+
指数 (309011)
+
+
+
0
+
债券 (300211)
+
+
+
0
+
港股 (306311)
+
+
+
0
+
其他类型
+
+
+ +
+
+
+ 实时行情 + -- +
+
+ + + + + + + + + + + +
代码类型最新价涨跌幅成交量
+
+
+
+
+ 消息日志 + 0 条 +
+
+
+
+
+ + + + diff --git a/valuefrontier.conf b/valuefrontier.conf index 9f4e2992..76fac284 100644 --- a/valuefrontier.conf +++ b/valuefrontier.conf @@ -112,6 +112,42 @@ server { proxy_buffering off; } + # ============================================ + # 实时行情 WebSocket 代理(灵活屏功能) + # ============================================ + + # 上交所实时行情 WebSocket + location /ws/sse { + proxy_pass http://49.232.185.254:8765; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + proxy_buffering off; + } + + # 深交所实时行情 WebSocket + location /ws/szse { + proxy_pass http://222.128.1.157:8765; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + proxy_buffering off; + } + location /mcp/ { proxy_pass http://127.0.0.1:8900/; proxy_http_version 1.1; @@ -142,7 +178,6 @@ server { } } - # 概念板块API代理 location /concept-api/ { proxy_pass http://222.128.1.157:16801/; @@ -158,6 +193,7 @@ server { proxy_send_timeout 60s; proxy_read_timeout 60s; } + # Elasticsearch API代理(价值论坛) location /es-api/ { proxy_pass http://222.128.1.157:19200/; @@ -223,36 +259,7 @@ server { proxy_send_timeout 86400s; proxy_read_timeout 86400s; } - # AI Chat 应用 (Next.js) - MCP 集成 - # AI Chat 静态资源(图片、CSS、JS) - location ~ ^/ai-chat/(images|_next/static|_next/image|favicon.ico) { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - # 缓存设置 - expires 30d; - add_header Cache-Control "public, immutable"; - } - - # AI Chat 主应用 - location /ai-chat { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Cookie $http_cookie; - proxy_pass_request_headers on; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_buffering off; - proxy_cache off; - } # iframe 内部资源代理(Bytedesk 聊天窗口的 CSS/JS) location /chat/ { proxy_pass http://43.143.189.195/chat/; @@ -326,6 +333,22 @@ server { add_header Cache-Control "public, max-age=86400"; } + # Bytedesk 文件访问代理(仅 2025 年文件) + location ^~ /file/2025/ { + proxy_pass http://43.143.189.195/file/2025/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 缓存配置 + proxy_cache_valid 200 1d; + expires 1d; + add_header Cache-Control "public, max-age=86400"; + add_header Access-Control-Allow-Origin *; + } + # Visitor API 代理(Bytedesk 初始化接口) location /visitor/ {