Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions data_provider/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,16 @@ class DataFetcherManager:
- 失败后自动切换到下一个
- 所有数据源都失败时抛出异常
"""

_DAILY_MARKET_FETCHER_SUPPORT = {
"EfinanceFetcher": {"cn"},
"AkshareFetcher": {"cn", "hk"},
"TushareFetcher": {"cn", "hk"},
"PytdxFetcher": {"cn"},
"BaostockFetcher": {"cn"},
"YfinanceFetcher": {"cn", "hk", "us"},
"LongbridgeFetcher": {"hk", "us"},
}

def __init__(self, fetchers: Optional[List[BaseFetcher]] = None):
"""
Expand Down Expand Up @@ -546,6 +556,33 @@ def _call_fetcher_method(self, fetcher: BaseFetcher, method_name: str, *args, **
with self._get_fetcher_call_lock(fetcher):
return method(*args, **kwargs)

@classmethod
def _filter_daily_fetchers_for_market(
cls,
fetchers: List[BaseFetcher],
market: str,
) -> List[BaseFetcher]:
"""Skip built-in daily fetchers that are known not to support a market."""
if market not in {"cn", "hk", "us"}:
return fetchers

kept: List[BaseFetcher] = []
skipped: List[str] = []
for fetcher in fetchers:
supported = cls._DAILY_MARKET_FETCHER_SUPPORT.get(fetcher.name)
if supported is not None and market not in supported:
skipped.append(fetcher.name)
else:
kept.append(fetcher)

if skipped:
logger.info(
"[数据源路由] %s 日线跳过不支持的数据源: %s",
market,
", ".join(skipped),
)
return kept

def _get_cached_stock_name(self, stock_code: str) -> Optional[str]:
self._ensure_concurrency_guards()
with self._stock_name_cache_lock:
Expand Down Expand Up @@ -935,16 +972,18 @@ def get_daily_data(

fetchers = self._get_fetchers_snapshot()
errors = []
total_fetchers = len(fetchers)
request_start = time.time()

# 快速路径:美股/港股使用专用数据源路由
# 快速路径:美股使用专用数据源路由;港股先过滤不支持港股日线的数据源
# - 配置长桥凭据后: Longbridge 为首选, YFinance/AkShare 兜底
# - 未配置长桥: YFinance 为首选(美股), 通用 fetcher 循环(港股)
# - 美股指数: 始终 YFinance 为首选(Longbridge 不提供指数K线)
is_us_index = is_us_index_code(stock_code)
is_us = is_us_index or is_us_stock_code(stock_code)
is_hk = (not is_us) and _is_hk_market(stock_code)
if is_hk:
fetchers = self._filter_daily_fetchers_for_market(fetchers, "hk")
total_fetchers = len(fetchers)

# 美股(含美股指数)使用 Longbridge/YFinance 特殊路由;港股走下方通用数据源循环
if is_us:
Expand Down
7 changes: 6 additions & 1 deletion data_provider/efinance_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@

from patch.eastmoney_patch import eastmoney_patch
from src.config import get_config
from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS,is_bse_code, is_st_stock, is_kc_cy_stock, normalize_stock_code
from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS,is_bse_code, is_st_stock, is_kc_cy_stock, normalize_stock_code, _is_hk_market
from .realtime_types import (
UnifiedRealtimeQuote, RealtimeSource,
get_realtime_circuit_breaker,
Expand Down Expand Up @@ -354,6 +354,11 @@ def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd
# 美股不支持,抛出异常让 DataFetcherManager 切换到 AkshareFetcher/YfinanceFetcher
if _is_us_code(stock_code):
raise DataFetchError(f"EfinanceFetcher 不支持美股 {stock_code},请使用 AkshareFetcher 或 YfinanceFetcher")

# efinance 的历史 K 线接口在港股代码上可能返回非预期市场数据,
# 明确跳过并交给 AkShare/Tushare/YFinance/Longbridge 等港股路径兜底。
if _is_hk_market(stock_code):
raise DataFetchError(f"EfinanceFetcher 不支持港股日线 {stock_code},请使用 AkshareFetcher 或其他港股数据源")

# 根据代码类型选择不同的获取方法
if _is_etf_code(stock_code):
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [改进] 设置项帮助窗口支持键盘焦点限制、Esc 关闭和关闭后焦点恢复,并移除短描述重复 hover tooltip。
- [文档] 新增设置页配置帮助维护说明,明确帮助元数据字段、首批覆盖范围、事实源和多语言文案同步规则。
- [测试] 补充设置项帮助元数据、API schema、前端弹窗交互测试,并修复 Bot 名称路由与调度时间 provider 测试的离线 CI 稳定性问题。
- [修复] 港股日线跳过不支持港股的内置历史数据源,避免港股代码错配到非港股市场数据。

## [3.15.0] - 2026-05-05

Expand Down
2 changes: 2 additions & 0 deletions docs/full-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,8 @@ PUSHOVER_API_TOKEN=your_api_token
STOCK_LIST=600519,hk00700,hk01810
```

港股日线会跳过 efinance、pytdx、baostock 等不支持港股日线的数据源,避免把港股代码错配到非港股市场;默认改由 AkShare/Tushare/YFinance/Longbridge 等港股路径继续兜底。

### ETF 与指数分析

针对指数跟踪型 ETF 和美股指数(如 VOO、QQQ、SPY、510050、SPX、DJI、IXIC),分析仅关注**指数走势、跟踪误差、市场流动性**,不纳入基金管理人/发行方的公司层面风险(诉讼、声誉、高管变动等)。风险警报与业绩预期均基于指数成分股整体表现,避免将基金公司新闻误判为标的本身利空。详见 Issue #274。
Expand Down
2 changes: 2 additions & 0 deletions docs/full-guide_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,8 @@ Use `hk` prefix for HK stock codes:
STOCK_LIST=600519,hk00700,hk01810
```

HK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.

### Multi-Model Switching

Configure multiple models, system auto-switches:
Expand Down
42 changes: 42 additions & 0 deletions tests/test_fetcher_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
return df


class _RecordingFetcher(BaseFetcher):
def __init__(self, name: str, priority: int):
self.name = name
self.priority = priority
self.calls = []

def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
self.calls.append(stock_code)
return _sample_df()

def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
return df


class TestFetcherLogging(unittest.TestCase):
def test_base_fetcher_logs_start_and_success(self):
fetcher = _SuccessFetcher()
Expand Down Expand Up @@ -82,6 +96,34 @@ def test_manager_logs_fallback_and_final_success(self):
self.assertIn("[数据源切换] 601006: [FailureFetcher] -> [SuccessFetcher]", log_text)
self.assertIn("[数据源完成] 601006 使用 [SuccessFetcher] 获取成功:", log_text)

def test_manager_skips_builtin_fetchers_that_do_not_support_hk_daily(self):
efinance = _RecordingFetcher("EfinanceFetcher", 0)
pytdx = _RecordingFetcher("PytdxFetcher", 1)
akshare = _RecordingFetcher("AkshareFetcher", 2)
yfinance = _RecordingFetcher("YfinanceFetcher", 3)

manager = DataFetcherManager(fetchers=[efinance, pytdx, akshare, yfinance])
df, source = manager.get_daily_data("1211.HK", start_date="2026-05-01", end_date="2026-05-08")

self.assertFalse(df.empty)
self.assertEqual(source, "AkshareFetcher")
self.assertEqual(efinance.calls, [])
self.assertEqual(pytdx.calls, [])
self.assertEqual(akshare.calls, ["HK01211"])
self.assertEqual(yfinance.calls, [])

@patch("data_provider.efinance_fetcher.get_config")
def test_efinance_rejects_hk_daily_without_calling_eastmoney(self, mock_get_config):
mock_get_config.return_value = types.SimpleNamespace(enable_eastmoney_patch=False)
fetcher = EfinanceFetcher(sleep_min=0, sleep_max=0)

with patch.object(fetcher, "_fetch_stock_data") as mock_fetch_stock_data:
with self.assertRaises(DataFetchError) as captured:
fetcher.get_daily_data("1211.HK", start_date="2026-05-01", end_date="2026-05-08")

mock_fetch_stock_data.assert_not_called()
self.assertIn("不支持港股日线", str(captured.exception))

def test_efinance_logs_eastmoney_endpoint_on_remote_disconnect(self):
fetcher = EfinanceFetcher()
fake_efinance = types.SimpleNamespace(
Expand Down
Loading