2b5a32ca1e
新增模块: - fund_flow_analyzer.py: 主力资金流向分析(P0, ±20) - market_sentiment.py: 市场情绪指标(P1, ±10) - external_factors.py: 北向资金/美股/大宗商品/汇率(P2-P4,P7) - news_analyzer.py: 公告/并购/政策面LLM分析(P5-P6) - score_engine.py: 综合评分引擎,整合技术面+外部因素 路由更新: - analysis.py: deep_analyze接入综合评分,根据最终评级修正买卖建议 - market.py: 新增4个外部因素API端点 - trades.py: 交易路由更新 算法文档重构: - 章节重排: 技术面(二三)→外部因素(四)→买卖决策(五)→数据源(六)→性能(七) - 架构图更新为五层,标注章节对应 - 5.1/5.2标注纯技术面,5.3整合外部因素修正推荐
585 lines
20 KiB
Python
585 lines
20 KiB
Python
"""
|
||
新闻/公告/政策分析模块(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', '')}",
|
||
}
|