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
275 lines
9.6 KiB
Python
275 lines
9.6 KiB
Python
"""AKShare loader: free, no-auth data for A-shares, US, HK, futures, forex, macro.
|
|
|
|
AKShare (https://github.com/akfamily/akshare) is a completely free financial
|
|
data aggregator covering Chinese and global markets. No API token required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Dict, List, Optional
|
|
|
|
import pandas as pd
|
|
|
|
from backtest.loaders.base import validate_date_range
|
|
from backtest.loaders.registry import register
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_INTERVAL_MAP_DAILY = {
|
|
"1D": "daily",
|
|
"1W": "weekly",
|
|
"1M": "monthly",
|
|
}
|
|
|
|
|
|
def _is_a_share(code: str) -> bool:
|
|
return code.upper().endswith((".SZ", ".SH", ".BJ"))
|
|
|
|
|
|
def _is_hk(code: str) -> bool:
|
|
return code.upper().endswith(".HK")
|
|
|
|
|
|
def _is_us(code: str) -> bool:
|
|
return code.upper().endswith(".US")
|
|
|
|
|
|
def _is_crypto(code: str) -> bool:
|
|
return "-USDT" in code.upper() or "/USDT" in code.upper()
|
|
|
|
|
|
# Exchange-listed ETF / LOF prefix codes:
|
|
# SH: 50/51/52/56/58 (ETFs), SZ: 15/16 (ETFs + LOFs).
|
|
# Issue #50 — these symbols look like A-shares (.SH / .SZ) but stock_zh_a_hist
|
|
# can't price them; route through fund_etf_hist_sina instead.
|
|
_ETF_PREFIXES = frozenset({"15", "16", "50", "51", "52", "56", "58"})
|
|
|
|
|
|
def _is_etf_listed(code: str) -> bool:
|
|
"""Detect exchange-listed ETF / LOF symbols (e.g. 518880.SH, 159915.SZ)."""
|
|
upper = code.upper()
|
|
if not upper.endswith((".SH", ".SZ")):
|
|
return False
|
|
digits = upper.split(".")[0]
|
|
if len(digits) != 6 or not digits.isdigit():
|
|
return False
|
|
return digits[:2] in _ETF_PREFIXES
|
|
|
|
|
|
def _is_forex(code: str) -> bool:
|
|
"""Detect forex pairs by matching against AKShare's symbol_market_map.
|
|
|
|
Issue #54 — forex symbols (EURUSD, GBPUSD, etc.) have no exchange suffix
|
|
and previously fell through to the A-share endpoint.
|
|
"""
|
|
upper = code.upper().removesuffix(".FX")
|
|
try:
|
|
from akshare.forex.cons import symbol_market_map
|
|
except Exception:
|
|
return False
|
|
return upper in symbol_market_map
|
|
|
|
|
|
@register
|
|
class DataLoader:
|
|
"""AKShare universal OHLCV loader (free, no auth)."""
|
|
|
|
name = "akshare"
|
|
markets = {"a_share", "us_equity", "hk_equity", "futures", "fund", "macro", "forex"}
|
|
requires_auth = False
|
|
|
|
def is_available(self) -> bool:
|
|
"""Available if akshare is installed."""
|
|
try:
|
|
import akshare # noqa: F401
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
def __init__(self) -> None:
|
|
pass
|
|
|
|
def fetch(
|
|
self,
|
|
codes: List[str],
|
|
start_date: str,
|
|
end_date: str,
|
|
*,
|
|
interval: str = "1D",
|
|
fields: Optional[List[str]] = None,
|
|
) -> Dict[str, pd.DataFrame]:
|
|
"""Fetch OHLCV data via AKShare.
|
|
|
|
Args:
|
|
codes: Symbol list.
|
|
start_date: YYYY-MM-DD.
|
|
end_date: YYYY-MM-DD.
|
|
interval: Bar size (only 1D supported currently).
|
|
fields: Ignored.
|
|
|
|
Returns:
|
|
Mapping symbol -> OHLCV DataFrame.
|
|
"""
|
|
validate_date_range(start_date, end_date)
|
|
|
|
result: Dict[str, pd.DataFrame] = {}
|
|
for code in codes:
|
|
try:
|
|
df = self._fetch_one(code, start_date, end_date, interval)
|
|
if df is not None and not df.empty:
|
|
result[code] = df
|
|
except Exception as exc:
|
|
logger.warning("akshare failed for %s: %s", code, exc)
|
|
return result
|
|
|
|
def _fetch_one(
|
|
self, code: str, start_date: str, end_date: str, interval: str,
|
|
) -> Optional[pd.DataFrame]:
|
|
"""Fetch a single symbol."""
|
|
import akshare as ak
|
|
|
|
# ETF check must precede A-share — 518880.SH ends with .SH but is an ETF.
|
|
if _is_etf_listed(code):
|
|
return self._fetch_etf(ak, code, start_date, end_date)
|
|
if _is_a_share(code):
|
|
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
|
if _is_us(code):
|
|
return self._fetch_us(ak, code, start_date, end_date)
|
|
if _is_hk(code):
|
|
return self._fetch_hk(ak, code, start_date, end_date)
|
|
if _is_forex(code):
|
|
return self._fetch_forex(ak, code, start_date, end_date)
|
|
# Default: try A-share
|
|
return self._fetch_a_share(ak, code, start_date, end_date, interval)
|
|
|
|
def _fetch_a_share(
|
|
self, ak, code: str, start_date: str, end_date: str, interval: str,
|
|
) -> Optional[pd.DataFrame]:
|
|
"""Fetch A-share via stock_zh_a_hist."""
|
|
symbol = code.split(".")[0]
|
|
period = _INTERVAL_MAP_DAILY.get(interval, "daily")
|
|
sd = start_date.replace("-", "")
|
|
ed = end_date.replace("-", "")
|
|
df = ak.stock_zh_a_hist(
|
|
symbol=symbol,
|
|
period=period,
|
|
start_date=sd,
|
|
end_date=ed,
|
|
adjust="qfq",
|
|
)
|
|
if df is None or df.empty:
|
|
return None
|
|
return self._normalize(df, date_col="日期")
|
|
|
|
def _fetch_us(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
|
"""Fetch US stock via stock_us_hist."""
|
|
symbol = code.replace(".US", "")
|
|
# akshare uses the format like "105.AAPL" for NASDAQ
|
|
# Try common prefixes
|
|
for prefix in ["105.", "106.", ""]:
|
|
try:
|
|
df = ak.stock_us_hist(
|
|
symbol=f"{prefix}{symbol}",
|
|
period="daily",
|
|
start_date=start_date.replace("-", ""),
|
|
end_date=end_date.replace("-", ""),
|
|
adjust="qfq",
|
|
)
|
|
if df is not None and not df.empty:
|
|
return self._normalize(df, date_col="日期")
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
def _fetch_etf(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
|
"""Fetch exchange-listed ETF / LOF via fund_etf_hist_sina.
|
|
|
|
Sina symbol format is ``sh518880`` / ``sz159915``. The endpoint returns
|
|
the full history; we filter to the requested window after fetching.
|
|
"""
|
|
digits, _, suffix = code.upper().partition(".")
|
|
symbol = f"{suffix.lower()}{digits}"
|
|
df = ak.fund_etf_hist_sina(symbol=symbol)
|
|
if df is None or df.empty:
|
|
return None
|
|
df = self._normalize(df, date_col="date")
|
|
# fund_etf_hist_sina returns full history — clip to window.
|
|
return df.loc[start_date:end_date]
|
|
|
|
def _fetch_forex(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
|
"""Fetch forex pair via forex_hist_em.
|
|
|
|
Columns returned are 日期 / 代码 / 名称 / 今开 / 最新价 / 最高 / 最低 / 振幅
|
|
— note ``最新价`` (latest) plays the role of close. Volume isn't reported,
|
|
so we synthesize a zero column to satisfy the OHLCV contract.
|
|
"""
|
|
symbol = code.upper().removesuffix(".FX")
|
|
df = ak.forex_hist_em(symbol=symbol)
|
|
if df is None or df.empty:
|
|
return None
|
|
df = df.rename(columns={
|
|
"日期": "trade_date",
|
|
"今开": "open",
|
|
"最新价": "close",
|
|
"最高": "high",
|
|
"最低": "low",
|
|
})
|
|
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
|
df = df.set_index("trade_date").sort_index()
|
|
df["volume"] = 0.0
|
|
for col in ("open", "high", "low", "close"):
|
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
df = df[["open", "high", "low", "close", "volume"]].dropna(
|
|
subset=["open", "high", "low", "close"]
|
|
)
|
|
return df.loc[start_date:end_date]
|
|
|
|
def _fetch_hk(self, ak, code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
|
"""Fetch HK stock via stock_hk_hist."""
|
|
symbol = code.replace(".HK", "").zfill(5)
|
|
df = ak.stock_hk_hist(
|
|
symbol=symbol,
|
|
period="daily",
|
|
start_date=start_date.replace("-", ""),
|
|
end_date=end_date.replace("-", ""),
|
|
adjust="qfq",
|
|
)
|
|
if df is None or df.empty:
|
|
return None
|
|
return self._normalize(df, date_col="日期")
|
|
|
|
@staticmethod
|
|
def _normalize(df: pd.DataFrame, date_col: str = "日期") -> pd.DataFrame:
|
|
"""Normalize AKShare DataFrame to standard OHLCV schema.
|
|
|
|
AKShare Chinese column names: 日期, 开盘, 最高, 最低, 收盘, 成交量
|
|
AKShare English column names: date, open, high, low, close, volume
|
|
"""
|
|
col_map_cn = {"开盘": "open", "最高": "high", "最低": "low", "收盘": "close", "成交量": "volume"}
|
|
col_map_en = {"date": "trade_date", "open": "open", "high": "high", "low": "low", "close": "close", "volume": "volume"}
|
|
|
|
if date_col in df.columns:
|
|
df = df.rename(columns={date_col: "trade_date"})
|
|
elif "date" in df.columns:
|
|
df = df.rename(columns={"date": "trade_date"})
|
|
|
|
# Try Chinese column names first, then English
|
|
if "开盘" in df.columns:
|
|
df = df.rename(columns=col_map_cn)
|
|
else:
|
|
df = df.rename(columns=col_map_en)
|
|
|
|
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
|
df = df.set_index("trade_date").sort_index()
|
|
|
|
for col in ["open", "high", "low", "close", "volume"]:
|
|
if col in df.columns:
|
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
|
|
ohlcv_cols = [c for c in ["open", "high", "low", "close", "volume"] if c in df.columns]
|
|
df = df[ohlcv_cols].dropna(subset=["open", "high", "low", "close"])
|
|
if "volume" not in df.columns:
|
|
df["volume"] = 0.0
|
|
return df
|