Files
openbb-invest-api/akshare_service.py
Yaojia Wang 9ee3ec9b4e feat: add A-share and HK stock data via AKShare (TDD)
5 new endpoints under /api/v1/cn/:
- GET /cn/a-share/{symbol}/quote - A-share real-time quote
- GET /cn/a-share/{symbol}/historical - A-share historical OHLCV
- GET /cn/a-share/search?query= - search A-shares by name
- GET /cn/hk/{symbol}/quote - HK stock real-time quote
- GET /cn/hk/{symbol}/historical - HK stock historical OHLCV

Features:
- Chinese column names auto-mapped to English
- Symbol validation: A-share ^[036]\d{5}$, HK ^\d{5}$
- qfq (forward-adjusted) prices by default
- 79 new tests (51 service unit + 28 route integration)
- All 470 tests passing
2026-03-19 22:44:30 +01:00

186 lines
5.0 KiB
Python

"""A-share and HK stock data service using AKShare."""
import asyncio
import logging
import re
from datetime import datetime, timedelta
from typing import Any
import akshare as ak
import pandas as pd
logger = logging.getLogger(__name__)
# --- Symbol validation patterns ---
_A_SHARE_PATTERN = re.compile(r"^[036]\d{5}$")
_HK_PATTERN = re.compile(r"^\d{5}$")
# --- Chinese column name mappings ---
_HIST_COLUMNS: dict[str, str] = {
"日期": "date",
"开盘": "open",
"收盘": "close",
"最高": "high",
"最低": "low",
"成交量": "volume",
"成交额": "turnover",
"振幅": "amplitude",
"涨跌幅": "change_percent",
"涨跌额": "change",
"换手率": "turnover_rate",
}
_QUOTE_COLUMNS: dict[str, str] = {
"代码": "symbol",
"名称": "name",
"最新价": "price",
"涨跌幅": "change_percent",
"涨跌额": "change",
"成交量": "volume",
"成交额": "turnover",
"今开": "open",
"最高": "high",
"最低": "low",
"昨收": "prev_close",
}
# --- Validation helpers ---
def validate_a_share_symbol(symbol: str) -> bool:
"""Return True if symbol matches A-share format (6 digits, starts with 0, 3, or 6)."""
return bool(_A_SHARE_PATTERN.match(symbol))
def validate_hk_symbol(symbol: str) -> bool:
"""Return True if symbol matches HK stock format (exactly 5 digits)."""
return bool(_HK_PATTERN.match(symbol))
# --- DataFrame parsers ---
def _parse_hist_df(df: pd.DataFrame) -> list[dict[str, Any]]:
"""Convert a Chinese-column historical DataFrame to a list of English-key dicts."""
if df.empty:
return []
df = df.rename(columns=_HIST_COLUMNS)
# Serialize date column to ISO string
if "date" in df.columns:
df["date"] = df["date"].astype(str)
return df.to_dict(orient="records")
def _parse_spot_row(df: pd.DataFrame, symbol: str) -> dict[str, Any] | None:
"""
Filter a spot quote DataFrame by symbol code column and return
an English-key dict for the matching row, or None if not found.
"""
if df.empty:
return None
code_col = "代码"
if code_col not in df.columns:
return None
matched = df[df[code_col] == symbol]
if matched.empty:
return None
row = matched.iloc[0]
result: dict[str, Any] = {}
for cn_key, en_key in _QUOTE_COLUMNS.items():
result[en_key] = row.get(cn_key)
return result
# --- Date helpers ---
def _date_range(days: int) -> tuple[str, str]:
"""Return (start_date, end_date) strings in YYYYMMDD format for the given window."""
end = datetime.now()
start = end - timedelta(days=days)
return start.strftime("%Y%m%d"), end.strftime("%Y%m%d")
# --- A-share service functions ---
async def get_a_share_quote(symbol: str) -> dict[str, Any] | None:
"""
Fetch real-time A-share quote for a single symbol.
Returns a dict with English keys, or None if the symbol is not found
in the spot market data. Propagates AKShare exceptions to the caller.
"""
df: pd.DataFrame = await asyncio.to_thread(ak.stock_zh_a_spot_em)
return _parse_spot_row(df, symbol)
async def get_a_share_historical(
symbol: str, *, days: int = 365
) -> list[dict[str, Any]]:
"""
Fetch daily OHLCV history for an A-share symbol with qfq (前复权) adjustment.
Propagates AKShare exceptions to the caller.
"""
start_date, end_date = _date_range(days)
df: pd.DataFrame = await asyncio.to_thread(
ak.stock_zh_a_hist,
symbol=symbol,
period="daily",
start_date=start_date,
end_date=end_date,
adjust="qfq",
)
return _parse_hist_df(df)
async def search_a_shares(query: str) -> list[dict[str, Any]]:
"""
Search A-share stocks by name substring.
Returns a list of {code, name} dicts. An empty query returns all stocks.
Propagates AKShare exceptions to the caller.
"""
df: pd.DataFrame = await asyncio.to_thread(ak.stock_info_a_code_name)
if query:
df = df[df["name"].str.contains(query, na=False)]
return df[["code", "name"]].to_dict(orient="records")
# --- HK stock service functions ---
async def get_hk_quote(symbol: str) -> dict[str, Any] | None:
"""
Fetch real-time HK stock quote for a single symbol.
Returns a dict with English keys, or None if the symbol is not found.
Propagates AKShare exceptions to the caller.
"""
df: pd.DataFrame = await asyncio.to_thread(ak.stock_hk_spot_em)
return _parse_spot_row(df, symbol)
async def get_hk_historical(
symbol: str, *, days: int = 365
) -> list[dict[str, Any]]:
"""
Fetch daily OHLCV history for a HK stock symbol with qfq adjustment.
Propagates AKShare exceptions to the caller.
"""
start_date, end_date = _date_range(days)
df: pd.DataFrame = await asyncio.to_thread(
ak.stock_hk_hist,
symbol=symbol,
period="daily",
start_date=start_date,
end_date=end_date,
adjust="qfq",
)
return _parse_hist_df(df)