Files
Auto-Trader/agent/backtest/loaders/registry.py
T
warren618 aae5a7ec1a fix(loader): route ETFs and forex correctly in AKShare; harden registry fallback
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
2026-04-27 22:28:14 +08:00

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."
)