feat: 新增外部因素分析模块+综合评分引擎+算法文档重构

新增模块:
- 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整合外部因素修正推荐
This commit is contained in:
selfrelease
2026-07-17 23:50:42 +08:00
parent 04f8b9f951
commit 2b5a32ca1e
23 changed files with 4191 additions and 268 deletions
+584
View File
@@ -0,0 +1,584 @@
"""
新闻/公告/政策分析模块(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', '')}",
}