Skip to content

Commit 73f58dd

Browse files
committed
Fix HK historical data provider routing
1 parent 8816421 commit 73f58dd

9 files changed

Lines changed: 139 additions & 2 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,9 @@ ADMIN_AUTH_ENABLED=false
622622
# Lower number = higher priority (tried first)
623623
# Default priorities: efinance(0) > akshare(1) > tushare/pytdx(2) > baostock(3) > yfinance(4)
624624
# For US stocks, set YFINANCE_PRIORITY=0 to use Yahoo Finance first
625+
# To temporarily remove a historical data provider from failover, use comma-separated ids:
626+
# DISABLED_DATA_PROVIDERS=efinance
627+
# Supported ids: efinance, akshare, tushare, pytdx, baostock, yfinance, longbridge
625628

626629
# EFINANCE_PRIORITY=0 # EastMoney (China) - default: 0
627630
# EFINANCE_CALL_TIMEOUT=30 # Timeout (seconds) for efinance API calls; prevents indefinite hangs

data_provider/base.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import logging
1818
import random
19+
import re
1920
import time
2021
from threading import BoundedSemaphore, RLock, Thread
2122
from abc import ABC, abstractmethod
@@ -36,6 +37,23 @@
3637
STANDARD_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume', 'amount', 'pct_chg']
3738

3839

40+
_DATA_PROVIDER_ALIASES = {
41+
"eastmoney": "efinance",
42+
"em": "efinance",
43+
"yahoo": "yfinance",
44+
"yahoofinance": "yfinance",
45+
"longbridgeopenapi": "longbridge",
46+
}
47+
48+
49+
def normalize_data_provider_id(value: str) -> str:
50+
"""Normalize user-facing provider names to stable fetcher ids."""
51+
normalized = re.sub(r"[^a-z0-9]", "", (value or "").strip().lower())
52+
if normalized.endswith("fetcher"):
53+
normalized = normalized[:-7]
54+
return _DATA_PROVIDER_ALIASES.get(normalized, normalized)
55+
56+
3957
def unwrap_exception(exc: Exception) -> Exception:
4058
"""
4159
Follow chained exceptions and return the deepest non-cyclic cause.
@@ -546,6 +564,40 @@ def _call_fetcher_method(self, fetcher: BaseFetcher, method_name: str, *args, **
546564
with self._get_fetcher_call_lock(fetcher):
547565
return method(*args, **kwargs)
548566

567+
@staticmethod
568+
def _filter_disabled_fetchers(
569+
fetchers: List[BaseFetcher],
570+
disabled_provider_ids: Optional[List[str]],
571+
) -> List[BaseFetcher]:
572+
"""Remove fetchers disabled through DISABLED_DATA_PROVIDERS."""
573+
disabled = {
574+
normalize_data_provider_id(provider)
575+
for provider in (disabled_provider_ids or [])
576+
if normalize_data_provider_id(provider)
577+
}
578+
if not disabled:
579+
return fetchers
580+
581+
kept: List[BaseFetcher] = []
582+
skipped: List[str] = []
583+
known = set()
584+
for fetcher in fetchers:
585+
provider_id = normalize_data_provider_id(fetcher.name)
586+
known.add(provider_id)
587+
if provider_id in disabled:
588+
skipped.append(fetcher.name)
589+
continue
590+
kept.append(fetcher)
591+
592+
unknown = sorted(disabled - known)
593+
if skipped:
594+
logger.info("已禁用数据源: %s", ", ".join(skipped))
595+
if unknown:
596+
logger.warning("DISABLED_DATA_PROVIDERS 包含未知数据源: %s", ", ".join(unknown))
597+
if not kept:
598+
logger.warning("所有默认数据源均已被禁用,后续数据请求将失败")
599+
return kept
600+
549601
def _get_cached_stock_name(self, stock_code: str) -> Optional[str]:
550602
self._ensure_concurrency_guards()
551603
with self._stock_name_cache_lock:
@@ -863,6 +915,7 @@ def _init_default_fetchers(self) -> None:
863915
from .baostock_fetcher import BaostockFetcher
864916
from .yfinance_fetcher import YfinanceFetcher
865917
from .longbridge_fetcher import LongbridgeFetcher
918+
from src.config import get_config
866919
# 创建所有数据源实例(优先级在各 Fetcher 的 __init__ 中确定)
867920
efinance = EfinanceFetcher()
868921
akshare = AkshareFetcher()
@@ -871,11 +924,12 @@ def _init_default_fetchers(self) -> None:
871924
baostock = BaostockFetcher()
872925
yfinance = YfinanceFetcher()
873926
longbridge = LongbridgeFetcher() # 长桥(美股/港股兜底,懒加载)
927+
disabled_data_providers = getattr(get_config(), "disabled_data_providers", [])
874928

875929
# 初始化数据源列表
876930
self._ensure_concurrency_guards()
877931
with self._fetchers_lock:
878-
self._fetchers = [
932+
fetchers = [
879933
efinance,
880934
akshare,
881935
tushare,
@@ -884,6 +938,10 @@ def _init_default_fetchers(self) -> None:
884938
yfinance,
885939
longbridge,
886940
]
941+
self._fetchers = self._filter_disabled_fetchers(
942+
fetchers,
943+
disabled_data_providers,
944+
)
887945

888946
# 按优先级排序(Tushare 如果配置了 Token 且初始化成功,优先级为 0)
889947
self._fetchers.sort(key=lambda f: f.priority)

data_provider/efinance_fetcher.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353

5454
from patch.eastmoney_patch import eastmoney_patch
5555
from src.config import get_config
56-
from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS,is_bse_code, is_st_stock, is_kc_cy_stock, normalize_stock_code
56+
from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS,is_bse_code, is_st_stock, is_kc_cy_stock, normalize_stock_code, _is_hk_market
5757
from .realtime_types import (
5858
UnifiedRealtimeQuote, RealtimeSource,
5959
get_realtime_circuit_breaker,
@@ -354,6 +354,11 @@ def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd
354354
# 美股不支持,抛出异常让 DataFetcherManager 切换到 AkshareFetcher/YfinanceFetcher
355355
if _is_us_code(stock_code):
356356
raise DataFetchError(f"EfinanceFetcher 不支持美股 {stock_code},请使用 AkshareFetcher 或 YfinanceFetcher")
357+
358+
# efinance 的历史 K 线接口在港股代码上可能返回非预期市场数据,
359+
# 明确跳过并交给 AkShare/Tushare/YFinance/Longbridge 等港股路径兜底。
360+
if _is_hk_market(stock_code):
361+
raise DataFetchError(f"EfinanceFetcher 不支持港股日线 {stock_code},请使用 AkshareFetcher 或其他港股数据源")
357362

358363
# 根据代码类型选择不同的获取方法
359364
if _is_etf_code(stock_code):

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
2222
- [改进] 设置项帮助窗口支持键盘焦点限制、Esc 关闭和关闭后焦点恢复,并移除短描述重复 hover tooltip。
2323
- [文档] 新增设置页配置帮助维护说明,明确帮助元数据字段、首批覆盖范围、事实源和多语言文案同步规则。
2424
- [测试] 补充设置项帮助元数据、API schema、前端弹窗交互测试,并修复 Bot 名称路由与调度时间 provider 测试的离线 CI 稳定性问题。
25+
- [修复] 港股日线跳过 efinance 东财历史 K 线接口,并新增 `DISABLED_DATA_PROVIDERS` 以便临时禁用异常历史数据源。
2526

2627
## [3.15.0] - 2026-05-05
2728

docs/full-guide.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,14 @@ PUSHOVER_API_TOKEN=your_api_token
893893
2. 将 `MAX_WORKERS=1` 降低并发
894894
3. 若已配置 Tushare,可优先使用 Tushare 数据源
895895

896+
如某个历史数据源持续返回错误或疑似错配市场,可用 `DISABLED_DATA_PROVIDERS` 临时禁用:
897+
898+
```bash
899+
DISABLED_DATA_PROVIDERS=efinance
900+
```
901+
902+
可选值包括 `efinance`、`akshare`、`tushare`、`pytdx`、`baostock`、`yfinance`、`longbridge`,多个值用英文逗号分隔。该配置只影响日线/历史数据源管理器,不替代实时行情的 `REALTIME_SOURCE_PRIORITY`。
903+
896904
---
897905

898906
## 高级功能
@@ -905,6 +913,8 @@ PUSHOVER_API_TOKEN=your_api_token
905913
STOCK_LIST=600519,hk00700,hk01810
906914
```
907915

916+
港股日线会跳过 efinance 东财历史 K 线接口,避免把港股代码错配到非港股市场;默认改由 AkShare/Tushare/YFinance/Longbridge 等港股路径继续兜底。
917+
908918
### ETF 与指数分析
909919

910920
针对指数跟踪型 ETF 和美股指数(如 VOO、QQQ、SPY、510050、SPX、DJI、IXIC),分析仅关注**指数走势、跟踪误差、市场流动性**,不纳入基金管理人/发行方的公司层面风险(诉讼、声誉、高管变动等)。风险警报与业绩预期均基于指数成分股整体表现,避免将基金公司新闻误判为标的本身利空。详见 Issue #274。

docs/full-guide_EN.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,14 @@ System defaults to AkShare (free), also supports other data sources:
758758
- Supports US/HK stock data
759759
- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues
760760

761+
If a historical data provider keeps returning bad data or mismatched markets, temporarily disable it with:
762+
763+
```bash
764+
DISABLED_DATA_PROVIDERS=efinance
765+
```
766+
767+
Supported ids: `efinance`, `akshare`, `tushare`, `pytdx`, `baostock`, `yfinance`, `longbridge`. Multiple ids are comma-separated. This only affects the historical/daily data manager; realtime quotes still use `REALTIME_SOURCE_PRIORITY`.
768+
761769
---
762770

763771
## Advanced Features
@@ -770,6 +778,8 @@ Use `hk` prefix for HK stock codes:
770778
STOCK_LIST=600519,hk00700,hk01810
771779
```
772780

781+
HK daily history skips the efinance Eastmoney historical K-line API to avoid mismatching HK symbols to non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.
782+
773783
### Multi-Model Switching
774784

775785
Configure multiple models, system auto-switches:

src/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ class Config:
600600
longbridge_app_key: Optional[str] = None
601601
longbridge_app_secret: Optional[str] = None
602602
longbridge_access_token: Optional[str] = None
603+
disabled_data_providers: List[str] = field(default_factory=list)
603604

604605
# === AI 分析配置 ===
605606
# LiteLLM unified model config (provider/model format, e.g. gemini/gemini-3.1-pro-preview)
@@ -1319,6 +1320,11 @@ def _load_from_env(cls) -> 'Config':
13191320
longbridge_app_key=os.getenv('LONGBRIDGE_APP_KEY') or None,
13201321
longbridge_app_secret=os.getenv('LONGBRIDGE_APP_SECRET') or None,
13211322
longbridge_access_token=os.getenv('LONGBRIDGE_ACCESS_TOKEN') or None,
1323+
disabled_data_providers=[
1324+
provider.strip()
1325+
for provider in os.getenv('DISABLED_DATA_PROVIDERS', '').split(',')
1326+
if provider.strip()
1327+
],
13221328
litellm_model=litellm_model,
13231329
litellm_fallback_models=litellm_fallback_models,
13241330
llm_temperature=resolve_unified_llm_temperature(litellm_model),

tests/test_config_env_compat.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ def test_load_from_env_reads_tickflow_api_key(
3131

3232
self.assertEqual(config.tickflow_api_key, "tf-secret")
3333

34+
@patch("src.config.setup_env")
35+
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
36+
def test_load_from_env_reads_disabled_data_providers(
37+
self, _mock_parse_litellm_yaml, _mock_setup_env
38+
):
39+
with patch.dict(
40+
os.environ,
41+
{
42+
"STOCK_LIST": "600519",
43+
"DISABLED_DATA_PROVIDERS": "efinance, YFinanceFetcher ,",
44+
},
45+
clear=True,
46+
):
47+
config = Config._load_from_env()
48+
49+
self.assertEqual(config.disabled_data_providers, ["efinance", "YFinanceFetcher"])
50+
3451
@patch("src.config.setup_env")
3552
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
3653
def test_load_from_env_keeps_default_behavior_without_tickflow_api_key(

tests/test_fetcher_logging.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,33 @@ def test_manager_logs_fallback_and_final_success(self):
8282
self.assertIn("[数据源切换] 601006: [FailureFetcher] -> [SuccessFetcher]", log_text)
8383
self.assertIn("[数据源完成] 601006 使用 [SuccessFetcher] 获取成功:", log_text)
8484

85+
def test_manager_filters_disabled_fetchers_by_config_name_or_alias(self):
86+
efinance = _SuccessFetcher()
87+
efinance.name = "EfinanceFetcher"
88+
akshare = _SuccessFetcher()
89+
akshare.name = "AkshareFetcher"
90+
yfinance = _SuccessFetcher()
91+
yfinance.name = "YfinanceFetcher"
92+
93+
filtered = DataFetcherManager._filter_disabled_fetchers(
94+
[efinance, akshare, yfinance],
95+
["eastmoney", "YFinanceFetcher"],
96+
)
97+
98+
self.assertEqual([fetcher.name for fetcher in filtered], ["AkshareFetcher"])
99+
100+
@patch("data_provider.efinance_fetcher.get_config")
101+
def test_efinance_rejects_hk_daily_without_calling_eastmoney(self, mock_get_config):
102+
mock_get_config.return_value = types.SimpleNamespace(enable_eastmoney_patch=False)
103+
fetcher = EfinanceFetcher(sleep_min=0, sleep_max=0)
104+
105+
with patch.object(fetcher, "_fetch_stock_data") as mock_fetch_stock_data:
106+
with self.assertRaises(DataFetchError) as captured:
107+
fetcher.get_daily_data("1211.HK", start_date="2026-05-01", end_date="2026-05-08")
108+
109+
mock_fetch_stock_data.assert_not_called()
110+
self.assertIn("不支持港股日线", str(captured.exception))
111+
85112
def test_efinance_logs_eastmoney_endpoint_on_remote_disconnect(self):
86113
fetcher = EfinanceFetcher()
87114
fake_efinance = types.SimpleNamespace(

0 commit comments

Comments
 (0)