Closes #50, closes #54. akshare_loader._fetch_one previously dispatched every unrecognized symbol to stock_zh_a_hist. Two consequences: - Exchange-listed ETFs (518880.SH, 159915.SZ, ...) match the .SH/.SZ tail and are misrouted to the A-share endpoint, which can't price them (#50). - Forex pairs (EURUSD, GBPUSD, ...) match no predicate and fall through to the same default branch (#54). Add two predicates + endpoints: - _is_etf_listed: 6-digit code with prefix 15/16/50/51/52/56/58 on .SH/.SZ -> fund_etf_hist_sina(symbol='sh518880') - _is_forex: looks up akshare.forex.cons.symbol_market_map (190 pairs) -> forex_hist_em(symbol='EURUSD'), with .FX suffix stripped ETF check runs before _is_a_share so 518880.SH takes the ETF branch. registry.resolve_loader / get_loader_cls_with_fallback used to construct the loader before checking is_available(). Tushare's __init__ calls ts.pro_api(token) and raises 'api init error' on missing token, which broke the fallback chain (secondary issue from #50). Construction is now wrapped in try/except so init failures are treated as unavailable and the chain keeps walking. Tests: - 21 new unit tests in test_akshare_loader.py (predicates + routing via monkeypatched sys.modules['akshare']); no network, no API keys. - 2 new tests in test_registry.py covering init-error fallback. - Real-data E2E (manual): loader.fetch returned 242 rows for 518880.SH and 262 rows for EURUSD over 2024; full runner.main produced metrics for both. Credit: - @LaierX for issue #50 root-cause + reference patch - @yuzawaya for issue #54 root-cause + reference patch
159 lines
4.8 KiB
Python
159 lines
4.8 KiB
Python
"""Loader registry with market-level fallback chains.
|
|
|
|
Loaders self-register via the ``@register`` decorator when their module is
|
|
first imported. The ``_ensure_registered()`` helper lazily imports every
|
|
known loader module so that callers of ``resolve_loader`` /
|
|
``get_loader_cls_with_fallback`` never see an empty registry — regardless
|
|
of import order.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Type
|
|
|
|
from backtest.loaders.base import NoAvailableSourceError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Global registry: source_name -> loader class
|
|
# ---------------------------------------------------------------------------
|
|
|
|
LOADER_REGISTRY: dict[str, Type[Any]] = {}
|
|
|
|
_registered = False
|
|
|
|
|
|
def register(cls: Type[Any]) -> Type[Any]:
|
|
"""Class decorator: register a loader into the global registry.
|
|
|
|
The class must have a ``name`` class attribute.
|
|
"""
|
|
LOADER_REGISTRY[cls.name] = cls
|
|
return cls
|
|
|
|
|
|
def _ensure_registered() -> None:
|
|
"""Import every known loader module so ``@register`` decorators fire.
|
|
|
|
Safe to call multiple times — only runs the imports once.
|
|
Loaders whose dependencies are missing (e.g. ``akshare`` not installed)
|
|
are silently skipped.
|
|
"""
|
|
global _registered
|
|
if _registered:
|
|
return
|
|
_registered = True
|
|
|
|
_loader_modules = [
|
|
"backtest.loaders.tushare",
|
|
"backtest.loaders.okx",
|
|
"backtest.loaders.yfinance_loader",
|
|
"backtest.loaders.akshare_loader",
|
|
"backtest.loaders.ccxt_loader",
|
|
"backtest.loaders.futu",
|
|
]
|
|
import importlib
|
|
for mod in _loader_modules:
|
|
try:
|
|
importlib.import_module(mod)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fallback chains: market_type -> ordered list of source names
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FALLBACK_CHAINS: dict[str, list[str]] = {
|
|
"a_share": ["tushare", "akshare"],
|
|
"us_equity": ["yfinance", "akshare"],
|
|
"hk_equity": ["yfinance", "futu", "akshare"],
|
|
"crypto": ["okx", "ccxt"],
|
|
"futures": ["tushare", "akshare"],
|
|
"fund": ["tushare", "akshare"],
|
|
"macro": ["akshare", "tushare"],
|
|
"forex": ["akshare", "yfinance"],
|
|
}
|
|
|
|
|
|
def resolve_loader(market: str) -> Any:
|
|
"""Return the first *available* loader instance for *market*.
|
|
|
|
Walks the fallback chain and returns the first loader whose
|
|
``is_available()`` returns ``True``.
|
|
|
|
Args:
|
|
market: Market type key (e.g. ``"a_share"``, ``"crypto"``).
|
|
|
|
Returns:
|
|
A loader instance.
|
|
|
|
Raises:
|
|
NoAvailableSourceError: If every candidate is unavailable.
|
|
"""
|
|
_ensure_registered()
|
|
chain = FALLBACK_CHAINS.get(market, [])
|
|
tried: list[str] = []
|
|
for name in chain:
|
|
if name not in LOADER_REGISTRY:
|
|
continue
|
|
tried.append(name)
|
|
# Issue #50 — some loaders (e.g. Tushare) call into the SDK during
|
|
# __init__ and raise on missing credentials. Treat that the same as
|
|
# is_available()=False so the fallback chain keeps walking.
|
|
try:
|
|
loader = LOADER_REGISTRY[name]()
|
|
except Exception as exc:
|
|
logger.debug("loader %s failed to construct: %s", name, exc)
|
|
continue
|
|
if loader.is_available():
|
|
return loader
|
|
raise NoAvailableSourceError(
|
|
f"No available data source for market '{market}'. "
|
|
f"Tried: {tried or chain}. Check network and API token config."
|
|
)
|
|
|
|
|
|
def get_loader_cls_with_fallback(source: str) -> Type[Any]:
|
|
"""Return a loader *class* for *source*, falling back if unavailable.
|
|
|
|
Args:
|
|
source: Requested data source name.
|
|
|
|
Returns:
|
|
A DataLoader class (not instance).
|
|
|
|
Raises:
|
|
NoAvailableSourceError: If the source and all fallbacks are unavailable.
|
|
"""
|
|
_ensure_registered()
|
|
if source not in LOADER_REGISTRY:
|
|
raise NoAvailableSourceError(f"Unknown data source: {source}")
|
|
|
|
loader_cls = LOADER_REGISTRY[source]
|
|
try:
|
|
instance = loader_cls()
|
|
except Exception as exc:
|
|
logger.debug("loader %s failed to construct: %s", source, exc)
|
|
instance = None
|
|
if instance is not None and instance.is_available():
|
|
return loader_cls
|
|
|
|
# Source unavailable — try same-market fallback
|
|
for market in loader_cls.markets:
|
|
try:
|
|
fallback = resolve_loader(market)
|
|
logger.warning(
|
|
"%s is unavailable, falling back to %s for market %s",
|
|
source, fallback.name, market,
|
|
)
|
|
return type(fallback)
|
|
except NoAvailableSourceError:
|
|
continue
|
|
|
|
raise NoAvailableSourceError(
|
|
f"Data source '{source}' is unavailable and no fallback found."
|
|
)
|