Files
stock/stock-html/services/external_factors.py
T
selfrelease 79e869eeda fix: 修复外部因素数据源 - 北向资金改为南向资金,修复P0-P7全部外部因素
- P0资金面:DB数据过期时用麦蕊API获取资金流向
- P1市场情绪:去掉turnover字段依赖(DB无此字段)
- P2南向资金:北向实时数据已停公布,改用南向资金(港股通)替代
- P3美股:用腾讯财经API替代失效的AKShare接口
- P4大宗商品:用腾讯财经API替代,修复var_name解析
- P7汇率:用新浪财经API替代失效的AKShare接口
- 修复评分详情API技术得分硬编码问题
- 前端传入tech_score参数确保明细与列表分数一致
2026-07-18 16:45:58 +08:00

627 lines
23 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: 汇率变化
数据源:
- P2: AKShare stock_hsgt_hist_em(南向资金历史)+ stock_hsgt_fund_flow_summary_em(今日汇总)
- P3: 腾讯财经API(美股指数实时)
- P4: 腾讯财经API(商品期货实时)
- P7: 新浪财经API(人民币汇率)
所有数据采集均带超时和异常处理,失败时返回中性评分不影响主流程。
"""
import logging
import requests
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_southbound_capital():
"""
获取跨境资金流向数据
南向资金(港股通)反映内地资金配置港股的意愿,是跨境资金情绪的重要指标:
- 南向净流入 > 0:内地资金积极配置港股,大中华区risk-on,对A股偏正面
- 南向净流入 < 0:内地资金撤出港股,risk-off,对A股偏负面
数据源:
1. AKShare stock_hsgt_fund_flow_summary_em(今日汇总)
2. AKShare stock_hsgt_hist_em(南向资金历史,用于连续天数计算)
返回:
dict: {
'net_inflow': float, # 今日南向净流入(亿)
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
}
"""
cached = _get_cache('southbound')
if cached:
return cached
try:
import akshare as ak
import pandas as pd
# 1. 用 stock_hsgt_fund_flow_summary_em 获取今日汇总
today_inflow = 0
try:
df_summary = ak.stock_hsgt_fund_flow_summary_em()
if df_summary is not None and not df_summary.empty:
# 筛选南向资金行
south_rows = df_summary[df_summary['资金方向'] == '南向']
if not south_rows.empty:
# 成交净买额列求和
vals = south_rows['成交净买额'].tolist()
today_inflow = float(sum(v for v in vals if pd.notna(v) and v != 0))
except Exception as e:
logger.debug(f"stock_hsgt_fund_flow_summary_em失败: {e}")
# 2. 用 stock_hsgt_hist_em 获取南向资金历史(计算连续天数)
consecutive_inflow = 0
consecutive_outflow = 0
try:
df = ak.stock_hsgt_hist_em(symbol="南向资金")
if df is not None and not df.empty:
recent = df.tail(5)
for _, row in recent[::-1].iterrows():
val = float(row.get('当日成交净买额', 0) or 0)
if str(val) == 'nan' or pd.isna(val):
val = 0
if val > 0:
if consecutive_outflow > 0:
break
consecutive_inflow += 1
elif val < 0:
if consecutive_inflow > 0:
break
consecutive_outflow += 1
except Exception as e:
logger.debug(f"stock_hsgt_hist_em南向失败: {e}")
# 如果今日数据也为0或NaN,说明无法获取
if str(today_inflow) == 'nan' or today_inflow == 0:
# 尝试从历史数据取最新值
try:
df = ak.stock_hsgt_hist_em(symbol="南向资金")
if df is not None and not df.empty:
last_val = float(df.iloc[-1].get('当日成交净买额', 0) or 0)
if str(last_val) != 'nan' and not pd.isna(last_val) and last_val != 0:
today_inflow = last_val
except Exception:
pass
# 评分(基于南向资金,逻辑与北向一致:净流入=正面,净流出=负面)
score = 0
reasons = []
summary_parts = []
if today_inflow > 80:
score += 5
reasons.append(f'南向今日净流入{today_inflow:.1f}亿(+5)')
summary_parts.append(f'南向资金大幅流入{today_inflow:.1f}亿元,跨境资金情绪偏暖')
elif today_inflow > 30:
score += 3
reasons.append(f'南向今日净流入{today_inflow:.1f}亿(+3)')
summary_parts.append(f'南向资金净流入{today_inflow:.1f}亿元')
elif today_inflow < -80:
score -= 5
reasons.append(f'南向今日净流出{abs(today_inflow):.1f}亿(-5)')
summary_parts.append(f'南向资金大幅流出{abs(today_inflow):.1f}亿元,跨境资金情绪偏冷')
elif today_inflow < -30:
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('southbound', 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():
"""
获取美股隔夜收盘数据,计算外盘情绪
数据源:腾讯财经APIqt.gtimg.cn
获取道琼斯、纳斯达克、标普500三大指数实时行情。
返回:
dict: {
'indices': dict, # 三大指数涨跌
'score': int, # 评分增减(-10 ~ +10
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_a_sectors': dict, # 对A股板块的影响
}
"""
cached = _get_cache('us_market')
if cached:
return cached
try:
# 腾讯财经API获取美股指数
# 格式: v_usDJI="200~道琼斯~.DJI~price~...~change_pct~..."
url = 'https://qt.gtimg.cn/q=usDJI,usIXIC,usSPX'
resp = requests.get(url, timeout=10)
text = resp.content.decode('gbk', errors='replace')
us_indices = {}
for line in text.strip().split(';'):
line = line.strip()
if not line or 'v_pv_none_match' in line:
continue
# 解析 v_usDJI="..."
if '=' not in line:
continue
var_name = line.split('=')[0].strip().replace('var ', '').replace('v_', '')
value = line.split('"')[1] if '"' in line else ''
fields = value.split('~')
if len(fields) < 33:
continue
name = fields[1]
change_pct = float(fields[32]) if fields[32] else 0
if 'DJI' in var_name.upper() or '道琼斯' in name:
us_indices['dow'] = {'name': name, 'change_pct': change_pct}
elif 'IXIC' in var_name.upper() or '纳斯达克' in name:
us_indices['nasdaq'] = {'name': name, 'change_pct': change_pct}
elif 'SPX' in var_name.upper() or '标普' in name:
us_indices['sp500'] = {'name': name, 'change_pct': change_pct}
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():
"""
获取主要大宗商品价格变化
数据源:腾讯财经APIqt.gtimg.cn
获取纽约黄金、纽约原油、美铜等商品期货实时行情。
返回:
dict: {
'commodities': dict, # 各商品涨跌
'score': int, # 评分增减(-5 ~ +5)
'summary': str, # 白话总结
'reasons': list, # 评分原因
'affected_sectors': dict, # 对A股板块影响
}
"""
cached = _get_cache('commodity')
if cached:
return cached
try:
# 腾讯财经API获取商品期货
# hf_GC=纽约黄金, hf_CL=纽约原油, hf_HG=美铜
url = 'https://qt.gtimg.cn/q=hf_GC,hf_CL,hf_HG'
resp = requests.get(url, timeout=10)
text = resp.content.decode('gbk', errors='replace')
# 商品名称映射
symbol_map = {
'hf_GC': '黄金',
'hf_CL': '原油',
'hf_HG': '',
}
commodities = {}
for line in text.strip().split(';'):
line = line.strip()
if not line or 'v_pv_none_match' in line:
continue
if '=' not in line:
continue
var_name = line.split('=')[0].strip().replace('var ', '').replace('v_', '')
value = line.split('"')[1] if '"' in line else ''
fields = value.split(',')
if len(fields) < 10:
continue
target = symbol_map.get(var_name)
if not target:
continue
# 腾讯商品格式: price,change_pct,prev_close,open,high,low,time,...,name
current_price = float(fields[0]) if fields[0] else 0
change_pct = float(fields[1]) if fields[1] else 0
name = fields[-1].rstrip(';"')
commodities[target] = {
'price': current_price,
'change_pct': round(change_pct, 2),
'name': name,
}
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():
"""
获取人民币汇率变化
数据源:新浪财经APIhq.sinajs.cn
获取在岸人民币兑美元实时汇率。
返回:
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:
# 新浪财经API获取在岸人民币汇率
# 格式: var hq_str_fx_susdcny="time,bid,ask,prev_close,...,name,change_pct,..."
url = 'https://hq.sinajs.cn/list=fx_susdcny'
resp = requests.get(url, timeout=10, headers={'Referer': 'https://finance.sina.com.cn'})
text = resp.content.decode('gbk', errors='replace')
# 解析汇率数据
if 'hq_str_fx_susdcny' not in text:
return _neutral_result('汇率数据解析失败')
value = text.split('"')[1] if '"' in text else ''
fields = value.split(',')
if len(fields) < 11:
return _neutral_result('汇率数据格式异常')
# 新浪汇率格式: time,bid,ask,prev_close,?,mid,?,?,?,name,change_pct,...
today_rate = float(fields[5]) if fields[5] else 0 # 中间价
prev_rate = float(fields[3]) if fields[3] else 0 # 昨收价
change_pct = float(fields[10]) if fields[10] else 0 # 涨跌幅
if today_rate == 0:
today_rate = float(fields[1]) if fields[1] else 0
if prev_rate == 0:
prev_rate = float(fields[3]) if fields[3] else 0
if today_rate == 0 or prev_rate == 0:
return _neutral_result('汇率数据异常')
# 如果涨跌幅为0,自行计算
if change_pct == 0:
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: 包含南向资金、美股、大宗商品、汇率的综合数据
"""
southbound = get_southbound_capital()
us_market = get_us_market_overview()
commodity = get_commodity_overview()
fx = get_fx_overview()
total_score = (
southbound.get('score', 0) +
us_market.get('score', 0) +
commodity.get('score', 0) +
fx.get('score', 0)
)
all_reasons = []
all_reasons.extend(southbound.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 [('南向资金', southbound), ('美股', us_market), ('大宗商品', commodity), ('汇率', fx)]:
s = data.get('summary', '')
if s and '失败' not in s and '为空' not in s:
summaries.append(f'{name}{s}')
return {
'southbound_capital': southbound,
'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': [],
}