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
This commit is contained in:
Yaojia Wang
2026-03-19 22:44:30 +01:00
parent 5c7a0ee4c0
commit 9ee3ec9b4e
6 changed files with 1057 additions and 0 deletions

105
routes_cn.py Normal file
View File

@@ -0,0 +1,105 @@
"""Routes for A-share (China) and Hong Kong stock market data via AKShare."""
from fastapi import APIRouter, HTTPException, Path, Query
from models import ApiResponse
from route_utils import safe
import akshare_service
router = APIRouter(prefix="/api/v1/cn", tags=["China & HK Markets"])
# --- Validation helpers ---
def _validate_a_share(symbol: str) -> str:
"""Validate A-share symbol format; raise 400 on failure."""
if not akshare_service.validate_a_share_symbol(symbol):
raise HTTPException(
status_code=400,
detail=(
f"Invalid A-share symbol '{symbol}'. "
"Must be 6 digits starting with 0, 3, or 6 (e.g. 000001, 300001, 600519)."
),
)
return symbol
def _validate_hk(symbol: str) -> str:
"""Validate HK stock symbol format; raise 400 on failure."""
if not akshare_service.validate_hk_symbol(symbol):
raise HTTPException(
status_code=400,
detail=(
f"Invalid HK symbol '{symbol}'. "
"Must be exactly 5 digits (e.g. 00700, 09988)."
),
)
return symbol
# --- A-share routes ---
# NOTE: /a-share/search MUST be registered before /a-share/{symbol} to avoid shadowing.
@router.get("/a-share/search", response_model=ApiResponse)
@safe
async def a_share_search(
query: str = Query(..., description="Stock name to search for (partial match)"),
) -> ApiResponse:
"""Search A-share stocks by name (partial match)."""
data = await akshare_service.search_a_shares(query)
return ApiResponse(data=data)
@router.get("/a-share/{symbol}/quote", response_model=ApiResponse)
@safe
async def a_share_quote(
symbol: str = Path(..., min_length=6, max_length=6),
) -> ApiResponse:
"""Get real-time A-share quote (沪深 real-time price)."""
symbol = _validate_a_share(symbol)
data = await akshare_service.get_a_share_quote(symbol)
if data is None:
raise HTTPException(status_code=404, detail=f"A-share symbol '{symbol}' not found.")
return ApiResponse(data=data)
@router.get("/a-share/{symbol}/historical", response_model=ApiResponse)
@safe
async def a_share_historical(
symbol: str = Path(..., min_length=6, max_length=6),
days: int = Query(default=365, ge=1, le=3650),
) -> ApiResponse:
"""Get A-share daily OHLCV history with qfq (前复权) adjustment."""
symbol = _validate_a_share(symbol)
data = await akshare_service.get_a_share_historical(symbol, days=days)
return ApiResponse(data=data)
# --- HK stock routes ---
@router.get("/hk/{symbol}/quote", response_model=ApiResponse)
@safe
async def hk_quote(
symbol: str = Path(..., min_length=5, max_length=5),
) -> ApiResponse:
"""Get real-time HK stock quote (港股 real-time price)."""
symbol = _validate_hk(symbol)
data = await akshare_service.get_hk_quote(symbol)
if data is None:
raise HTTPException(status_code=404, detail=f"HK symbol '{symbol}' not found.")
return ApiResponse(data=data)
@router.get("/hk/{symbol}/historical", response_model=ApiResponse)
@safe
async def hk_historical(
symbol: str = Path(..., min_length=5, max_length=5),
days: int = Query(default=365, ge=1, le=3650),
) -> ApiResponse:
"""Get HK stock daily OHLCV history with qfq adjustment."""
symbol = _validate_hk(symbol)
data = await akshare_service.get_hk_historical(symbol, days=days)
return ApiResponse(data=data)