Files
daily_stock_analysis/tests/test_yfinance_us_indices.py
Alfred 88fdaf7933 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' 回退
2026-02-19 11:45:47 +08:00

198 lines
7.4 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.

# -*- 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()