Files
stock/stock-html/services/external_factors.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

546 lines
19 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.
"""
外部因素分析模块(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': [],
}