400 lines
15 KiB
Python
400 lines
15 KiB
Python
"""Unified data downloader: baostock primary, akshare fallback."""
|
|
import logging
|
|
from typing import Iterable, Iterator, Optional, Tuple
|
|
import pandas as pd
|
|
import akshare as ak
|
|
import baostock as bs
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Map the adjust argument to baostock's adjustflag codes.
|
|
_BAOSTOCK_ADJUST = {"qfq": "2", "hfq": "1", "": "3", "none": "3"}
|
|
|
|
# Richer field set requested by the batch downloader. On top of OHLCV+amount we
|
|
# pull baostock's preclose, turnover rate, daily % change, trade/ST status, and
|
|
# the four valuation ratios, then derive a daily VWAP (amount / volume).
|
|
_BATCH_FIELDS = (
|
|
"date,open,high,low,close,preclose,volume,amount,turn,pctChg,"
|
|
"tradestatus,isST,peTTM,pbMRQ,psTTM,pcfNcfTTM"
|
|
)
|
|
# Every batch field except ``date`` is numeric (flags included: 0/1 strings).
|
|
_BATCH_NUMERIC = [
|
|
"open", "high", "low", "close", "preclose", "volume", "amount",
|
|
"turn", "pctChg", "tradestatus", "isST",
|
|
"peTTM", "pbMRQ", "psTTM", "pcfNcfTTM",
|
|
]
|
|
# Output column order; ``vwap`` is derived (inserted right after ``amount``).
|
|
_BATCH_COLUMNS = [
|
|
"symbol", "date",
|
|
"open", "high", "low", "close", "preclose", "volume", "amount", "vwap",
|
|
"turn", "pctChg", "tradestatus", "isST",
|
|
"peTTM", "pbMRQ", "psTTM", "pcfNcfTTM",
|
|
]
|
|
|
|
# Raw Baostock minute bars. The ``time`` field is usually compact
|
|
# YYYYMMDDHHMMSSmmm; parsing below also tolerates HH:MM:SS strings in tests.
|
|
_MINUTE_FIELDS = "date,time,code,open,high,low,close,volume,amount,adjustflag"
|
|
_MINUTE_NUMERIC = ["open", "high", "low", "close", "volume", "amount"]
|
|
_MINUTE_COLUMNS = [
|
|
"symbol", "datetime", "date", "time", "frequency",
|
|
"open", "high", "low", "close", "volume", "amount", "vwap", "adjustflag",
|
|
]
|
|
_MINUTE_FREQUENCIES = {"5", "15", "30", "60"}
|
|
|
|
|
|
class _SessionLost(Exception):
|
|
"""baostock reported the session was dropped (``用户未登录``)."""
|
|
|
|
|
|
def _normalize_minute_frequency(frequency: str | int) -> tuple[str, str]:
|
|
"""Return Baostock frequency and partition label for a minute interval."""
|
|
raw = str(frequency).strip().lower()
|
|
if raw.endswith("m"):
|
|
raw = raw[:-1]
|
|
if raw not in _MINUTE_FREQUENCIES:
|
|
raise ValueError(
|
|
f"Unsupported minute frequency '{frequency}'. "
|
|
f"Expected one of {sorted(_MINUTE_FREQUENCIES)} minutes."
|
|
)
|
|
return raw, f"{raw}m"
|
|
|
|
|
|
def _parse_minute_datetime(date: pd.Series, time: pd.Series) -> pd.Series:
|
|
"""Parse Baostock minute timestamps into pandas datetimes."""
|
|
date_dt = pd.to_datetime(date, errors="coerce")
|
|
date_compact = date_dt.dt.strftime("%Y%m%d")
|
|
time_text = time.astype(str).str.strip()
|
|
time_digits = time_text.str.replace(r"\D", "", regex=True)
|
|
|
|
full_digits = time_digits.str.slice(0, 14)
|
|
from_full = pd.to_datetime(full_digits, format="%Y%m%d%H%M%S", errors="coerce")
|
|
|
|
from_short = pd.Series(pd.NaT, index=date.index, dtype="datetime64[ns]")
|
|
short_time = time_digits.str.len().between(1, 6)
|
|
if short_time.any():
|
|
short_digits = (
|
|
time_digits.loc[short_time]
|
|
.str.pad(6, side="right", fillchar="0")
|
|
.str.slice(0, 6)
|
|
)
|
|
from_short.loc[short_time] = pd.to_datetime(
|
|
date_compact.loc[short_time] + short_digits,
|
|
format="%Y%m%d%H%M%S",
|
|
errors="coerce",
|
|
)
|
|
|
|
from_text = pd.Series(pd.NaT, index=date.index, dtype="datetime64[ns]")
|
|
text_time = time_text.str.contains(":", regex=False)
|
|
if text_time.any():
|
|
from_text.loc[text_time] = pd.to_datetime(
|
|
date.astype(str).loc[text_time] + " " + time_text.loc[text_time],
|
|
errors="coerce",
|
|
)
|
|
return from_full.fillna(from_short).fillna(from_text)
|
|
|
|
|
|
def _download_akshare(symbol: str, start: str, end: str, adjust: str = "qfq") -> Optional[pd.DataFrame]:
|
|
"""Download daily bars from akshare. Returns DataFrame with OHLCV columns."""
|
|
try:
|
|
# symbol format: 'sh600000' in akshare stock_zh_a_hist expects raw code like '600000'
|
|
# strip exchange prefix for akshare
|
|
raw = symbol.replace("sh", "").replace("sz", "")
|
|
df = ak.stock_zh_a_hist(
|
|
symbol=raw,
|
|
period="daily",
|
|
start_date=start,
|
|
end_date=end,
|
|
adjust=adjust,
|
|
)
|
|
if df is None or df.empty:
|
|
return None
|
|
# Standardize columns
|
|
col_map = {
|
|
"日期": "date",
|
|
"开盘": "open",
|
|
"最高": "high",
|
|
"最低": "low",
|
|
"收盘": "close",
|
|
"成交量": "volume",
|
|
"成交额": "amount",
|
|
}
|
|
df = df.rename(columns=col_map)
|
|
df["symbol"] = symbol
|
|
return df[["symbol", "date", "open", "high", "low", "close", "volume", "amount"]]
|
|
except Exception as e:
|
|
logger.warning(f"akshare download failed for {symbol}: {e}")
|
|
return None
|
|
|
|
|
|
def _download_baostock(symbol: str, start: str, end: str, adjust: str = "qfq") -> Optional[pd.DataFrame]:
|
|
"""Download daily bars from baostock (primary source)."""
|
|
try:
|
|
bs.login()
|
|
# baostock format: sh.600000
|
|
code = f"{symbol[:2]}.{symbol[2:]}"
|
|
rs = bs.query_history_k_data_plus(
|
|
code=code,
|
|
fields="date,open,high,low,close,volume,amount",
|
|
start_date=start,
|
|
end_date=end,
|
|
frequency="d",
|
|
adjustflag=_BAOSTOCK_ADJUST.get(adjust, "2"),
|
|
)
|
|
if rs.error_code != "0":
|
|
logger.warning(f"baostock error for {symbol}: {rs.error_msg}")
|
|
return None
|
|
data_list = []
|
|
while rs.next():
|
|
data_list.append(rs.get_row_data())
|
|
if not data_list:
|
|
return None
|
|
df = pd.DataFrame(data_list, columns=["date", "open", "high", "low", "close", "volume", "amount"])
|
|
df[["open", "high", "low", "close", "volume", "amount"]] = df[
|
|
["open", "high", "low", "close", "volume", "amount"]
|
|
].apply(pd.to_numeric, errors="coerce")
|
|
df["symbol"] = symbol
|
|
return df[["symbol", "date", "open", "high", "low", "close", "volume", "amount"]]
|
|
except Exception as e:
|
|
logger.warning(f"baostock download failed for {symbol}: {e}")
|
|
return None
|
|
finally:
|
|
try:
|
|
bs.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def download_daily(
|
|
symbol: str,
|
|
start: str,
|
|
end: str,
|
|
adjust: str = "qfq",
|
|
source: str = "auto",
|
|
) -> pd.DataFrame:
|
|
"""
|
|
Download daily OHLCV data. Tries baostock first, falls back to akshare.
|
|
|
|
Args:
|
|
symbol: Stock symbol like 'sh600000' or 'sz000001'
|
|
start: Start date 'YYYY-MM-DD'
|
|
end: End date 'YYYY-MM-DD'
|
|
adjust: 'qfq' (forward-adjusted), 'hfq' (backward), '' (none)
|
|
source: 'auto' (baostock then akshare fallback), 'baostock' only,
|
|
or 'akshare' only
|
|
|
|
Returns:
|
|
DataFrame with columns: symbol, date, open, high, low, close, volume, amount
|
|
"""
|
|
df = None
|
|
if source in ("baostock", "auto"):
|
|
df = _download_baostock(symbol, start, end, adjust)
|
|
if df is None and source in ("akshare", "auto"):
|
|
df = _download_akshare(symbol, start, end, adjust)
|
|
|
|
if df is None or df.empty:
|
|
raise RuntimeError(f"Failed to download data for {symbol} from {start} to {end}")
|
|
|
|
df["date"] = pd.to_datetime(df["date"])
|
|
df = df.sort_values("date").reset_index(drop=True)
|
|
return df
|
|
|
|
|
|
def download_daily_batch(
|
|
symbols: Iterable[str],
|
|
start: str,
|
|
end: str,
|
|
adjust: str = "qfq",
|
|
akshare_fallback: bool = False,
|
|
relogin_every: int = 200,
|
|
) -> Iterator[Tuple[str, Optional[pd.DataFrame]]]:
|
|
"""Download many symbols, keeping a baostock session alive across the run.
|
|
|
|
Logging in once (instead of per symbol) is the dominant speed-up for
|
|
thousands of symbols, but baostock drops a session after a while
|
|
(subsequent queries return ``用户未登录``). So we refresh the session every
|
|
``relogin_every`` symbols and also re-login + retry once whenever a query
|
|
reports the session is gone. Yields ``(symbol, df)`` as each symbol
|
|
completes; ``df`` is ``None`` when no data is available. Each ``df`` has the
|
|
same 8 columns as :func:`download_daily`.
|
|
|
|
Args:
|
|
symbols: Internal-form symbols (``sh600000`` / ``sz000001``).
|
|
start, end: ``YYYY-MM-DD`` bounds.
|
|
adjust: ``qfq`` / ``hfq`` / ``''``.
|
|
akshare_fallback: Retry a failed symbol through akshare. Off by default
|
|
because akshare is unreliable on the deployment network and each
|
|
failed attempt is slow; baostock + re-login is the fast path.
|
|
relogin_every: Proactively refresh the baostock session every N symbols.
|
|
"""
|
|
flag = _BAOSTOCK_ADJUST.get(adjust, "2")
|
|
|
|
def _relogin() -> None:
|
|
try:
|
|
bs.logout()
|
|
except Exception:
|
|
pass
|
|
bs.login()
|
|
|
|
def _fetch(symbol: str) -> Optional[pd.DataFrame]:
|
|
"""One baostock query; returns df, or None (no data), or raises _SessionLost."""
|
|
code = f"{symbol[:2]}.{symbol[2:]}"
|
|
rs = bs.query_history_k_data_plus(
|
|
code=code, fields=_BATCH_FIELDS,
|
|
start_date=start, end_date=end, frequency="d", adjustflag=flag,
|
|
)
|
|
if rs.error_code != "0":
|
|
if "未登录" in (rs.error_msg or ""):
|
|
raise _SessionLost(rs.error_msg)
|
|
logger.warning("baostock error for %s: %s", symbol, rs.error_msg)
|
|
return None
|
|
rows = []
|
|
while rs.next():
|
|
rows.append(rs.get_row_data())
|
|
if not rows:
|
|
return None
|
|
df = pd.DataFrame(rows, columns=["date", *_BATCH_NUMERIC])
|
|
# Suspended-trading days come back as empty strings; coerce to NaN
|
|
# rather than crashing the whole symbol.
|
|
df[_BATCH_NUMERIC] = df[_BATCH_NUMERIC].apply(pd.to_numeric, errors="coerce")
|
|
# Daily VWAP = turnover (yuan) / shares; NaN when no volume (suspended).
|
|
df["vwap"] = (df["amount"] / df["volume"]).where(df["volume"] > 0)
|
|
df["symbol"] = symbol
|
|
return df[_BATCH_COLUMNS]
|
|
|
|
bs.login()
|
|
try:
|
|
for i, symbol in enumerate(symbols):
|
|
if i and relogin_every and i % relogin_every == 0:
|
|
_relogin()
|
|
|
|
df: Optional[pd.DataFrame] = None
|
|
for attempt in (1, 2):
|
|
try:
|
|
df = _fetch(symbol)
|
|
break
|
|
except _SessionLost:
|
|
if attempt == 1:
|
|
_relogin() # session dropped — refresh and retry once
|
|
continue
|
|
logger.warning("baostock session lost for %s after relogin", symbol)
|
|
except Exception as e:
|
|
logger.warning("baostock download failed for %s: %s", symbol, e)
|
|
break
|
|
|
|
if (df is None or df.empty) and akshare_fallback:
|
|
df = _download_akshare(symbol, start, end, adjust)
|
|
|
|
if df is not None and not df.empty:
|
|
df["date"] = pd.to_datetime(df["date"])
|
|
df = df.sort_values("date").reset_index(drop=True)
|
|
yield symbol, df
|
|
else:
|
|
yield symbol, None
|
|
finally:
|
|
try:
|
|
bs.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def download_minute_batch(
|
|
symbols: Iterable[str],
|
|
start: str,
|
|
end: str,
|
|
frequency: str | int = 5,
|
|
relogin_every: int = 200,
|
|
) -> Iterator[Tuple[str, Optional[pd.DataFrame]]]:
|
|
"""Download raw Baostock minute bars for many symbols.
|
|
|
|
Minute bars are intentionally unadjusted (`adjustflag='3'`) because the
|
|
output is raw intraday market data for downstream feature aggregation, not a
|
|
tradable daily price series.
|
|
|
|
Args:
|
|
symbols: Internal-form symbols (``sh600000`` / ``sz000001``).
|
|
start, end: ``YYYY-MM-DD`` bounds.
|
|
frequency: Baostock minute frequency. ``5``/``"5"``/``"5m"`` all mean
|
|
5-minute bars.
|
|
relogin_every: Proactively refresh the baostock session every N symbols.
|
|
|
|
Yields:
|
|
``(symbol, df)`` where ``df`` has raw minute bars or ``None`` when no
|
|
data is available.
|
|
"""
|
|
query_frequency, frequency_label = _normalize_minute_frequency(frequency)
|
|
adjustflag = _BAOSTOCK_ADJUST["none"]
|
|
|
|
def _relogin() -> None:
|
|
try:
|
|
bs.logout()
|
|
except Exception:
|
|
pass
|
|
bs.login()
|
|
|
|
def _fetch(symbol: str) -> Optional[pd.DataFrame]:
|
|
"""One Baostock minute query; returns df, None, or raises _SessionLost."""
|
|
code = f"{symbol[:2]}.{symbol[2:]}"
|
|
rs = bs.query_history_k_data_plus(
|
|
code=code,
|
|
fields=_MINUTE_FIELDS,
|
|
start_date=start,
|
|
end_date=end,
|
|
frequency=query_frequency,
|
|
adjustflag=adjustflag,
|
|
)
|
|
if rs.error_code != "0":
|
|
if "未登录" in (rs.error_msg or ""):
|
|
raise _SessionLost(rs.error_msg)
|
|
logger.warning("baostock minute error for %s: %s", symbol, rs.error_msg)
|
|
return None
|
|
rows = []
|
|
while rs.next():
|
|
rows.append(rs.get_row_data())
|
|
if not rows:
|
|
return None
|
|
|
|
df = pd.DataFrame(rows, columns=_MINUTE_FIELDS.split(","))
|
|
df[_MINUTE_NUMERIC] = df[_MINUTE_NUMERIC].apply(pd.to_numeric, errors="coerce")
|
|
df["datetime"] = _parse_minute_datetime(df["date"], df["time"])
|
|
bad_timestamps = df["datetime"].isna()
|
|
if bad_timestamps.any():
|
|
raise ValueError(
|
|
f"Could not parse {int(bad_timestamps.sum())} minute timestamp(s)"
|
|
)
|
|
df["date"] = pd.to_datetime(df["date"], errors="coerce")
|
|
df["time"] = df["datetime"].dt.strftime("%H:%M:%S")
|
|
df["frequency"] = frequency_label
|
|
df["vwap"] = (df["amount"] / df["volume"]).where(df["volume"] > 0)
|
|
df["symbol"] = symbol
|
|
return df[_MINUTE_COLUMNS].sort_values("datetime").reset_index(drop=True)
|
|
|
|
bs.login()
|
|
try:
|
|
for i, symbol in enumerate(symbols):
|
|
if i and relogin_every and i % relogin_every == 0:
|
|
_relogin()
|
|
|
|
df: Optional[pd.DataFrame] = None
|
|
for attempt in (1, 2):
|
|
try:
|
|
df = _fetch(symbol)
|
|
break
|
|
except _SessionLost:
|
|
if attempt == 1:
|
|
_relogin()
|
|
continue
|
|
logger.warning("baostock minute session lost for %s after relogin", symbol)
|
|
except Exception as e:
|
|
logger.warning("baostock minute download failed for %s: %s", symbol, e)
|
|
break
|
|
|
|
if df is not None and not df.empty:
|
|
yield symbol, df
|
|
else:
|
|
yield symbol, None
|
|
finally:
|
|
try:
|
|
bs.logout()
|
|
except Exception:
|
|
pass
|