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
+545
View File
@@ -0,0 +1,545 @@
"""
外部因素分析模块(P2/P3/P4/P7
包含:
- P2: 北向资金(外资动向)
- P3: 美股隔夜板块变化
- P4: 大宗商品价格
- P7: 汇率变化
数据源:AKShare(开源免费)
所有数据采集均带超时和异常处理,失败时返回中性评分不影响主流程。
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# 缓存(当日有效)
_cache = {}
_cache_date = {}
def _get_cache(key):
"""获取当日缓存"""
today = datetime.now().strftime('%Y-%m-%d')
if _cache_date.get(key) == today:
return _cache.get(key)
return None
def _set_cache(key, value):
"""设置当日缓存"""
today = datetime.now().strftime('%Y-%m-%d')
_cache[key] = value
_cache_date[key] = today
# ═══════════════════════════════════════════════
# P2: 北向资金
# ═══════════════════════════════════════════════
def get_northbound_capital():
"""
获取北向资金净流入数据
返回:
dict: {
'net_inflow': float, # 今日净流入(亿)
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
}
"""
cached = _get_cache('northbound')
if cached:
return cached
try:
import akshare as ak
# 获取北向资金净流入数据
df = ak.stock_hsgt_north_net_flow_in_em(symbol="北向")
if df is None or df.empty:
return _neutral_result('北向资金数据为空')
# 取最近5个交易日
recent = df.tail(5)
today_inflow = float(recent.iloc[-1].get('当日净流入', 0) or 0)
# 连续流入/流出天数
consecutive_inflow = 0
consecutive_outflow = 0
for _, row in recent[::-1].iterrows():
val = float(row.get('当日净流入', 0) or 0)
if val > 0:
if consecutive_outflow > 0:
break
consecutive_inflow += 1
elif val < 0:
if consecutive_inflow > 0:
break
consecutive_outflow += 1
# 评分
score = 0
reasons = []
summary_parts = []
if today_inflow > 50:
score += 5
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+5)')
summary_parts.append(f'外资今日大幅买入{today_inflow:.1f}亿元')
elif today_inflow > 20:
score += 3
reasons.append(f'北向今日净流入{today_inflow:.1f}亿(+3)')
summary_parts.append(f'外资今日净流入{today_inflow:.1f}亿元')
elif today_inflow < -50:
score -= 5
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-5)')
summary_parts.append(f'外资今日大幅卖出{abs(today_inflow):.1f}亿元')
elif today_inflow < -20:
score -= 3
reasons.append(f'北向今日净流出{abs(today_inflow):.1f}亿(-3)')
summary_parts.append(f'外资今日净流出{abs(today_inflow):.1f}亿元')
else:
summary_parts.append(f'外资今日净流入{today_inflow:.1f}亿元,方向不明')
if consecutive_inflow >= 3:
score += 3
reasons.append(f'北向连续{consecutive_inflow}日净流入(+3)')
summary_parts.append(f'已连续{consecutive_inflow}天买入')
if consecutive_outflow >= 3:
score -= 3
reasons.append(f'北向连续{consecutive_outflow}日净流出(-3)')
summary_parts.append(f'已连续{consecutive_outflow}天卖出')
score = max(-10, min(10, score))
result = {
'net_inflow': round(today_inflow, 2),
'consecutive_inflow': consecutive_inflow,
'consecutive_outflow': consecutive_outflow,
'score': score,
'summary': ''.join(summary_parts),
'reasons': reasons,
}
_set_cache('northbound', result)
return result
except Exception as e:
logger.warning(f"获取北向资金数据失败: {e}")
return _neutral_result('北向资金数据获取失败')
# ═══════════════════════════════════════════════
# P3: 美股隔夜板块变化
# ═══════════════════════════════════════════════
# 美股板块 → A股板块映射
US_A_SECTOR_MAP = {
'科技': ['半导体', '软件', '消费电子', '芯片', 'IT服务'],
'新能源车': ['锂电池', '汽车零部件', '新能源车', '充电桩'],
'金融': ['银行', '保险', '券商'],
'能源': ['石油开采', '化工', '页岩气'],
'医药': ['创新药', '医疗器械', '生物制品', 'CXO'],
'消费': ['白酒', '食品饮料', '零售', '免税'],
'房地产': ['房地产', '建材', '家居'],
'工业': ['机械', '军工', '工业4.0'],
'材料': ['有色金属', '钢铁', '化工新材料'],
'公用事业': ['电力', '环保', '水务'],
}
def get_us_market_overview():
"""
获取美股隔夜收盘数据,计算外盘情绪
返回:
dict: {
'indices': dict, # 三大指数涨跌
'sectors': dict, # 主要板块涨跌
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_a_sectors': dict, # 对A股板块的影响
}
"""
cached = _get_cache('us_market')
if cached:
return cached
try:
import akshare as ak
# 获取全球主要指数
df = ak.index_global()
if df is None or df.empty:
return _neutral_result('美股指数数据为空')
# 筛选美股主要指数
us_indices = {}
for _, row in df.iterrows():
name = str(row.get('名称', ''))
if '纳斯达克' in name:
us_indices['nasdaq'] = {
'name': name,
'change_pct': float(row.get('涨跌幅', 0) or 0),
}
elif '道琼斯' in name:
us_indices['dow'] = {
'name': name,
'change_pct': float(row.get('涨跌幅', 0) or 0),
}
elif '标普500' in name:
us_indices['sp500'] = {
'name': name,
'change_pct': float(row.get('涨跌幅', 0) or 0),
}
if not us_indices:
return _neutral_result('未找到美股指数')
# 计算综合涨跌
avg_change = sum(v['change_pct'] for v in us_indices.values()) / len(us_indices)
# 评分
score = 0
reasons = []
summary_parts = []
if avg_change > 2:
score += 5
reasons.append(f'美股三大指数平均涨幅{avg_change:.1f}%(+5)')
summary_parts.append(f'美股大涨,平均涨幅{avg_change:.1f}%')
elif avg_change > 0.5:
score += 2
reasons.append(f'美股偏强,平均涨幅{avg_change:.1f}%(+2)')
summary_parts.append(f'美股小幅上涨,平均{avg_change:.1f}%')
elif avg_change < -2:
score -= 5
reasons.append(f'美股三大指数平均跌幅{abs(avg_change):.1f}%(-5)')
summary_parts.append(f'美股大跌,平均跌幅{abs(avg_change):.1f}%')
elif avg_change < -0.5:
score -= 2
reasons.append(f'美股偏弱,平均跌幅{abs(avg_change):.1f}%(-2)')
summary_parts.append(f'美股小幅下跌,平均{avg_change:.1f}%')
else:
summary_parts.append(f'美股基本平盘,平均变化{avg_change:.1f}%')
# 对A股板块的影响
affected_sectors = {}
if avg_change > 1:
for us_sector, a_sectors in US_A_SECTOR_MAP.items():
affected_sectors[us_sector] = {
'a_sectors': a_sectors,
'direction': '利好',
'note': f'美股{us_sector}板块偏强,A股{"".join(a_sectors[:3])}可能高开',
}
elif avg_change < -1:
for us_sector, a_sectors in US_A_SECTOR_MAP.items():
affected_sectors[us_sector] = {
'a_sectors': a_sectors,
'direction': '利空',
'note': f'美股{us_sector}板块偏弱,A股{"".join(a_sectors[:3])}可能低开',
}
score = max(-10, min(10, score))
result = {
'indices': us_indices,
'avg_change': round(avg_change, 2),
'score': score,
'summary': ''.join(summary_parts),
'reasons': reasons,
'affected_a_sectors': affected_sectors,
}
_set_cache('us_market', result)
return result
except Exception as e:
logger.warning(f"获取美股数据失败: {e}")
return _neutral_result('美股数据获取失败')
# ═══════════════════════════════════════════════
# P4: 大宗商品价格
# ═══════════════════════════════════════════════
# 大宗商品 → A股板块影响映射
COMMODITY_A_SECTOR_MAP = {
'原油': {
'beneficiary': ['石油开采', '油服', '化工'],
'victim': ['航空', '物流', '化工下游'],
'direction': '油价涨→开采受益,航空受损',
},
'黄金': {
'beneficiary': ['黄金股', '珠宝', '有色'],
'victim': [],
'direction': '金价涨→黄金企业受益',
},
'': {
'beneficiary': ['铜矿', '有色', '电缆'],
'victim': [],
'direction': '铜价涨→铜企受益',
},
'螺纹钢': {
'beneficiary': ['钢铁', '钢矿'],
'victim': ['基建', '地产'],
'direction': '钢价涨→钢企受益,基建成本增',
},
'碳酸锂': {
'beneficiary': ['锂矿', '锂电池'],
'victim': ['新能源车'],
'direction': '锂价涨→锂矿受益,新能源车成本增',
},
}
def get_commodity_overview():
"""
获取主要大宗商品价格变化
返回:
dict: {
'commodities': dict, # 各商品涨跌
'score': int, # 评分增减(-5 ~ +5)
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 对A股板块影响
}
"""
cached = _get_cache('commodity')
if cached:
return cached
try:
import akshare as ak
# 获取国内商品期货行情
df = ak.futures_main_sina()
if df is None or df.empty:
return _neutral_result('大宗商品数据为空')
# 关注的商品
target_commodities = ['原油', '黄金', '', '螺纹钢', '碳酸锂']
commodities = {}
for _, row in df.iterrows():
symbol = str(row.get('symbol', ''))
for target in target_commodities:
if target in symbol:
change = float(row.get('change', 0) or 0)
pct = float(row.get('change_pct', 0) or 0)
commodities[target] = {
'symbol': symbol,
'change_pct': round(pct, 2),
}
break
if not commodities:
return _neutral_result('未找到关注的大宗商品')
# 评分和影响
score = 0
reasons = []
summary_parts = []
affected = {}
for name, data in commodities.items():
pct = data['change_pct']
if abs(pct) < 0.5:
continue
mapping = COMMODITY_A_SECTOR_MAP.get(name)
if not mapping:
continue
if pct > 2:
score += 1
reasons.append(f'{name}{pct:.1f}%,利好{"".join(mapping["beneficiary"][:2])}(+1)')
summary_parts.append(f'{name}大涨{pct:.1f}%')
affected[name] = {
'direction': '利好',
'beneficiary': mapping['beneficiary'],
'victim': mapping['victim'],
'note': mapping['direction'],
}
elif pct < -2:
score -= 1
reasons.append(f'{name}{abs(pct):.1f}%,利空{"".join(mapping["beneficiary"][:2])}(-1)')
summary_parts.append(f'{name}大跌{pct:.1f}%')
affected[name] = {
'direction': '利空',
'beneficiary': mapping['victim'],
'victim': mapping['beneficiary'],
'note': mapping['direction'],
}
score = max(-5, min(5, score))
result = {
'commodities': commodities,
'score': score,
'summary': ''.join(summary_parts) if summary_parts else '大宗商品整体平稳',
'reasons': reasons,
'affected_sectors': affected,
}
_set_cache('commodity', result)
return result
except Exception as e:
logger.warning(f"获取大宗商品数据失败: {e}")
return _neutral_result('大宗商品数据获取失败')
# ═══════════════════════════════════════════════
# P7: 汇率变化
# ═══════════════════════════════════════════════
# 汇率 → A股板块影响
FX_SECTOR_MAP = {
'升值': {
'beneficiary': ['航空', '造纸', '房地产'],
'victim': ['纺织', '家电出口', '电子代工'],
},
'贬值': {
'beneficiary': ['纺织', '家电', '电子代工'],
'victim': ['航空', '造纸'],
},
}
def get_fx_overview():
"""
获取人民币汇率变化
返回:
dict: {
'usd_cny': float, # 美元兑人民币汇率
'change_pct': float, # 涨跌幅
'direction': str, # 升值/贬值/稳定
'score': int, # 评分增减(-3 ~ +3)
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 对A股板块影响
}
"""
cached = _get_cache('fx')
if cached:
return cached
try:
import akshare as ak
# 获取人民币汇率
df = ak.currency_boc_sina(symbol="美元")
if df is None or df.empty:
return _neutral_result('汇率数据为空')
# 取最近2条计算变化
recent = df.tail(2)
if len(recent) < 2:
return _neutral_result('汇率数据不足')
today_rate = float(recent.iloc[-1].get('中行折算价', 0) or 0)
prev_rate = float(recent.iloc[-2].get('中行折算价', 0) or 0)
if prev_rate == 0:
return _neutral_result('汇率数据异常')
change_pct = round((today_rate / prev_rate - 1) * 100, 3)
# 判断方向(美元兑人民币:涨=人民币贬值,跌=人民币升值)
if change_pct > 0.1:
direction = '贬值'
score = -2
summary = f'人民币贬值{abs(change_pct):.3f}%'
reasons = [f'人民币贬值{abs(change_pct):.3f}%(-2)']
affected = FX_SECTOR_MAP['贬值']
elif change_pct < -0.1:
direction = '升值'
score = 2
summary = f'人民币升值{abs(change_pct):.3f}%'
reasons = [f'人民币升值{abs(change_pct):.3f}%(+2)']
affected = FX_SECTOR_MAP['升值']
else:
direction = '稳定'
score = 0
summary = '人民币汇率基本稳定'
reasons = []
affected = {}
score = max(-3, min(3, score))
result = {
'usd_cny': round(today_rate, 4),
'change_pct': change_pct,
'direction': direction,
'score': score,
'summary': summary,
'reasons': reasons,
'affected_sectors': affected,
}
_set_cache('fx', result)
return result
except Exception as e:
logger.warning(f"获取汇率数据失败: {e}")
return _neutral_result('汇率数据获取失败')
# ═══════════════════════════════════════════════
# 综合外部因素
# ═══════════════════════════════════════════════
def get_all_external_factors():
"""
获取所有外部因素数据,返回综合结果
返回:
dict: 包含北向资金、美股、大宗商品、汇率的综合数据
"""
northbound = get_northbound_capital()
us_market = get_us_market_overview()
commodity = get_commodity_overview()
fx = get_fx_overview()
total_score = (
northbound.get('score', 0) +
us_market.get('score', 0) +
commodity.get('score', 0) +
fx.get('score', 0)
)
all_reasons = []
all_reasons.extend(northbound.get('reasons', []))
all_reasons.extend(us_market.get('reasons', []))
all_reasons.extend(commodity.get('reasons', []))
all_reasons.extend(fx.get('reasons', []))
summaries = []
for name, data in [('北向资金', northbound), ('美股', us_market), ('大宗商品', commodity), ('汇率', fx)]:
s = data.get('summary', '')
if s and '失败' not in s and '为空' not in s:
summaries.append(f'{name}{s}')
return {
'northbound_capital': northbound,
'us_market': us_market,
'commodity': commodity,
'fx': fx,
'total_score': total_score,
'all_reasons': all_reasons,
'summary': ' | '.join(summaries),
}
def _neutral_result(reason):
"""返回中性结果"""
return {
'score': 0,
'summary': reason,
'reasons': [],
}