Files
stock/stock-html/services/news_analyzer.py
T
selfrelease 2b5a32ca1e 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整合外部因素修正推荐
2026-07-17 23:50:42 +08:00

585 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
新闻/公告/政策分析模块(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', '')}",
}