mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: 大盘复盘可选 A 股/美股区域 (Issue #299) - 新增 MARKET_REVIEW_REGION 配置:cn/us/both - 新增 market_profile 抽象,按 region 切换新闻搜索、Prompt、模板 - 数据层 get_main_indices(region):yfinance 支持 us 返回 SPX/IXIC/DJI/VIX - MarketAnalyzer 支持 region 参数,美股时跳过涨跌统计与板块 - region=both 时顺序执行 A 股+美股复盘并合并报告 - 更新 .env.example、full-guide、README、CHANGELOG * Issue #299: 审查建议修复与优化 - bot: 透传 market_review_region 到 MarketAnalyzer - yfinance: 提取 _fetch_yf_ticker_data 公共方法,消除 cn/us 重复逻辑 - market_analyzer: 修正 mood_index 匹配逻辑,美股搜索 stock_name 为 US market - market_analyzer: region=us 时 LLM 提示语改为英文模板 - market_review: both 模式增加 A股/美股生成进度日志 - tests: 新增 _get_us_main_indices 单元测试(mock yfinance) * Issue #299: 落实审查建议 - config: _parse_market_review_region 解析到非法值时打印 warning 日志 - market_review: both 模式报告分隔符增加描述性说明 - market_analyzer: 指数表格中 amount=0.0 时显示 N/A,避免用户误解 * docs: 优化 MARKET_REVIEW_REGION 描述措辞,避免歧义 - 移除「仅炒美股用户可设为 us」表述 - 明确 both 模式可同时复盘 A 股与美股 - 更新 full-guide、README、CHANGELOG、config 注释、env.example * style: 移除 bot market 命令中冗余的 or 'cn' 回退
This commit is contained in:
@@ -231,6 +231,8 @@ SCHEDULE_ENABLED=false
|
||||
SCHEDULE_TIME=18:00
|
||||
# 是否启用大盘复盘(true/false)
|
||||
MARKET_REVIEW_ENABLED=true
|
||||
# 大盘复盘市场区域:cn(A股)、us(美股)、both(两者),us 适合仅关注美股的用户
|
||||
# MARKET_REVIEW_REGION=cn
|
||||
|
||||
# ===================================
|
||||
# 代理配置(可选)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| AI | 决策仪表盘 | 一句话核心结论 + 精确买卖点位 + 操作检查清单 |
|
||||
| 分析 | 多维度分析 | 技术面 + 筹码分布 + 舆情情报 + 实时行情 |
|
||||
| 市场 | 全球市场 | 支持 A股、港股、美股及美股指数(SPX、DJI、IXIC 等) |
|
||||
| 复盘 | 大盘复盘 | 每日市场概览、板块涨跌、北向资金 |
|
||||
| 复盘 | 大盘复盘 | 每日市场概览、板块涨跌;支持 cn(A股)/us(美股)/both(两者) 切换 |
|
||||
| 图片识别 | 从图片添加 | 上传自选股截图,Vision LLM 自动提取股票代码,一键加入监控 |
|
||||
| 回测 | AI 回测验证 | 自动评估历史分析准确率,方向胜率、止盈止损命中率 |
|
||||
| 推送 | 多渠道通知 | 企业微信、飞书、Telegram、钉钉、邮件、Pushover |
|
||||
|
||||
@@ -97,10 +97,14 @@ class MarketCommand(BotCommand):
|
||||
if config.gemini_api_key or config.openai_api_key:
|
||||
analyzer = GeminiAnalyzer()
|
||||
|
||||
# 读取配置中的市场区域,与定时任务/CLI 保持一致
|
||||
region = getattr(config, 'market_review_region', 'cn')
|
||||
|
||||
# 执行复盘
|
||||
market_analyzer = MarketAnalyzer(
|
||||
search_service=search_service,
|
||||
analyzer=analyzer
|
||||
analyzer=analyzer,
|
||||
region=region,
|
||||
)
|
||||
|
||||
review_report = market_analyzer.run_daily_review()
|
||||
|
||||
@@ -1305,10 +1305,12 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
return result
|
||||
|
||||
def get_main_indices(self) -> Optional[List[Dict[str, Any]]]:
|
||||
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取主要指数实时行情 (新浪接口)
|
||||
获取主要指数实时行情 (新浪接口),仅支持 A 股
|
||||
"""
|
||||
if region != "cn":
|
||||
return None
|
||||
import akshare as ak
|
||||
|
||||
# 主要指数代码映射
|
||||
|
||||
@@ -131,10 +131,13 @@ class BaseFetcher(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_main_indices(self) -> Optional[List[Dict[str, Any]]]:
|
||||
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取主要指数实时行情
|
||||
|
||||
Args:
|
||||
region: 市场区域,cn=A股 us=美股
|
||||
|
||||
Returns:
|
||||
List[Dict]: 指数列表,每个元素为字典,包含:
|
||||
- code: 指数代码
|
||||
@@ -923,11 +926,11 @@ class DataFetcherManager:
|
||||
logger.info(f"[股票名称] 批量获取完成,成功 {len(result)}/{len(stock_codes)}")
|
||||
return result
|
||||
|
||||
def get_main_indices(self) -> List[Dict[str, Any]]:
|
||||
def get_main_indices(self, region: str = "cn") -> List[Dict[str, Any]]:
|
||||
"""获取主要指数实时行情(自动切换数据源)"""
|
||||
for fetcher in self._fetchers:
|
||||
try:
|
||||
data = fetcher.get_main_indices()
|
||||
data = fetcher.get_main_indices(region=region)
|
||||
if data:
|
||||
logger.info(f"[{fetcher.name}] 获取指数行情成功")
|
||||
return data
|
||||
|
||||
@@ -662,10 +662,12 @@ class EfinanceFetcher(BaseFetcher):
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
return None
|
||||
|
||||
def get_main_indices(self) -> Optional[List[Dict[str, Any]]]:
|
||||
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取主要指数实时行情 (efinance)
|
||||
获取主要指数实时行情 (efinance),仅支持 A 股
|
||||
"""
|
||||
if region != "cn":
|
||||
return None
|
||||
import efinance as ef
|
||||
|
||||
indices_map = {
|
||||
|
||||
@@ -612,10 +612,12 @@ class TushareFetcher(BaseFetcher):
|
||||
logger.warning(f"Tushare (旧版) 获取实时行情失败 {stock_code}: {e}")
|
||||
return None
|
||||
|
||||
def get_main_indices(self) -> Optional[List[dict]]:
|
||||
def get_main_indices(self, region: str = "cn") -> Optional[List[dict]]:
|
||||
"""
|
||||
获取主要指数实时行情 (Tushare Pro)
|
||||
获取主要指数实时行情 (Tushare Pro),仅支持 A 股
|
||||
"""
|
||||
if region != "cn":
|
||||
return None
|
||||
if self._api is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -231,13 +231,60 @@ class YfinanceFetcher(BaseFetcher):
|
||||
|
||||
return df
|
||||
|
||||
def get_main_indices(self) -> Optional[List[Dict[str, Any]]]:
|
||||
def _fetch_yf_ticker_data(self, yf, yf_code: str, name: str, return_code: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取主要指数行情 (Yahoo Finance)
|
||||
通过 yfinance 拉取单个指数/股票的行情数据。
|
||||
|
||||
Args:
|
||||
yf: yfinance 模块引用
|
||||
yf_code: yfinance 使用的代码(如 '000001.SS'、'^GSPC')
|
||||
name: 指数显示名称
|
||||
return_code: 写入结果 dict 的 code 字段(如 'sh000001'、'SPX')
|
||||
|
||||
Returns:
|
||||
行情字典,失败时返回 None
|
||||
"""
|
||||
ticker = yf.Ticker(yf_code)
|
||||
# 取近两日数据以计算涨跌幅
|
||||
hist = ticker.history(period='2d')
|
||||
if hist.empty:
|
||||
return None
|
||||
today_row = hist.iloc[-1]
|
||||
prev_row = hist.iloc[-2] if len(hist) > 1 else today_row
|
||||
price = float(today_row['Close'])
|
||||
prev_close = float(prev_row['Close'])
|
||||
change = price - prev_close
|
||||
change_pct = (change / prev_close) * 100 if prev_close else 0
|
||||
high = float(today_row['High'])
|
||||
low = float(today_row['Low'])
|
||||
# 振幅 = (最高 - 最低) / 昨收 * 100
|
||||
amplitude = ((high - low) / prev_close * 100) if prev_close else 0
|
||||
return {
|
||||
'code': return_code,
|
||||
'name': name,
|
||||
'current': price,
|
||||
'change': change,
|
||||
'change_pct': change_pct,
|
||||
'open': float(today_row['Open']),
|
||||
'high': high,
|
||||
'low': low,
|
||||
'prev_close': prev_close,
|
||||
'volume': float(today_row['Volume']),
|
||||
'amount': 0.0, # Yahoo Finance 不提供准确成交额
|
||||
'amplitude': amplitude,
|
||||
}
|
||||
|
||||
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取主要指数行情 (Yahoo Finance),支持 A 股与美股。
|
||||
region=us 时委托给 _get_us_main_indices。
|
||||
"""
|
||||
import yfinance as yf
|
||||
|
||||
# 映射关系:akshare代码 -> (yfinance代码, 名称)
|
||||
if region == "us":
|
||||
return self._get_us_main_indices(yf)
|
||||
|
||||
# A 股指数:akshare 代码 -> (yfinance 代码, 显示名称)
|
||||
yf_mapping = {
|
||||
'sh000001': ('000001.SS', '上证指数'),
|
||||
'sz399001': ('399001.SZ', '深证成指'),
|
||||
@@ -251,51 +298,46 @@ class YfinanceFetcher(BaseFetcher):
|
||||
try:
|
||||
for ak_code, (yf_code, name) in yf_mapping.items():
|
||||
try:
|
||||
ticker = yf.Ticker(yf_code)
|
||||
# 获取最近2天数据以计算涨跌
|
||||
hist = ticker.history(period='2d')
|
||||
if hist.empty:
|
||||
continue
|
||||
|
||||
today = hist.iloc[-1]
|
||||
prev = hist.iloc[-2] if len(hist) > 1 else today
|
||||
|
||||
price = float(today['Close'])
|
||||
prev_close = float(prev['Close'])
|
||||
change = price - prev_close
|
||||
change_pct = (change / prev_close) * 100 if prev_close else 0
|
||||
|
||||
# 振幅
|
||||
high = float(today['High'])
|
||||
low = float(today['Low'])
|
||||
amplitude = ((high - low) / prev_close * 100) if prev_close else 0
|
||||
|
||||
results.append({
|
||||
'code': ak_code,
|
||||
'name': name,
|
||||
'current': price,
|
||||
'change': change,
|
||||
'change_pct': change_pct,
|
||||
'open': float(today['Open']),
|
||||
'high': high,
|
||||
'low': low,
|
||||
'prev_close': prev_close,
|
||||
'volume': float(today['Volume']),
|
||||
'amount': 0.0, # Yahoo Finance 可能不提供准确的成交额
|
||||
'amplitude': amplitude
|
||||
})
|
||||
item = self._fetch_yf_ticker_data(yf, yf_code, name, ak_code)
|
||||
if item:
|
||||
results.append(item)
|
||||
logger.debug(f"[Yfinance] 获取指数 {name} 成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Yfinance] 获取指数 {name} 失败: {e}")
|
||||
continue
|
||||
|
||||
if results:
|
||||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个指数行情")
|
||||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个 A 股指数行情")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Yfinance] 获取指数行情失败: {e}")
|
||||
logger.error(f"[Yfinance] 获取 A 股指数行情失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_us_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
|
||||
"""获取美股主要指数行情(SPX、IXIC、DJI、VIX),复用 _fetch_yf_ticker_data"""
|
||||
# 大盘复盘所需核心美股指数
|
||||
us_indices = ['SPX', 'IXIC', 'DJI', 'VIX']
|
||||
results = []
|
||||
try:
|
||||
for code in us_indices:
|
||||
yf_symbol, name = get_us_index_yf_symbol(code)
|
||||
if not yf_symbol:
|
||||
continue
|
||||
try:
|
||||
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
|
||||
if item:
|
||||
results.append(item)
|
||||
logger.debug(f"[Yfinance] 获取美股指数 {name} 成功")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Yfinance] 获取美股指数 {name} 失败: {e}")
|
||||
|
||||
if results:
|
||||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个美股指数行情")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Yfinance] 获取美股指数行情失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
- 新增 `AI_REVIEW_STRICT` 开关,可选将 AI 审查失败升级为阻断
|
||||
|
||||
### 新增
|
||||
- **大盘复盘可选区域** (Issue #299)
|
||||
- 支持 `MARKET_REVIEW_REGION` 环境变量:cn(A股)、us(美股)、both(两者)
|
||||
- us 模式适合仅关注美股的用户,使用 SPX/纳斯达克/道指/VIX 等指数;both 模式可同时复盘 A 股与美股
|
||||
- 默认 cn,保持向后兼容
|
||||
- 📊 **仅分析结果摘要** (Issue #262)
|
||||
- 支持 `REPORT_SUMMARY_ONLY` 环境变量,设为 `true` 时只推送汇总,不含个股详情
|
||||
- 默认 `false`,多股时适合快速浏览
|
||||
|
||||
@@ -225,6 +225,7 @@ daily_stock_analysis/
|
||||
| `TRUST_X_FORWARDED_FOR` | 反向代理部署时设为 `true`,从 `X-Forwarded-For` 获取真实 IP(限流等);直连公网时保持 `false` 防伪造 | `false` |
|
||||
| `MAX_WORKERS` | 并发线程数 | `3` |
|
||||
| `MARKET_REVIEW_ENABLED` | 启用大盘复盘 | `true` |
|
||||
| `MARKET_REVIEW_REGION` | 大盘复盘市场区域:cn(A股)、us(美股)、both(两者),us 适合仅关注美股的用户 | `cn` |
|
||||
| `SCHEDULE_ENABLED` | 启用定时任务 | `false` |
|
||||
| `SCHEDULE_TIME` | 定时执行时间 | `18:00` |
|
||||
| `LOG_DIR` | 日志目录 | `./logs` |
|
||||
|
||||
@@ -209,6 +209,7 @@ Default schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.
|
||||
| `STOCK_LIST` | Watchlist codes (comma-separated) | - |
|
||||
| `MAX_WORKERS` | Concurrent threads | `3` |
|
||||
| `MARKET_REVIEW_ENABLED` | Enable market review | `true` |
|
||||
| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), us (US stocks), both | `cn` |
|
||||
| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |
|
||||
| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |
|
||||
| `LOG_DIR` | Log directory | `./logs` |
|
||||
|
||||
@@ -193,6 +193,8 @@ class Config:
|
||||
schedule_time: str = "18:00" # 每日推送时间(HH:MM 格式)
|
||||
schedule_run_immediately: bool = True # 启动时是否立即执行一次
|
||||
market_review_enabled: bool = True # 是否启用大盘复盘
|
||||
# 大盘复盘市场区域:cn(A股)、us(美股)、both(两者),us 适合仅关注美股的用户
|
||||
market_review_region: str = "cn"
|
||||
|
||||
# === 实时行情增强数据配置 ===
|
||||
# 实时行情开关(关闭后使用历史收盘价进行分析)
|
||||
@@ -451,6 +453,9 @@ class Config:
|
||||
schedule_time=os.getenv('SCHEDULE_TIME', '18:00'),
|
||||
schedule_run_immediately=os.getenv('SCHEDULE_RUN_IMMEDIATELY', 'true').lower() == 'true',
|
||||
market_review_enabled=os.getenv('MARKET_REVIEW_ENABLED', 'true').lower() == 'true',
|
||||
market_review_region=cls._parse_market_review_region(
|
||||
os.getenv('MARKET_REVIEW_REGION', 'cn')
|
||||
),
|
||||
webui_enabled=os.getenv('WEBUI_ENABLED', 'false').lower() == 'true',
|
||||
webui_host=os.getenv('WEBUI_HOST', '127.0.0.1'),
|
||||
webui_port=int(os.getenv('WEBUI_PORT', '8000')),
|
||||
@@ -519,6 +524,18 @@ class Config:
|
||||
result.append((g['stocks'], g['emails']))
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _parse_market_review_region(cls, value: str) -> str:
|
||||
"""解析大盘复盘市场区域,非法值记录警告后回退为 cn"""
|
||||
import logging
|
||||
v = (value or 'cn').strip().lower()
|
||||
if v in ('cn', 'us', 'both'):
|
||||
return v
|
||||
logging.getLogger(__name__).warning(
|
||||
f"MARKET_REVIEW_REGION 配置值 '{value}' 无效,已回退为默认值 'cn'(合法值:cn / us / both)"
|
||||
)
|
||||
return 'cn'
|
||||
|
||||
@classmethod
|
||||
def _resolve_realtime_source_priority(cls) -> str:
|
||||
"""
|
||||
|
||||
61
src/core/market_profile.py
Normal file
61
src/core/market_profile.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
大盘复盘市场区域配置
|
||||
|
||||
定义各市场区域的指数、新闻搜索词、Prompt 提示等元数据,
|
||||
供 MarketAnalyzer 按 region 切换 A 股/美股复盘行为。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketProfile:
|
||||
"""大盘复盘市场区域配置"""
|
||||
|
||||
region: str # "cn" | "us"
|
||||
# 用于判断整体走势的指数代码,cn 用上证 000001,us 用标普 SPX
|
||||
mood_index_code: str
|
||||
# 新闻搜索关键词
|
||||
news_queries: List[str]
|
||||
# 指数点评 Prompt 提示语
|
||||
prompt_index_hint: str
|
||||
# 市场概况是否包含涨跌家数、涨停跌停(A 股有,美股无)
|
||||
has_market_stats: bool
|
||||
# 市场概况是否包含板块涨跌(A 股有,美股暂无)
|
||||
has_sector_rankings: bool
|
||||
|
||||
|
||||
CN_PROFILE = MarketProfile(
|
||||
region="cn",
|
||||
mood_index_code="000001",
|
||||
news_queries=[
|
||||
"A股 大盘 复盘",
|
||||
"股市 行情 分析",
|
||||
"A股 市场 热点 板块",
|
||||
],
|
||||
prompt_index_hint="分析上证、深证、创业板等各指数走势特点",
|
||||
has_market_stats=True,
|
||||
has_sector_rankings=True,
|
||||
)
|
||||
|
||||
US_PROFILE = MarketProfile(
|
||||
region="us",
|
||||
mood_index_code="SPX",
|
||||
news_queries=[
|
||||
"美股 大盘",
|
||||
"US stock market",
|
||||
"S&P 500 NASDAQ",
|
||||
],
|
||||
prompt_index_hint="分析标普500、纳斯达克、道指等各指数走势特点",
|
||||
has_market_stats=False,
|
||||
has_sector_rankings=False,
|
||||
)
|
||||
|
||||
|
||||
def get_profile(region: str) -> MarketProfile:
|
||||
"""根据 region 返回对应的 MarketProfile"""
|
||||
if region == "us":
|
||||
return US_PROFILE
|
||||
return CN_PROFILE
|
||||
@@ -1,12 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
A股自选股智能分析系统 - 大盘复盘模块
|
||||
股票智能分析系统 - 大盘复盘模块(支持 A 股 / 美股)
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 执行大盘复盘分析
|
||||
2. 生成复盘报告
|
||||
1. 根据 MARKET_REVIEW_REGION 配置选择市场区域(cn / us / both)
|
||||
2. 执行大盘复盘分析并生成复盘报告
|
||||
3. 保存和发送复盘报告
|
||||
"""
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from src.config import get_config
|
||||
from src.notification import NotificationService
|
||||
from src.market_analyzer import MarketAnalyzer
|
||||
from src.search_service import SearchService
|
||||
@@ -44,14 +45,39 @@ def run_market_review(
|
||||
复盘报告文本
|
||||
"""
|
||||
logger.info("开始执行大盘复盘分析...")
|
||||
config = get_config()
|
||||
region = getattr(config, 'market_review_region', 'cn') or 'cn'
|
||||
if region not in ('cn', 'us', 'both'):
|
||||
region = 'cn'
|
||||
|
||||
try:
|
||||
if region == 'both':
|
||||
# 顺序执行 A 股 + 美股,合并报告
|
||||
cn_analyzer = MarketAnalyzer(
|
||||
search_service=search_service, analyzer=analyzer, region='cn'
|
||||
)
|
||||
us_analyzer = MarketAnalyzer(
|
||||
search_service=search_service, analyzer=analyzer, region='us'
|
||||
)
|
||||
logger.info("生成 A 股大盘复盘报告...")
|
||||
cn_report = cn_analyzer.run_daily_review()
|
||||
logger.info("生成美股大盘复盘报告...")
|
||||
us_report = us_analyzer.run_daily_review()
|
||||
review_report = ''
|
||||
if cn_report:
|
||||
review_report = f"# A股大盘复盘\n\n{cn_report}"
|
||||
if us_report:
|
||||
if review_report:
|
||||
review_report += "\n\n---\n\n> 以下为美股大盘复盘\n\n"
|
||||
review_report += f"# 美股大盘复盘\n\n{us_report}"
|
||||
if not review_report:
|
||||
review_report = None
|
||||
else:
|
||||
market_analyzer = MarketAnalyzer(
|
||||
search_service=search_service,
|
||||
analyzer=analyzer
|
||||
analyzer=analyzer,
|
||||
region=region,
|
||||
)
|
||||
|
||||
# 执行复盘
|
||||
review_report = market_analyzer.run_daily_review()
|
||||
|
||||
if review_report:
|
||||
|
||||
@@ -20,6 +20,7 @@ import pandas as pd
|
||||
|
||||
from src.config import get_config
|
||||
from src.search_service import SearchService
|
||||
from src.core.market_profile import get_profile, MarketProfile
|
||||
from data_provider.base import DataFetcherManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -87,18 +88,26 @@ class MarketAnalyzer:
|
||||
5. 生成大盘复盘报告
|
||||
"""
|
||||
|
||||
def __init__(self, search_service: Optional[SearchService] = None, analyzer=None):
|
||||
def __init__(
|
||||
self,
|
||||
search_service: Optional[SearchService] = None,
|
||||
analyzer=None,
|
||||
region: str = "cn",
|
||||
):
|
||||
"""
|
||||
初始化大盘分析器
|
||||
|
||||
Args:
|
||||
search_service: 搜索服务实例
|
||||
analyzer: AI分析器实例(用于调用LLM)
|
||||
region: 市场区域 cn=A股 us=美股
|
||||
"""
|
||||
self.config = get_config()
|
||||
self.search_service = search_service
|
||||
self.analyzer = analyzer
|
||||
self.data_manager = DataFetcherManager()
|
||||
self.region = region if region in ("cn", "us") else "cn"
|
||||
self.profile: MarketProfile = get_profile(self.region)
|
||||
|
||||
def get_market_overview(self) -> MarketOverview:
|
||||
"""
|
||||
@@ -110,13 +119,15 @@ class MarketAnalyzer:
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
overview = MarketOverview(date=today)
|
||||
|
||||
# 1. 获取主要指数行情
|
||||
# 1. 获取主要指数行情(按 region 切换 A 股/美股)
|
||||
overview.indices = self._get_main_indices()
|
||||
|
||||
# 2. 获取涨跌统计
|
||||
# 2. 获取涨跌统计(A 股有,美股无等效数据)
|
||||
if self.profile.has_market_stats:
|
||||
self._get_market_statistics(overview)
|
||||
|
||||
# 3. 获取板块涨跌榜
|
||||
# 3. 获取板块涨跌榜(A 股有,美股暂无)
|
||||
if self.profile.has_sector_rankings:
|
||||
self._get_sector_rankings(overview)
|
||||
|
||||
# 4. 获取北向资金(可选)
|
||||
@@ -132,9 +143,8 @@ class MarketAnalyzer:
|
||||
try:
|
||||
logger.info("[大盘] 获取主要指数实时行情...")
|
||||
|
||||
# 使用 DataFetcherManager 获取指数行情
|
||||
# Manager 会自动尝试:Akshare -> Tushare -> Yfinance
|
||||
data_list = self.data_manager.get_main_indices()
|
||||
# 使用 DataFetcherManager 获取指数行情(按 region 切换)
|
||||
data_list = self.data_manager.get_main_indices(region=self.region)
|
||||
|
||||
if data_list:
|
||||
for item in data_list:
|
||||
@@ -239,21 +249,18 @@ class MarketAnalyzer:
|
||||
today = datetime.now()
|
||||
date_str = today.strftime('%Y年%m月%d日')
|
||||
|
||||
# 多维度搜索
|
||||
search_queries = [
|
||||
"A股 大盘 复盘",
|
||||
"股市 行情 分析",
|
||||
"A股 市场 热点 板块",
|
||||
]
|
||||
# 按 region 使用不同的新闻搜索词
|
||||
search_queries = self.profile.news_queries
|
||||
|
||||
try:
|
||||
logger.info("[大盘] 开始搜索市场新闻...")
|
||||
|
||||
# 根据 region 设置搜索上下文名称,避免美股搜索被解读为 A 股语境
|
||||
market_name = "大盘" if self.region == "cn" else "US market"
|
||||
for query in search_queries:
|
||||
# 使用 search_stock_news 方法,传入"大盘"作为股票名
|
||||
response = self.search_service.search_stock_news(
|
||||
stock_code="market",
|
||||
stock_name="大盘",
|
||||
stock_name=market_name,
|
||||
max_results=3,
|
||||
focus_keywords=query.split()
|
||||
)
|
||||
@@ -374,7 +381,7 @@ class MarketAnalyzer:
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_indices_block(self, overview: MarketOverview) -> str:
|
||||
"""Build indices table block (without amplitude)."""
|
||||
"""构建指数行情表格(不含振幅)"""
|
||||
if not overview.indices:
|
||||
return ""
|
||||
lines = [
|
||||
@@ -383,8 +390,14 @@ class MarketAnalyzer:
|
||||
for idx in overview.indices:
|
||||
arrow = "🔴" if idx.change_pct < 0 else "🟢" if idx.change_pct > 0 else "⚪"
|
||||
amount_raw = idx.amount or 0.0
|
||||
amount_yi = amount_raw / 1e8 if amount_raw > 1e6 else amount_raw
|
||||
lines.append(f"| {idx.name} | {idx.current:.2f} | {arrow} {idx.change_pct:+.2f}% | {amount_yi:.0f} |")
|
||||
if amount_raw == 0.0:
|
||||
# Yahoo Finance 不提供成交额,显示 N/A 避免误解
|
||||
amount_str = "N/A"
|
||||
elif amount_raw > 1e6:
|
||||
amount_str = f"{amount_raw / 1e8:.0f}"
|
||||
else:
|
||||
amount_str = f"{amount_raw:.0f}"
|
||||
lines.append(f"| {idx.name} | {idx.current:.2f} | {arrow} {idx.change_pct:+.2f}% | {amount_str} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_sector_block(self, overview: MarketOverview) -> str:
|
||||
@@ -428,7 +441,113 @@ class MarketAnalyzer:
|
||||
snippet = n.get('snippet', '')[:100]
|
||||
news_text += f"{i}. {title}\n {snippet}\n"
|
||||
|
||||
prompt = f"""你是一位专业的A/H/美股市场分析师,请根据以下数据生成一份简洁的大盘复盘报告。
|
||||
# 按 region 组装市场概况与板块区块(美股无涨跌家数、板块数据)
|
||||
stats_block = ""
|
||||
sector_block = ""
|
||||
if self.region == "us":
|
||||
if self.profile.has_market_stats:
|
||||
stats_block = f"""## Market Overview
|
||||
- Up: {overview.up_count} | Down: {overview.down_count} | Flat: {overview.flat_count}
|
||||
- Limit up: {overview.limit_up_count} | Limit down: {overview.limit_down_count}
|
||||
- Total volume (CNY bn): {overview.total_amount:.0f}"""
|
||||
else:
|
||||
stats_block = "## Market Overview\n(US market has no equivalent advance/decline stats.)"
|
||||
|
||||
if self.profile.has_sector_rankings:
|
||||
sector_block = f"""## Sector Performance
|
||||
Leading: {top_sectors_text if top_sectors_text else "N/A"}
|
||||
Lagging: {bottom_sectors_text if bottom_sectors_text else "N/A"}"""
|
||||
else:
|
||||
sector_block = "## Sector Performance\n(US sector data not available.)"
|
||||
else:
|
||||
if self.profile.has_market_stats:
|
||||
stats_block = f"""## 市场概况
|
||||
- 上涨: {overview.up_count} 家 | 下跌: {overview.down_count} 家 | 平盘: {overview.flat_count} 家
|
||||
- 涨停: {overview.limit_up_count} 家 | 跌停: {overview.limit_down_count} 家
|
||||
- 两市成交额: {overview.total_amount:.0f} 亿元"""
|
||||
else:
|
||||
stats_block = "## 市场概况\n(美股暂无涨跌家数等统计)"
|
||||
|
||||
if self.profile.has_sector_rankings:
|
||||
sector_block = f"""## 板块表现
|
||||
领涨: {top_sectors_text if top_sectors_text else "暂无数据"}
|
||||
领跌: {bottom_sectors_text if bottom_sectors_text else "暂无数据"}"""
|
||||
else:
|
||||
sector_block = "## 板块表现\n(美股暂无板块涨跌数据)"
|
||||
|
||||
data_no_indices_hint = (
|
||||
"注意:由于行情数据获取失败,请主要根据【市场新闻】进行定性分析和总结,不要编造具体的指数点位。"
|
||||
if not indices_text
|
||||
else ""
|
||||
)
|
||||
indices_placeholder = indices_text if indices_text else ("No index data (API error)" if self.region == "us" else "暂无指数数据(接口异常)")
|
||||
news_placeholder = news_text if news_text else ("No relevant news" if self.region == "us" else "暂无相关新闻")
|
||||
|
||||
# 美股场景使用英文提示语,便于生成更符合美股语境的报告
|
||||
if self.region == "us":
|
||||
data_no_indices_hint_en = (
|
||||
"Note: Market data fetch failed. Rely mainly on [Market News] for qualitative analysis. Do not invent index levels."
|
||||
if not indices_text
|
||||
else ""
|
||||
)
|
||||
return f"""You are a professional US/A/H market analyst. Please produce a concise US market recap report based on the data below.
|
||||
|
||||
[Requirements]
|
||||
- Output pure Markdown only
|
||||
- No JSON
|
||||
- No code blocks
|
||||
- Use emoji sparingly in headings (at most one per heading)
|
||||
|
||||
---
|
||||
|
||||
# Today's Market Data
|
||||
|
||||
## Date
|
||||
{overview.date}
|
||||
|
||||
## Major Indices
|
||||
{indices_placeholder}
|
||||
|
||||
{stats_block}
|
||||
|
||||
{sector_block}
|
||||
|
||||
## Market News
|
||||
{news_placeholder}
|
||||
|
||||
{data_no_indices_hint_en}
|
||||
|
||||
---
|
||||
|
||||
# Output Template (follow this structure)
|
||||
|
||||
## {overview.date} US Market Recap
|
||||
|
||||
### 1. Market Summary
|
||||
(2-3 sentences on overall market performance, index moves, volume)
|
||||
|
||||
### 2. Index Commentary
|
||||
(Analyse S&P 500, Nasdaq, Dow and other major index moves.)
|
||||
|
||||
### 3. Fund Flows
|
||||
(Interpret volume and flow implications)
|
||||
|
||||
### 4. Sector/Theme Highlights
|
||||
(Analyze drivers behind leading/lagging sectors)
|
||||
|
||||
### 5. Outlook
|
||||
(Short-term view based on price action and news)
|
||||
|
||||
### 6. Risk Alerts
|
||||
(Key risks to watch)
|
||||
|
||||
---
|
||||
|
||||
Output the report content directly, no extra commentary.
|
||||
"""
|
||||
|
||||
# A 股场景使用中文提示语
|
||||
return f"""你是一位专业的A/H/美股市场分析师,请根据以下数据生成一份简洁的大盘复盘报告。
|
||||
|
||||
【重要】输出要求:
|
||||
- 必须输出纯 Markdown 文本格式
|
||||
@@ -444,33 +563,28 @@ class MarketAnalyzer:
|
||||
{overview.date}
|
||||
|
||||
## 主要指数
|
||||
{indices_text if indices_text else "暂无指数数据(接口异常)"}
|
||||
{indices_placeholder}
|
||||
|
||||
## 市场概况
|
||||
- 上涨: {overview.up_count} 家 | 下跌: {overview.down_count} 家 | 平盘: {overview.flat_count} 家
|
||||
- 涨停: {overview.limit_up_count} 家 | 跌停: {overview.limit_down_count} 家
|
||||
- 两市成交额: {overview.total_amount:.0f} 亿元
|
||||
{stats_block}
|
||||
|
||||
## 板块表现
|
||||
领涨: {top_sectors_text if top_sectors_text else "暂无数据"}
|
||||
领跌: {bottom_sectors_text if bottom_sectors_text else "暂无数据"}
|
||||
{sector_block}
|
||||
|
||||
## 市场新闻
|
||||
{news_text if news_text else "暂无相关新闻"}
|
||||
{news_placeholder}
|
||||
|
||||
{"注意:由于行情数据获取失败,请主要根据【市场新闻】进行定性分析和总结,不要编造具体的指数点位。" if not indices_text else ""}
|
||||
{data_no_indices_hint}
|
||||
|
||||
---
|
||||
|
||||
# 输出格式模板(请严格按此格式输出)
|
||||
|
||||
## 📊 {overview.date} 大盘复盘
|
||||
## {overview.date} 大盘复盘
|
||||
|
||||
### 一、市场总结
|
||||
(2-3句话概括今日市场整体表现,包括指数涨跌、成交量变化)
|
||||
|
||||
### 二、指数点评
|
||||
(分析上证、深证、创业板等各指数走势特点)
|
||||
({self.profile.prompt_index_hint})
|
||||
|
||||
### 三、资金动向
|
||||
(解读成交额流向的含义)
|
||||
@@ -488,19 +602,27 @@ class MarketAnalyzer:
|
||||
|
||||
请直接输出复盘报告内容,不要输出其他说明文字。
|
||||
"""
|
||||
return prompt
|
||||
|
||||
def _generate_template_review(self, overview: MarketOverview, news: List) -> str:
|
||||
"""使用模板生成复盘报告(无大模型时的备选方案)"""
|
||||
|
||||
# 判断市场走势
|
||||
sh_index = next((idx for idx in overview.indices if idx.code == '000001'), None)
|
||||
if sh_index:
|
||||
if sh_index.change_pct > 1:
|
||||
mood_code = self.profile.mood_index_code
|
||||
# 根据 mood_index_code 查找对应指数
|
||||
# cn: mood_code="000001",idx.code 可能为 "sh000001"(以 mood_code 结尾)
|
||||
# us: mood_code="SPX",idx.code 直接为 "SPX"
|
||||
mood_index = next(
|
||||
(
|
||||
idx
|
||||
for idx in overview.indices
|
||||
if idx.code == mood_code or idx.code.endswith(mood_code)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mood_index:
|
||||
if mood_index.change_pct > 1:
|
||||
market_mood = "强势上涨"
|
||||
elif sh_index.change_pct > 0:
|
||||
elif mood_index.change_pct > 0:
|
||||
market_mood = "小幅上涨"
|
||||
elif sh_index.change_pct > -1:
|
||||
elif mood_index.change_pct > -1:
|
||||
market_mood = "小幅下跌"
|
||||
else:
|
||||
market_mood = "明显下跌"
|
||||
@@ -517,14 +639,10 @@ class MarketAnalyzer:
|
||||
top_text = "、".join([s['name'] for s in overview.top_sectors[:3]])
|
||||
bottom_text = "、".join([s['name'] for s in overview.bottom_sectors[:3]])
|
||||
|
||||
report = f"""## 📊 {overview.date} 大盘复盘
|
||||
|
||||
### 一、市场总结
|
||||
今日A股市场整体呈现**{market_mood}**态势。
|
||||
|
||||
### 二、主要指数
|
||||
{indices_text}
|
||||
|
||||
# 按 region 决定是否包含涨跌统计和板块(美股无)
|
||||
stats_section = ""
|
||||
if self.profile.has_market_stats:
|
||||
stats_section = f"""
|
||||
### 三、涨跌统计
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
@@ -533,11 +651,24 @@ class MarketAnalyzer:
|
||||
| 涨停 | {overview.limit_up_count} |
|
||||
| 跌停 | {overview.limit_down_count} |
|
||||
| 两市成交额 | {overview.total_amount:.0f}亿 |
|
||||
|
||||
"""
|
||||
sector_section = ""
|
||||
if self.profile.has_sector_rankings and (top_text or bottom_text):
|
||||
sector_section = f"""
|
||||
### 四、板块表现
|
||||
- **领涨**: {top_text}
|
||||
- **领跌**: {bottom_text}
|
||||
"""
|
||||
market_label = "A股" if self.region == "cn" else "美股"
|
||||
report = f"""## {overview.date} 大盘复盘
|
||||
|
||||
### 一、市场总结
|
||||
今日{market_label}市场整体呈现**{market_mood}**态势。
|
||||
|
||||
### 二、主要指数
|
||||
{indices_text}
|
||||
{stats_section}
|
||||
{sector_section}
|
||||
### 五、风险提示
|
||||
市场有风险,投资需谨慎。以上数据仅供参考,不构成投资建议。
|
||||
|
||||
|
||||
197
tests/test_yfinance_us_indices.py
Normal file
197
tests/test_yfinance_us_indices.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
data_provider/yfinance_fetcher 中美股指数获取逻辑的单元测试
|
||||
|
||||
使用 unittest.mock 模拟 yfinance API 响应,覆盖:
|
||||
- _fetch_yf_ticker_data 单指数数据解析
|
||||
- _get_us_main_indices 美股指数批量获取及异常场景
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pandas as pd
|
||||
|
||||
# 在导入 data_provider 前 mock 可能缺失的依赖,避免环境差异导致测试无法运行
|
||||
if 'fake_useragent' not in sys.modules:
|
||||
sys.modules['fake_useragent'] = MagicMock()
|
||||
|
||||
# 确保能导入项目模块
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
|
||||
def _make_mock_hist(close: float, prev_close: float, high: float = None, low: float = None) -> pd.DataFrame:
|
||||
"""构造模拟的 history DataFrame,包含计算涨跌幅所需字段"""
|
||||
high = high if high is not None else close + 1
|
||||
low = low if low is not None else close - 1
|
||||
return pd.DataFrame({
|
||||
'Close': [prev_close, close],
|
||||
'Open': [prev_close - 0.5, close - 0.3],
|
||||
'High': [prev_close + 1, high],
|
||||
'Low': [prev_close - 1, low],
|
||||
'Volume': [1000000.0, 1200000.0],
|
||||
}, index=pd.DatetimeIndex(['2025-02-16', '2025-02-17']))
|
||||
|
||||
|
||||
def _make_mock_yf(hist_df: pd.DataFrame):
|
||||
"""构造模拟的 yf 模块,Ticker().history() 返回给定 DataFrame"""
|
||||
mock_ticker = MagicMock()
|
||||
mock_ticker.history.return_value = hist_df
|
||||
mock_yf = MagicMock()
|
||||
mock_yf.Ticker.return_value = mock_ticker
|
||||
return mock_yf
|
||||
|
||||
|
||||
class TestFetchYfTickerData(unittest.TestCase):
|
||||
"""_fetch_yf_ticker_data 单指数取数逻辑测试"""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.yfinance_fetcher import YfinanceFetcher
|
||||
self.fetcher = YfinanceFetcher()
|
||||
|
||||
def test_returns_dict_with_correct_fields(self):
|
||||
"""正常数据应返回包含 code/name/current/change_pct 等字段的字典"""
|
||||
mock_hist = _make_mock_hist(close=5100.0, prev_close=5000.0)
|
||||
mock_yf = _make_mock_yf(mock_hist)
|
||||
|
||||
result = self.fetcher._fetch_yf_ticker_data(mock_yf, '^GSPC', '标普500指数', 'SPX')
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result['code'], 'SPX')
|
||||
self.assertEqual(result['name'], '标普500指数')
|
||||
self.assertEqual(result['current'], 5100.0)
|
||||
self.assertEqual(result['prev_close'], 5000.0)
|
||||
self.assertEqual(result['change'], 100.0)
|
||||
self.assertAlmostEqual(result['change_pct'], 2.0)
|
||||
self.assertIn('open', result)
|
||||
self.assertIn('high', result)
|
||||
self.assertIn('low', result)
|
||||
self.assertIn('volume', result)
|
||||
self.assertIn('amount', result)
|
||||
self.assertIn('amplitude', result)
|
||||
|
||||
def test_returns_none_when_history_empty(self):
|
||||
"""history 为空时应返回 None"""
|
||||
mock_yf = _make_mock_yf(pd.DataFrame())
|
||||
|
||||
result = self.fetcher._fetch_yf_ticker_data(mock_yf, '^GSPC', '标普500指数', 'SPX')
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_single_row_history_uses_same_as_prev(self):
|
||||
"""仅一行数据时 prev_close 等于 current,change_pct 为 0"""
|
||||
mock_hist = _make_mock_hist(close=5000.0, prev_close=5000.0)
|
||||
mock_hist = mock_hist.iloc[[-1]]
|
||||
mock_yf = _make_mock_yf(mock_hist)
|
||||
|
||||
result = self.fetcher._fetch_yf_ticker_data(mock_yf, '^GSPC', '标普500指数', 'SPX')
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result['change_pct'], 0.0)
|
||||
|
||||
|
||||
class TestGetUsMainIndices(unittest.TestCase):
|
||||
"""_get_us_main_indices 美股指数批量获取测试"""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.yfinance_fetcher import YfinanceFetcher
|
||||
self.fetcher = YfinanceFetcher()
|
||||
|
||||
@patch('data_provider.yfinance_fetcher.get_us_index_yf_symbol')
|
||||
def test_returns_list_when_mock_succeeds(self, mock_get_symbol):
|
||||
"""当映射与取数均成功时返回指数列表"""
|
||||
def get_symbol(code):
|
||||
mapping = {
|
||||
'SPX': ('^GSPC', '标普500指数'),
|
||||
'IXIC': ('^IXIC', '纳斯达克综合指数'),
|
||||
'DJI': ('^DJI', '道琼斯工业指数'),
|
||||
'VIX': ('^VIX', 'VIX恐慌指数'),
|
||||
}
|
||||
return mapping.get(code, (None, None))
|
||||
|
||||
mock_get_symbol.side_effect = get_symbol
|
||||
mock_hist = _make_mock_hist(close=5100.0, prev_close=5000.0)
|
||||
mock_yf = _make_mock_yf(mock_hist)
|
||||
|
||||
result = self.fetcher._get_us_main_indices(mock_yf)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIsInstance(result, list)
|
||||
self.assertGreaterEqual(len(result), 1)
|
||||
for item in result:
|
||||
self.assertIn('code', item)
|
||||
self.assertIn('name', item)
|
||||
self.assertIn('current', item)
|
||||
self.assertIn('change_pct', item)
|
||||
|
||||
@patch('data_provider.yfinance_fetcher.get_us_index_yf_symbol')
|
||||
def test_handles_empty_history_gracefully(self, mock_get_symbol):
|
||||
"""部分指数 history 为空时仍返回能取到数据的指数"""
|
||||
call_count = [0]
|
||||
|
||||
def get_symbol(code):
|
||||
return ('^GSPC', '标普500指数') if code == 'SPX' else (
|
||||
('^IXIC', '纳斯达克综合指数') if code == 'IXIC' else (None, None)
|
||||
)
|
||||
|
||||
def history_side_effect(period):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return _make_mock_hist(close=5100.0, prev_close=5000.0)
|
||||
return pd.DataFrame()
|
||||
|
||||
mock_get_symbol.side_effect = get_symbol
|
||||
mock_ticker = MagicMock()
|
||||
mock_ticker.history.side_effect = history_side_effect
|
||||
mock_yf = MagicMock()
|
||||
mock_yf.Ticker.return_value = mock_ticker
|
||||
|
||||
result = self.fetcher._get_us_main_indices(mock_yf)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIsInstance(result, list)
|
||||
|
||||
@patch('data_provider.yfinance_fetcher.get_us_index_yf_symbol')
|
||||
def test_returns_none_when_all_fail(self, mock_get_symbol):
|
||||
"""全部取数失败时返回 None"""
|
||||
mock_get_symbol.return_value = (None, None)
|
||||
mock_yf = _make_mock_yf(pd.DataFrame())
|
||||
|
||||
result = self.fetcher._get_us_main_indices(mock_yf)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch('data_provider.yfinance_fetcher.get_us_index_yf_symbol')
|
||||
def test_handles_ticker_exception(self, mock_get_symbol):
|
||||
"""Ticker.history 抛异常时跳过该指数,不整体失败"""
|
||||
mock_get_symbol.return_value = ('^GSPC', '标普500指数')
|
||||
mock_ticker = MagicMock()
|
||||
mock_ticker.history.side_effect = Exception("Network error")
|
||||
mock_yf = MagicMock()
|
||||
mock_yf.Ticker.return_value = mock_ticker
|
||||
|
||||
result = self.fetcher._get_us_main_indices(mock_yf)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch('data_provider.yfinance_fetcher.get_us_index_yf_symbol')
|
||||
def test_skips_unknown_index_code(self, mock_get_symbol):
|
||||
"""get_us_index_yf_symbol 返回 (None, None) 的代码应被跳过"""
|
||||
def get_symbol(code):
|
||||
if code == 'SPX':
|
||||
return ('^GSPC', '标普500指数')
|
||||
return (None, None)
|
||||
|
||||
mock_get_symbol.side_effect = get_symbol
|
||||
mock_hist = _make_mock_hist(close=5100.0, prev_close=5000.0)
|
||||
mock_yf = _make_mock_yf(mock_hist)
|
||||
|
||||
result = self.fetcher._get_us_main_indices(mock_yf)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['code'], 'SPX')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user