feat: 大盘复盘可选除 A 股外的其它大盘(Issue #299) (#348)

* 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:
Alfred
2026-02-19 11:45:47 +08:00
committed by GitHub
parent 9955b8ab52
commit 88fdaf7933
16 changed files with 610 additions and 115 deletions

View File

@@ -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
# ===================================
# 代理配置(可选)

View File

@@ -33,7 +33,7 @@
| AI | 决策仪表盘 | 一句话核心结论 + 精确买卖点位 + 操作检查清单 |
| 分析 | 多维度分析 | 技术面 + 筹码分布 + 舆情情报 + 实时行情 |
| 市场 | 全球市场 | 支持 A股、港股、美股及美股指数SPX、DJI、IXIC 等) |
| 复盘 | 大盘复盘 | 每日市场概览、板块涨跌、北向资金 |
| 复盘 | 大盘复盘 | 每日市场概览、板块涨跌;支持 cn(A股)/us(美股)/both(两者) 切换 |
| 图片识别 | 从图片添加 | 上传自选股截图Vision LLM 自动提取股票代码,一键加入监控 |
| 回测 | AI 回测验证 | 自动评估历史分析准确率,方向胜率、止盈止损命中率 |
| 推送 | 多渠道通知 | 企业微信、飞书、Telegram、钉钉、邮件、Pushover |

View File

@@ -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()

View File

@@ -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
# 主要指数代码映射

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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

View File

@@ -37,6 +37,10 @@
- 新增 `AI_REVIEW_STRICT` 开关,可选将 AI 审查失败升级为阻断
### 新增
- **大盘复盘可选区域** (Issue #299)
- 支持 `MARKET_REVIEW_REGION` 环境变量cnA股、us美股、both两者
- us 模式适合仅关注美股的用户,使用 SPX/纳斯达克/道指/VIX 等指数both 模式可同时复盘 A 股与美股
- 默认 cn保持向后兼容
- 📊 **仅分析结果摘要** (Issue #262)
- 支持 `REPORT_SUMMARY_ONLY` 环境变量,设为 `true` 时只推送汇总,不含个股详情
- 默认 `false`,多股时适合快速浏览

View File

@@ -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` |

View File

@@ -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` |

View File

@@ -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:
"""

View 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 用上证 000001us 用标普 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

View File

@@ -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:

View File

@@ -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}
### 五、风险提示
市场有风险,投资需谨慎。以上数据仅供参考,不构成投资建议。

View 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 等于 currentchange_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()