""" 新闻/公告/政策分析模块(P5/P6) 功能: - P5: 上市公司公告采集 + LLM情感分析 + 异动监测 - P6: 政策面新闻监控 + LLM政策分析 数据源: - AKShare 公告数据 (stock_notice_report) - 豆包LLM 做分类和情感分析 """ import logging from datetime import datetime, timedelta logger = logging.getLogger(__name__) # 当日缓存 _news_cache = {} _news_cache_date = {} def _get_cache(key): today = datetime.now().strftime('%Y-%m-%d') if _news_cache_date.get(key) == today: return _news_cache.get(key) return None def _set_cache(key, value): today = datetime.now().strftime('%Y-%m-%d') _news_cache[key] = value _news_cache_date[key] = today # ═══════════════════════════════════════════════ # P5: 公告/并购消息分析 # ═══════════════════════════════════════════════ # 公告类型关键词映射 ANNOUNCEMENT_KEYWORDS = { '并购重组': ['收购', '合并', '重组', '并购', '吸收合并'], '增减持': ['增持', '减持', '股份变动', '股东减持', '股东增持'], '业绩预告': ['业绩预告', '业绩快报', '盈利预测', '预增', '预减', '预亏', '扭亏'], '股权激励': ['股权激励', '限制性股票', '股票期权'], '定增再融资': ['定增', '非公开发行', '配股', '可转债', '再融资'], '分红送转': ['分红', '送转', '派息', '转增', '利润分配'], '重大合同': ['重大合同', '中标', '框架协议', '战略合作'], '停复牌': ['停牌', '复牌', '继续停牌'], '其他重大事项': ['重大事项', '重大投资', '资产出售', '资产剥离', '商誉减值'], } def classify_announcement(title): """ 根据标题关键词对公告进行分类 参数: title: 公告标题 返回: str: 公告类型 """ for category, keywords in ANNOUNCEMENT_KEYWORDS.items(): for kw in keywords: if kw in title: return category return '其他' def get_stock_announcements(stock_code, days=7): """ 获取个股近期公告 参数: stock_code: 股票代码 days: 获取最近几天的公告 返回: list[dict]: 公告列表 """ cached = _get_cache(f'announcements_{stock_code}') if cached: return cached try: import akshare as ak end_date = datetime.now().strftime('%Y%m%d') start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') df = ak.stock_notice_report(symbol=stock_code, date=start_date) if df is None or df.empty: # 尝试备用接口 try: df = ak.stock_zh_a_disclosure_report_cninfo( symbol=stock_code, market='沪深京', start_date=start_date, end_date=end_date ) except Exception: return [] if df is None or df.empty: return [] announcements = [] for _, row in df.iterrows(): title = str(row.get('标题', row.get('title', ''))) date_str = str(row.get('公告日期', row.get('date', ''))) category = classify_announcement(title) announcements.append({ 'title': title, 'date': date_str[:10] if date_str else '', 'category': category, 'sentiment': None, # 待LLM分析 }) _set_cache(f'announcements_{stock_code}', announcements) return announcements except Exception as e: logger.warning(f"获取公告数据失败({stock_code}): {e}") return [] def analyze_announcement_sentiment(stock_name, stock_code, announcements): """ 使用LLM分析公告情感倾向 参数: stock_name: 股票名称 stock_code: 股票代码 announcements: 公告列表 返回: dict: { 'score': int, # 评分增减(-15 ~ +15) 'summary': str, # 白话总结 'reasons': list, # 评分原因 'details': list, # 各公告分析结果 } """ if not announcements: return { 'score': 0, 'summary': '近期无重要公告', 'reasons': [], 'details': [], } # 先用规则快速分类 positive_keywords = ['收购', '增持', '预增', '扭亏', '重大合同', '中标', '战略合作', '分红', '送转', '股权激励'] negative_keywords = ['减持', '预亏', '预减', '商誉减值', '资产出售', '停牌', '重大事项'] details = [] score = 0 reasons = [] positive_count = 0 negative_count = 0 for ann in announcements: title = ann['title'] category = ann['category'] is_positive = any(kw in title for kw in positive_keywords) is_negative = any(kw in title for kw in negative_keywords) if is_positive and not is_negative: sentiment = '利好' ann_score = _get_category_score(category, positive=True) positive_count += 1 elif is_negative and not is_positive: sentiment = '利空' ann_score = _get_category_score(category, positive=False) negative_count += 1 else: sentiment = '中性' ann_score = 0 ann['sentiment'] = sentiment ann['score'] = ann_score score += ann_score details.append(ann) if ann_score != 0: reasons.append(f'[{category}]{title[:30]}...({sentiment}{ann_score:+d})') # 尝试用LLM深度分析(如果有重要公告) important_categories = ['并购重组', '业绩预告', '增减持', '定增再融资'] important_anns = [a for a in announcements if a['category'] in important_categories] if important_anns and len(important_anns) <= 5: try: llm_result = _llm_analyze_announcements(stock_name, stock_code, important_anns) if llm_result: # LLM分析覆盖规则评分 score = llm_result.get('score', score) reasons = llm_result.get('reasons', reasons) except Exception as e: logger.warning(f"LLM公告分析失败: {e}") score = max(-15, min(15, score)) # 白话总结 if positive_count > negative_count: summary = f'近{len(announcements)}条公告中{positive_count}条利好、{negative_count}条利空,消息面偏多' elif negative_count > positive_count: summary = f'近{len(announcements)}条公告中{negative_count}条利空、{positive_count}条利好,消息面偏空' else: summary = f'近{len(announcements)}条公告,消息面中性' return { 'score': score, 'summary': summary, 'reasons': reasons, 'details': details, } def _get_category_score(category, positive=True): """根据公告类型和方向返回评分""" scores = { '并购重组': 10 if positive else -8, '业绩预告': 8 if positive else -10, '增减持': 5 if positive else -5, '定增再融资': 5 if positive else -3, '重大合同': 5 if positive else 0, '分红送转': 3 if positive else 0, '股权激励': 3 if positive else 0, '停复牌': 0, '其他重大事项': 0, '其他': 0, } return scores.get(category, 0) def _llm_analyze_announcements(stock_name, stock_code, announcements): """ 调用豆包LLM分析公告情感 参数: stock_name: 股票名称 stock_code: 股票代码 announcements: 重要公告列表 返回: dict: LLM分析结果 """ try: import requests import json from services.doubao_api import API_KEY, API_URL, MODEL ann_text = '\n'.join([f"- [{a['category']}]{a['title']}" for a in announcements]) prompt = f"""请分析以下{stock_name}({stock_code})的近期公告,判断每条公告是利好还是利空,并给出整体消息面评分。 公告列表: {ann_text} 请按以下JSON格式输出(不要输出其他内容): {{"score": <整数,-15到+15>, "reasons": ["原因1", "原因2"], "summary": "一句话总结"}}""" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}" } payload = { "model": MODEL, "max_completion_tokens": 1024, "stream": False, "messages": [ {"role": "user", "content": prompt} ] } resp = requests.post(API_URL, headers=headers, json=payload, timeout=30) if resp.status_code == 200: data = resp.json() content = data.get('choices', [{}])[0].get('message', {}).get('content', '') # 尝试解析JSON try: result = json.loads(content) return result except json.JSONDecodeError: # 尝试提取JSON import re match = re.search(r'\{.*\}', content, re.DOTALL) if match: return json.loads(match.group()) return None except Exception as e: logger.warning(f"LLM公告分析失败: {e}") return None def detect_price_anomaly(stock_code, df): """ 检测股价异动(可能由消息面驱动) 参数: stock_code: 股票代码 df: K线DataFrame 返回: dict: 异动检测结果 """ if df is None or len(df) < 20: return {'anomaly': False, 'score': 0, 'reasons': []} try: import numpy as np recent = df.tail(5) vol_20 = float(df['volume'].tail(20).mean()) vol_recent = float(recent['volume'].mean()) vol_ratio = vol_recent / vol_20 if vol_20 > 0 else 1 change_recent = float((recent.iloc[-1]['close'] / recent.iloc[0]['close'] - 1) * 100) # 异动条件:量比>3 且 涨跌幅>5% if vol_ratio > 3 and abs(change_recent) > 5: direction = '利好' if change_recent > 0 else '利空' score = 5 if change_recent > 0 else -5 return { 'anomaly': True, 'direction': direction, 'vol_ratio': round(vol_ratio, 1), 'change_pct': round(change_recent, 2), 'score': score, 'reasons': [f'近期异动:量比{vol_ratio:.1f}倍+{direction}{abs(change_recent):.1f}%,可能有消息面催化({score:+d})'], 'summary': f'近期量比{vol_ratio:.1f}倍,{"涨" if change_recent > 0 else "跌"}{abs(change_recent):.1f}%,可能有消息面催化', } return {'anomaly': False, 'score': 0, 'reasons': []} except Exception as e: logger.warning(f"异动检测失败({stock_code}): {e}") return {'anomaly': False, 'score': 0, 'reasons': []} # ═══════════════════════════════════════════════ # P6: 政策面分析 # ═══════════════════════════════════════════════ # 政策关键词 POLICY_KEYWORDS = { '行业扶持': ['扶持', '支持', '补贴', '鼓励', '促进', '加快', '推动', '振兴'], '行业监管': ['监管', '限制', '禁止', '整顿', '规范', '处罚', '约谈'], '货币政策': ['降准', '降息', '逆回购', 'MLF', 'SLF', '流动性', '存款准备金'], '财政政策': ['减税', '降费', '基建', '专项债', '财政赤字', '以旧换新'], '资本市场': ['注册制', '退市', '再融资', 'IPO', '印花税', '减持新规', '分红'], } def get_policy_news(days=3): """ 获取近期财经政策新闻 返回: list[dict]: 政策新闻列表 """ cached = _get_cache('policy_news') if cached: return cached try: import akshare as ak # 获取财经新闻 df = ak.stock_info_global_em() if df is None or df.empty: return [] # 筛选含政策关键词的新闻 policy_news = [] for _, row in df.head(50).iterrows(): title = str(row.get('标题', row.get('title', ''))) content = str(row.get('内容', row.get('content', ''))) date_str = str(row.get('发布时间', row.get('date', ''))) for category, keywords in POLICY_KEYWORDS.items(): if any(kw in title for kw in keywords): policy_news.append({ 'title': title, 'date': date_str[:10] if date_str else '', 'category': category, 'content': content[:200], 'sentiment': None, }) break _set_cache('policy_news', policy_news) return policy_news except Exception as e: logger.warning(f"获取政策新闻失败: {e}") return [] def analyze_policy_impact(policy_news): """ 分析政策面对市场的影响 参数: policy_news: 政策新闻列表 返回: dict: { 'score': int, # 评分增减(-10 ~ +10) 'summary': str, # 白话总结 'reasons': list, # 评分原因 'affected_sectors': dict, # 受影响板块 } """ if not policy_news: return { 'score': 0, 'summary': '近期无明显政策消息', 'reasons': [], 'affected_sectors': {}, } # 规则评分 sector_impact = { '行业扶持': {'direction': '利好', 'sectors': ['对应行业板块']}, '行业监管': {'direction': '利空', 'sectors': ['对应行业板块']}, '货币政策': {'direction': '利好', 'sectors': ['全市场']}, '财政政策': {'direction': '利好', 'sectors': ['基建', '消费', '相关板块']}, '资本市场': {'direction': '中性', 'sectors': ['券商', '全市场']}, } score = 0 reasons = [] affected = {} positive_count = 0 negative_count = 0 for news in policy_news: category = news['category'] impact = sector_impact.get(category, {'direction': '中性', 'sectors': []}) if impact['direction'] == '利好': score += 2 positive_count += 1 news['sentiment'] = '利好' reasons.append(f'[{category}]{news["title"][:30]}...(利好+2)') elif impact['direction'] == '利空': score -= 3 negative_count += 1 news['sentiment'] = '利空' reasons.append(f'[{category}]{news["title"][:30]}...(利空-3)') else: news['sentiment'] = '中性' affected[category] = impact # 尝试用LLM深度分析重大政策 major_policies = [n for n in policy_news if n['category'] in ['行业扶持', '行业监管', '货币政策']] if major_policies and len(major_policies) <= 5: try: llm_result = _llm_analyze_policy(major_policies) if llm_result: score = llm_result.get('score', score) reasons = llm_result.get('reasons', reasons) except Exception as e: logger.warning(f"LLM政策分析失败: {e}") score = max(-10, min(10, score)) if positive_count > negative_count: summary = f'近期{len(policy_news)}条政策消息,偏利好({positive_count}条利好/{negative_count}条利空)' elif negative_count > positive_count: summary = f'近期{len(policy_news)}条政策消息,偏利空({negative_count}条利空/{positive_count}条利好)' else: summary = f'近期{len(policy_news)}条政策消息,影响中性' return { 'score': score, 'summary': summary, 'reasons': reasons, 'affected_sectors': affected, 'details': policy_news, } def _llm_analyze_policy(policy_news): """ 调用豆包LLM分析政策影响 参数: policy_news: 政策新闻列表 返回: dict: LLM分析结果 """ try: import requests import json from services.doubao_api import API_KEY, API_URL, MODEL news_text = '\n'.join([f"- [{n['category']}]{n['title']}" for n in policy_news]) prompt = f"""请分析以下财经政策新闻对A股市场的影响,判断整体是利好还是利空,并给出评分。 政策新闻: {news_text} 请按以下JSON格式输出(不要输出其他内容): {{"score": <整数,-10到+10>, "reasons": ["原因1", "原因2"], "summary": "一句话总结", "affected_sectors": {{"板块名": "利好/利空"}}}}""" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}" } payload = { "model": MODEL, "max_completion_tokens": 1024, "stream": False, "messages": [ {"role": "user", "content": prompt} ] } resp = requests.post(API_URL, headers=headers, json=payload, timeout=30) if resp.status_code == 200: data = resp.json() content = data.get('choices', [{}])[0].get('message', {}).get('content', '') try: return json.loads(content) except json.JSONDecodeError: import re match = re.search(r'\{.*\}', content, re.DOTALL) if match: return json.loads(match.group()) return None except Exception as e: logger.warning(f"LLM政策分析失败: {e}") return None # ═══════════════════════════════════════════════ # 综合消息面分析 # ═══════════════════════════════════════════════ def analyze_news_factors(stock_code, stock_name, df=None): """ 获取个股消息面 + 政策面综合分析 参数: stock_code: 股票代码 stock_name: 股票名称 df: K线DataFrame(用于异动检测) 返回: dict: 综合消息面分析结果 """ # 公告分析 announcements = get_stock_announcements(stock_code, days=7) ann_result = analyze_announcement_sentiment(stock_name, stock_code, announcements) # 异动检测 anomaly_result = detect_price_anomaly(stock_code, df) if df is not None else {'anomaly': False, 'score': 0, 'reasons': []} # 政策面 policy_news = get_policy_news(days=3) policy_result = analyze_policy_impact(policy_news) total_score = ann_result.get('score', 0) + anomaly_result.get('score', 0) + policy_result.get('score', 0) total_score = max(-20, min(20, total_score)) all_reasons = [] all_reasons.extend(ann_result.get('reasons', [])) all_reasons.extend(anomaly_result.get('reasons', [])) all_reasons.extend(policy_result.get('reasons', [])) return { 'announcements': ann_result, 'price_anomaly': anomaly_result, 'policy': policy_result, 'total_score': total_score, 'all_reasons': all_reasons, 'summary': f"公告:{ann_result.get('summary', '')} | 政策:{policy_result.get('summary', '')}", }