""" 外部因素分析模块(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(): """ 获取美股隔夜收盘数据,计算外盘情绪 数据源:腾讯财经API(qt.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(): """ 获取主要大宗商品价格变化 数据源:腾讯财经API(qt.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(): """ 获取人民币汇率变化 数据源:新浪财经API(hq.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': [], }