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