- vendor 同花顺官方 SDK 到 backend/sdk(含K线>10年自动切片、重试、拼音首字母检索兜底) - 新增 /api/v2 路由:行情/估值/财务/日历/指数/K线/标的检索 - 股票详情页K线改用 v2 同花顺接口(前复权日K+总手+按昨收涨跌幅) - 密钥仅后端持有,响应/日志无泄露 - 新增 run_local.sh 本地直接拉起(不再依赖 docker)
1538 lines
49 KiB
Python
1538 lines
49 KiB
Python
"""hithink finance (fuyao.aicubes.cn) API client as typed functions.
|
|
|
|
Python adapter contract:
|
|
- Every capability is a top-level function with full type annotations.
|
|
- Parameter constraints (mutual exclusion, enum ranges, window limits) are enforced
|
|
client-side and raise ValueError before any HTTP call.
|
|
- Long historical windows (>10 years) are auto-sliced and concatenated.
|
|
- Local ticker cache (TTL 12h) backs tickers_search to avoid network round-trips.
|
|
- Returns plain list[dict] / dict — no DataFrame dependency.
|
|
- API Key comes from the unified credential resolver; never accepted as a parameter.
|
|
- Business errors (code != 0) raise FuyaoApiError(code, message, request_id).
|
|
|
|
Upstream field semantics live in the repository's ``docs/api/`` contract and at
|
|
https://fuyao.aicubes.cn/llms-full.txt; do not reproduce them in docstrings here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import date as Date, datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Literal, Optional
|
|
|
|
import requests
|
|
|
|
from sdk.credentials import resolve_api_key
|
|
|
|
BASE_URL = "https://fuyao.aicubes.cn"
|
|
# AUV 项目布局:缓存落在 backend/data/(与 DB 同目录,持久化)
|
|
_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
|
TICKERS_CACHE_PATH = _DATA_DIR / "tickers-cache.json"
|
|
TICKERS_CACHE_TTL_SECONDS = 12 * 3600 # 12 hours — intraday, avoids overnight skew
|
|
TEN_YEARS_MS = int(10 * 365.25 * 86400 * 1000)
|
|
RETRY_CODES = {4001, 5001, 5002, 5003}
|
|
MAX_RETRIES = 3
|
|
RETRY_BASE_SECONDS = 1.0
|
|
DEFAULT_TIMEOUT_SECONDS = 30
|
|
|
|
AssetType = Literal[
|
|
"a-share",
|
|
"a-share-index",
|
|
"forex",
|
|
"fund-otc",
|
|
"fund-etf",
|
|
"fund-lof",
|
|
"fund-reits",
|
|
]
|
|
FundType = Literal["otc", "exchange", "reits"]
|
|
FundRange = Literal[
|
|
"week", "month", "tmonth", "hyear", "year", "twoyear", "tyear", "fyear"
|
|
]
|
|
FundNavType = Literal["unit", "adj", "unit,adj"]
|
|
FundHolderMergeScope = Literal["all", "merged", "separate"]
|
|
|
|
_ASSET_TYPES = {
|
|
"a-share",
|
|
"a-share-index",
|
|
"forex",
|
|
"fund-otc",
|
|
"fund-etf",
|
|
"fund-lof",
|
|
"fund-reits",
|
|
}
|
|
_FUND_TYPES = {"otc", "exchange", "reits"}
|
|
_FUND_RANGES = {
|
|
"week", "month", "tmonth", "hyear", "year", "twoyear", "tyear", "fyear"
|
|
}
|
|
_FUND_NAV_TYPES = {"unit", "adj", "unit,adj"}
|
|
_FUND_HOLDER_MERGE_SCOPES = {"all", "merged", "separate"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Errors / session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class FuyaoApiError(RuntimeError):
|
|
"""Raised when the Fuyao API returns a non-zero business code."""
|
|
|
|
def __init__(self, code: int, message: str, request_id: str | None = None):
|
|
super().__init__(f"[fuyao code={code}] {message} (request_id={request_id})")
|
|
self.code = code
|
|
self.message = message
|
|
self.request_id = request_id
|
|
|
|
|
|
@dataclass
|
|
class _ClientConfig:
|
|
base_url: str = BASE_URL
|
|
timeout: int = DEFAULT_TIMEOUT_SECONDS
|
|
session: Optional[requests.Session] = None
|
|
|
|
|
|
_default_config = _ClientConfig()
|
|
|
|
|
|
def _session() -> requests.Session:
|
|
if _default_config.session is None:
|
|
_default_config.session = requests.Session()
|
|
return _default_config.session
|
|
|
|
|
|
def _token() -> str:
|
|
tok = resolve_api_key()
|
|
if not tok:
|
|
raise RuntimeError(
|
|
"HITHINK_FINANCE_API_KEY or the user credential file is required. "
|
|
"Create an API key at https://fuyao.aicubes.cn/admin. "
|
|
"FUYAO_TOKEN and API_KEY remain legacy compatibility sources."
|
|
)
|
|
return tok
|
|
|
|
|
|
def _get(path: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Low-level GET with retry on RETRY_CODES / network errors. Returns the
|
|
response envelope `data` payload; raises FuyaoApiError on business failure.
|
|
"""
|
|
url = f"{_default_config.base_url}{path}"
|
|
clean_params = {k: v for k, v in params.items() if v is not None}
|
|
headers = {"X-api-key": _token()}
|
|
last_exc: Optional[Exception] = None
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
resp = _session().get(
|
|
url,
|
|
params=clean_params,
|
|
headers=headers,
|
|
timeout=_default_config.timeout,
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
last_exc = exc
|
|
time.sleep(RETRY_BASE_SECONDS * (2**attempt))
|
|
continue
|
|
code = payload.get("code", -1)
|
|
if code == 0:
|
|
return payload.get("data") or {}
|
|
if code in RETRY_CODES and attempt < MAX_RETRIES - 1:
|
|
time.sleep(RETRY_BASE_SECONDS * (2**attempt))
|
|
continue
|
|
raise FuyaoApiError(
|
|
code=code,
|
|
message=payload.get("message", ""),
|
|
request_id=payload.get("request_id"),
|
|
)
|
|
if last_exc:
|
|
raise last_exc
|
|
raise RuntimeError("unreachable")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _validate_thscode(thscode: str) -> None:
|
|
if not isinstance(thscode, str) or "." not in thscode:
|
|
raise ValueError(
|
|
f"thscode must include exchange suffix (e.g. '600519.SH'); got {thscode!r}"
|
|
)
|
|
if "," in thscode:
|
|
raise ValueError("single-thscode endpoint does not accept comma-separated input")
|
|
|
|
|
|
def _validate_period(period: str) -> None:
|
|
if period not in ("annual", "quarterly"):
|
|
raise ValueError(f"period must be 'annual' or 'quarterly'; got {period!r}")
|
|
|
|
|
|
def _validate_adjust(adjust: str) -> None:
|
|
if adjust not in ("none", "forward", "backward"):
|
|
raise ValueError(f"adjust must be one of none/forward/backward; got {adjust!r}")
|
|
|
|
|
|
def _normalize_asset_type(
|
|
asset_type: AssetType | str | Iterable[AssetType | str] | None,
|
|
) -> str | None:
|
|
if asset_type is None:
|
|
return None
|
|
raw = asset_type.split(",") if isinstance(asset_type, str) else list(asset_type)
|
|
normalized: list[str] = []
|
|
for value in raw:
|
|
token = value.strip().lower() if isinstance(value, str) else ""
|
|
if not token or token not in _ASSET_TYPES:
|
|
raise ValueError(f"asset_type contains unsupported value: {value!r}")
|
|
if token not in normalized:
|
|
normalized.append(token)
|
|
return ",".join(normalized)
|
|
|
|
|
|
def _validate_fund_target(fund_type: str, thscode: str) -> tuple[str, str]:
|
|
normalized_type = fund_type.strip().lower() if isinstance(fund_type, str) else ""
|
|
if normalized_type not in _FUND_TYPES:
|
|
raise ValueError(f"fund_type must be one of otc/exchange/reits; got {fund_type!r}")
|
|
_validate_thscode(thscode)
|
|
return normalized_type, thscode.strip().upper()
|
|
|
|
|
|
def _validate_exchange_fund_code(thscode: str) -> str:
|
|
_validate_thscode(thscode)
|
|
normalized = thscode.strip().upper()
|
|
if not re.fullmatch(r"[0-9]{6}\.(SH|SZ)", normalized):
|
|
raise ValueError("exchange-traded fund thscode must end in .SH or .SZ")
|
|
return normalized
|
|
|
|
|
|
def _five_year_limit_ms(start_ms: int) -> int:
|
|
start = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc)
|
|
try:
|
|
limit = start.replace(year=start.year + 5)
|
|
except ValueError:
|
|
limit = start.replace(year=start.year + 5, day=28)
|
|
return int(limit.timestamp() * 1000)
|
|
|
|
|
|
def _validate_recent_or_range(
|
|
limit: int | None, start_ms: int | None, end_ms: int | None
|
|
) -> tuple[str, dict[str, Any]]:
|
|
"""Returns ('recent', {'limit': N}) or ('range', {'start': ms, 'end': ms})."""
|
|
has_range = (start_ms is not None) or (end_ms is not None)
|
|
has_limit = limit is not None
|
|
if has_range and has_limit:
|
|
raise ValueError(
|
|
"financials: limit and (start_ms, end_ms) are mutually exclusive"
|
|
)
|
|
if has_range and (start_ms is None or end_ms is None):
|
|
raise ValueError("financials: start_ms and end_ms must be provided together")
|
|
if has_range:
|
|
if end_ms < start_ms: # type: ignore[operator]
|
|
raise ValueError("financials: end_ms must be >= start_ms")
|
|
if end_ms - start_ms > TEN_YEARS_MS: # type: ignore[operator]
|
|
raise ValueError("financials: window must be <= 10 years")
|
|
return "range", {"start": start_ms, "end": end_ms}
|
|
if has_limit:
|
|
if not (1 <= limit <= 20): # type: ignore[operator]
|
|
raise ValueError("financials: limit must be in [1, 20]")
|
|
return "recent", {"limit": limit}
|
|
return "recent", {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Tickers search (with local cache)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_cache() -> tuple[list[dict] | None, float | None]:
|
|
if not TICKERS_CACHE_PATH.exists():
|
|
return None, None
|
|
try:
|
|
blob = json.loads(TICKERS_CACHE_PATH.read_text(encoding="utf-8"))
|
|
return blob.get("item", []), float(blob.get("cached_at", 0))
|
|
except (json.JSONDecodeError, OSError):
|
|
return None, None
|
|
|
|
|
|
def _write_cache(items: list[dict]) -> None:
|
|
TICKERS_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
blob = {"cached_at": time.time(), "item": items}
|
|
TICKERS_CACHE_PATH.write_text(
|
|
json.dumps(blob, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
|
|
|
|
def _cache_is_fresh(cached_at: float | None) -> bool:
|
|
if not cached_at:
|
|
return False
|
|
return (time.time() - cached_at) < TICKERS_CACHE_TTL_SECONDS
|
|
|
|
|
|
def _local_search(
|
|
items: list[dict],
|
|
q: str,
|
|
exchange: str | None,
|
|
asset_type: str | None,
|
|
limit: int,
|
|
) -> list[dict]:
|
|
q_lower = q.lower()
|
|
out: list[dict] = []
|
|
for it in items:
|
|
if exchange and it.get("exchange") != exchange:
|
|
continue
|
|
if asset_type and it.get("asset_type") not in asset_type.split(","):
|
|
continue
|
|
haystack = " ".join(
|
|
str(it.get(k) or "") for k in ("thscode", "ticker", "name")
|
|
).lower()
|
|
if q_lower in haystack:
|
|
out.append(it)
|
|
if len(out) >= limit:
|
|
break
|
|
return out
|
|
|
|
|
|
def tickers_search(
|
|
q: str,
|
|
*,
|
|
exchange: Literal["SH", "SZ", "BJ"] | None = None,
|
|
asset_type: AssetType | str | Iterable[AssetType | str] | None = None,
|
|
limit: int = 10,
|
|
use_cache: bool = True,
|
|
remote: bool = False,
|
|
) -> list[dict]:
|
|
"""Resolve a name/ticker/thscode fragment into TickerItem list.
|
|
|
|
Defaults to local cache (TTL 12h, written by tickers_list(refresh_cache=True)).
|
|
If cache is missing/stale or `remote=True`, queries the upstream search endpoint.
|
|
"""
|
|
if not q:
|
|
raise ValueError("q is required")
|
|
if limit < 1 or limit > 50:
|
|
raise ValueError("limit must be in [1, 50]")
|
|
normalized_asset_type = _normalize_asset_type(asset_type)
|
|
if not remote and use_cache:
|
|
items, cached_at = _load_cache()
|
|
if items is not None:
|
|
if not _cache_is_fresh(cached_at):
|
|
import sys
|
|
|
|
print(
|
|
f"[fuyao] warn: tickers cache stale (>{TICKERS_CACHE_TTL_SECONDS//3600}h); "
|
|
"run `fuyao.py tickers-list --refresh-cache` to refresh",
|
|
file=sys.stderr,
|
|
)
|
|
hits = _local_search(items, q, exchange, normalized_asset_type, limit)
|
|
if hits:
|
|
return hits
|
|
# Fall through to remote when cache misses on this query.
|
|
data = _get(
|
|
"/api/meta/tickers/search",
|
|
{
|
|
"q": q,
|
|
"exchange": exchange,
|
|
"asset_type": normalized_asset_type,
|
|
"limit": limit,
|
|
},
|
|
)
|
|
return data.get("item", [])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Tickers list (with paging + cache refresh)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def tickers_list(
|
|
*,
|
|
exchange: str = "SH,SZ",
|
|
asset_type: AssetType | str | Iterable[AssetType | str] = "a-share",
|
|
limit: int = 1000,
|
|
offset: int = 0,
|
|
fetch_all: bool = False,
|
|
refresh_cache: bool = False,
|
|
) -> list[dict]:
|
|
"""List tickers. With `fetch_all=True`, loops offset until exhausted.
|
|
|
|
When `refresh_cache=True`, implies `fetch_all=True` and writes
|
|
docs/tickers-cache.json for tickers_search to consume.
|
|
"""
|
|
if limit < 1 or limit > 10000:
|
|
raise ValueError("limit must be in [1, 10000]")
|
|
if offset < 0:
|
|
raise ValueError("offset must be >= 0")
|
|
normalized_asset_type = _normalize_asset_type(asset_type)
|
|
if refresh_cache:
|
|
fetch_all = True
|
|
|
|
if not fetch_all:
|
|
data = _get(
|
|
"/api/meta/tickers/list",
|
|
{
|
|
"exchange": exchange,
|
|
"asset_type": normalized_asset_type,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
)
|
|
return data.get("item", [])
|
|
|
|
all_items: list[dict] = []
|
|
cur_offset = offset
|
|
while True:
|
|
data = _get(
|
|
"/api/meta/tickers/list",
|
|
{
|
|
"exchange": exchange,
|
|
"asset_type": normalized_asset_type,
|
|
"limit": limit,
|
|
"offset": cur_offset,
|
|
},
|
|
)
|
|
items = data.get("item", [])
|
|
all_items.extend(items)
|
|
if len(items) < limit:
|
|
break
|
|
cur_offset += limit
|
|
|
|
if refresh_cache:
|
|
_write_cache(all_items)
|
|
return all_items
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Prices snapshot
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def prices_snapshot(
|
|
thscodes: Iterable[str] | None = None,
|
|
*,
|
|
fetch_all_market: bool = False,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> list[dict]:
|
|
"""Snapshot prices. Three modes:
|
|
|
|
- thscodes given: batch by codes (no paging).
|
|
- fetch_all_market=True: page through entire A-share universe until exhausted.
|
|
- neither: single page (default limit=100).
|
|
"""
|
|
if thscodes is not None and fetch_all_market:
|
|
raise ValueError("pass either thscodes or fetch_all_market, not both")
|
|
|
|
if thscodes is not None:
|
|
joined = ",".join(thscodes)
|
|
data = _get("/api/a-share/prices/snapshot", {"thscodes": joined})
|
|
return data.get("item", [])
|
|
|
|
if not fetch_all_market:
|
|
data = _get(
|
|
"/api/a-share/prices/snapshot", {"limit": limit, "offset": offset}
|
|
)
|
|
return data.get("item", [])
|
|
|
|
if limit < 1 or limit > 10000:
|
|
raise ValueError("limit must be in [1, 10000]")
|
|
all_items: list[dict] = []
|
|
cur = offset
|
|
while True:
|
|
data = _get(
|
|
"/api/a-share/prices/snapshot", {"limit": limit, "offset": cur}
|
|
)
|
|
items = data.get("item", [])
|
|
all_items.extend(items)
|
|
if len(items) < limit:
|
|
break
|
|
cur += limit
|
|
return all_items
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Prices historical (with auto-slicing for >10y windows)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def prices_historical(
|
|
thscode: str,
|
|
start_ms: int,
|
|
end_ms: int,
|
|
*,
|
|
interval: Literal["1d"] = "1d",
|
|
adjust: Literal["none", "forward", "backward"] = "forward",
|
|
) -> list[dict]:
|
|
"""Daily K-line for a single thscode. Windows > 10 years are auto-sliced
|
|
and concatenated in chronological order, transparently to the caller.
|
|
"""
|
|
_validate_thscode(thscode)
|
|
_validate_adjust(adjust)
|
|
if interval != "1d":
|
|
raise ValueError("interval: only '1d' is supported currently")
|
|
if not isinstance(start_ms, int) or not isinstance(end_ms, int):
|
|
raise ValueError("start_ms / end_ms must be int milliseconds")
|
|
if end_ms < start_ms:
|
|
raise ValueError("end_ms must be >= start_ms")
|
|
|
|
slices: list[tuple[int, int]] = []
|
|
cur_start = start_ms
|
|
while cur_start < end_ms:
|
|
cur_end = min(cur_start + TEN_YEARS_MS, end_ms)
|
|
slices.append((cur_start, cur_end))
|
|
cur_start = cur_end + 1
|
|
|
|
all_bars: list[dict] = []
|
|
seen_dates: set[int] = set()
|
|
for s, e in slices:
|
|
data = _get(
|
|
"/api/a-share/prices/historical",
|
|
{
|
|
"thscode": thscode,
|
|
"interval": interval,
|
|
"start": s,
|
|
"end": e,
|
|
"adjust": adjust,
|
|
},
|
|
)
|
|
for bar in data.get("item", []):
|
|
d = bar.get("date_ms")
|
|
if d in seen_dates:
|
|
continue
|
|
seen_dates.add(d)
|
|
all_bars.append(bar)
|
|
all_bars.sort(key=lambda b: b.get("date_ms", 0))
|
|
return all_bars
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Corporate actions (adjustment factors)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def corp_actions_adjustment_factors(
|
|
thscode: str,
|
|
*,
|
|
from_date: str | None = None,
|
|
to_date: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Returns the full envelope {thscode, ticker, item: [...]}."""
|
|
_validate_thscode(thscode)
|
|
return _get(
|
|
"/api/a-share/corporate-actions/adjustment-factors",
|
|
{"thscode": thscode, "from": from_date, "to": to_date},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6/7/8. Financials (income / balance / cash-flow)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _financials(
|
|
endpoint: str,
|
|
thscode: str,
|
|
period: str,
|
|
limit: int | None,
|
|
start_ms: int | None,
|
|
end_ms: int | None,
|
|
) -> list[dict]:
|
|
_validate_thscode(thscode)
|
|
_validate_period(period)
|
|
_, mode_params = _validate_recent_or_range(limit, start_ms, end_ms)
|
|
data = _get(
|
|
endpoint,
|
|
{"thscode": thscode, "period": period, **mode_params},
|
|
)
|
|
return data.get("item", [])
|
|
|
|
|
|
def financials_income_statements(
|
|
thscode: str,
|
|
*,
|
|
period: Literal["annual", "quarterly"] = "annual",
|
|
limit: int | None = None,
|
|
start_ms: int | None = None,
|
|
end_ms: int | None = None,
|
|
) -> list[dict]:
|
|
"""Modes are mutually exclusive: (limit) XOR (start_ms+end_ms)."""
|
|
return _financials(
|
|
"/api/a-share/financials/income-statements",
|
|
thscode,
|
|
period,
|
|
limit,
|
|
start_ms,
|
|
end_ms,
|
|
)
|
|
|
|
|
|
def financials_balance_sheets(
|
|
thscode: str,
|
|
*,
|
|
period: Literal["annual", "quarterly"] = "annual",
|
|
limit: int | None = None,
|
|
start_ms: int | None = None,
|
|
end_ms: int | None = None,
|
|
) -> list[dict]:
|
|
return _financials(
|
|
"/api/a-share/financials/balance-sheets",
|
|
thscode,
|
|
period,
|
|
limit,
|
|
start_ms,
|
|
end_ms,
|
|
)
|
|
|
|
|
|
def financials_cash_flow_statements(
|
|
thscode: str,
|
|
*,
|
|
period: Literal["annual", "quarterly"] = "annual",
|
|
limit: int | None = None,
|
|
start_ms: int | None = None,
|
|
end_ms: int | None = None,
|
|
) -> list[dict]:
|
|
return _financials(
|
|
"/api/a-share/financials/cash-flow-statements",
|
|
thscode,
|
|
period,
|
|
limit,
|
|
start_ms,
|
|
end_ms,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Financial indicators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_FINANCIAL_REPORT_PATTERN = re.compile(r"^[0-9]{4}-[1-4]$")
|
|
|
|
|
|
def financials_indicators(thscode: str, report: str) -> dict[str, Any]:
|
|
"""Aggregated financial indicators for one stock and report quarter."""
|
|
_validate_thscode(thscode)
|
|
if not isinstance(report, str) or not _FINANCIAL_REPORT_PATTERN.fullmatch(report):
|
|
raise ValueError("report must match YYYY-[1-4] (e.g. '2025-1')")
|
|
return _get(
|
|
"/api/a-share/financials/indicators",
|
|
{"thscode": thscode, "report": report},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. A-share valuation snapshot
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_VALUATION_MAX_RAW_THSCODES = 100
|
|
|
|
|
|
def a_share_valuations_snapshot(
|
|
thscodes: Iterable[str] | str,
|
|
) -> dict[str, Any]:
|
|
"""Return the current valuation snapshot for 1..100 raw A-share code tokens."""
|
|
raw_codes = (
|
|
thscodes.split(",") if isinstance(thscodes, str) else list(thscodes or [])
|
|
)
|
|
if not raw_codes:
|
|
raise ValueError("thscodes must be non-empty")
|
|
if len(raw_codes) > _VALUATION_MAX_RAW_THSCODES:
|
|
raise ValueError("thscodes accepts at most 100 raw tokens")
|
|
|
|
normalized: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw in raw_codes:
|
|
code = raw.strip().upper() if isinstance(raw, str) else ""
|
|
if not _A_SHARE_THSCODE_PATTERN.fullmatch(code):
|
|
raise ValueError(f"thscodes must contain valid A-share codes; got {raw!r}")
|
|
if code not in seen:
|
|
seen.add(code)
|
|
normalized.append(code)
|
|
|
|
return _get(
|
|
"/api/a-share/valuations/snapshot",
|
|
{"thscodes": ",".join(normalized)},
|
|
)
|
|
|
|
|
|
def a_share_auction_snapshot(
|
|
thscodes: Iterable[str] | str,
|
|
*,
|
|
stage: Literal["live", "final"] = "final",
|
|
) -> dict[str, Any]:
|
|
"""Return auction snapshots; data.timestamp is the response assembly timestamp."""
|
|
if stage not in ("live", "final"):
|
|
raise ValueError("stage must be live or final")
|
|
raw_codes = (
|
|
thscodes.split(",") if isinstance(thscodes, str) else list(thscodes or [])
|
|
)
|
|
if not raw_codes or len(raw_codes) > 100:
|
|
raise ValueError("thscodes must contain 1..100 raw tokens")
|
|
normalized: list[str] = []
|
|
for raw in raw_codes:
|
|
code = _normalize_a_share_thscode(raw)
|
|
if code not in normalized:
|
|
normalized.append(code)
|
|
return _get(
|
|
"/api/a-share/auction/snapshot",
|
|
{"thscodes": ",".join(normalized), "stage": stage},
|
|
)
|
|
|
|
|
|
def a_share_auction_short_term_benchmark(
|
|
*, date: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Return the benchmark; omit date for the Asia/Shanghai current date.
|
|
|
|
The response data includes the resolved date/date_ms and an assembly timestamp.
|
|
"""
|
|
if date is not None:
|
|
_parse_iso_date(date, "date")
|
|
return _get("/api/a-share/auction/short-term-benchmark", {"date": date})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. Calendar
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def calendar_trading_days() -> list[dict]:
|
|
data = _get("/api/a-share/calendar/trading-days", {})
|
|
return data.get("item", [])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11/12. A-share index — catalog & constituents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_THS_INDEX_TAGS = ("cn_concept", "region", "tszs", "industry")
|
|
|
|
|
|
def index_catalog_ths_index_list(
|
|
tag: Literal["cn_concept", "region", "tszs", "industry"] = "cn_concept",
|
|
) -> list[dict]:
|
|
"""List 同花顺指数 (whole tag dump, no paging)."""
|
|
if tag.lower() not in _THS_INDEX_TAGS:
|
|
raise ValueError(f"tag must be one of {_THS_INDEX_TAGS}; got {tag!r}")
|
|
data = _get(
|
|
"/api/a-share-index/catalog/ths-index-list", {"tag": tag.lower()}
|
|
)
|
|
return data.get("item", [])
|
|
|
|
|
|
def index_constituents_ths_stock_list(thscode: str) -> list[dict]:
|
|
"""Current constituents of a single index (THS block or standard index like 000300.SH)."""
|
|
_validate_thscode(thscode)
|
|
data = _get(
|
|
"/api/a-share-index/constituents/ths-stock-list", {"thscode": thscode}
|
|
)
|
|
return data.get("item", [])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 13/14. A-share index — prices snapshot & historical
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def index_prices_snapshot(thscodes: Iterable[str]) -> list[dict]:
|
|
"""Index snapshot — batch by thscodes ONLY. Empty input is rejected upstream
|
|
(unlike a-share snapshot, there is no full-market mode for indices).
|
|
"""
|
|
codes = list(thscodes) if thscodes is not None else []
|
|
if not codes:
|
|
raise ValueError("index_prices_snapshot requires non-empty thscodes")
|
|
data = _get(
|
|
"/api/a-share-index/prices/snapshot", {"thscodes": ",".join(codes)}
|
|
)
|
|
return data.get("item", [])
|
|
|
|
|
|
def index_prices_historical(
|
|
thscode: str,
|
|
start_ms: int,
|
|
end_ms: int,
|
|
*,
|
|
interval: Literal["1d", "1w", "1mo"] = "1d",
|
|
) -> list[dict]:
|
|
"""Index historical K-line for a single thscode. Auto-slices >10y windows.
|
|
|
|
Indices have no adjust / offset semantics; both are absent from the upstream contract.
|
|
"""
|
|
_validate_thscode(thscode)
|
|
if interval not in ("1d", "1w", "1mo"):
|
|
raise ValueError(f"interval must be one of 1d/1w/1mo; got {interval!r}")
|
|
if not isinstance(start_ms, int) or not isinstance(end_ms, int):
|
|
raise ValueError("start_ms / end_ms must be int milliseconds")
|
|
if end_ms < start_ms:
|
|
raise ValueError("end_ms must be >= start_ms")
|
|
|
|
slices: list[tuple[int, int]] = []
|
|
cur = start_ms
|
|
while cur < end_ms:
|
|
nxt = min(cur + TEN_YEARS_MS, end_ms)
|
|
slices.append((cur, nxt))
|
|
cur = nxt + 1
|
|
|
|
all_bars: list[dict] = []
|
|
seen: set[int] = set()
|
|
for s, e in slices:
|
|
data = _get(
|
|
"/api/a-share-index/prices/historical",
|
|
{"thscode": thscode, "interval": interval, "start": s, "end": e},
|
|
)
|
|
for bar in data.get("item", []):
|
|
d = bar.get("date_ms")
|
|
if d in seen:
|
|
continue
|
|
seen.add(d)
|
|
all_bars.append(bar)
|
|
all_bars.sort(key=lambda b: b.get("date_ms", 0))
|
|
return all_bars
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 15-21. Fund profile, performance, holders, and exchange market data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fund_detail(
|
|
path: str, thscode: str, fund_type: FundType | str
|
|
) -> dict[str, Any]:
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
return _get(path, {"fund_type": normalized_type, "thscode": normalized_code})
|
|
|
|
|
|
def fund_profile_detail(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
"""Fund profile for one explicitly typed fund target."""
|
|
return _fund_detail("/api/fund/profile/detail", thscode, fund_type)
|
|
|
|
|
|
def fund_portfolio_holdings(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
"""Fund portfolio holdings for one explicitly typed target."""
|
|
return _fund_detail("/api/fund/portfolio/holdings", thscode, fund_type)
|
|
|
|
|
|
def fund_performance_nav(
|
|
thscode: str,
|
|
*,
|
|
fund_type: FundType,
|
|
range: FundRange | None = None,
|
|
nav_type: FundNavType = "unit,adj",
|
|
) -> dict[str, Any]:
|
|
"""Fund NAV; omit range for the latest point."""
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
if range is not None and range not in _FUND_RANGES:
|
|
raise ValueError(f"range must be one of {sorted(_FUND_RANGES)}; got {range!r}")
|
|
if nav_type not in _FUND_NAV_TYPES:
|
|
raise ValueError(
|
|
f"nav_type must be one of unit/adj/unit,adj; got {nav_type!r}"
|
|
)
|
|
return _get(
|
|
"/api/fund/performance/nav",
|
|
{
|
|
"fund_type": normalized_type,
|
|
"thscode": normalized_code,
|
|
"range": range,
|
|
"nav_type": nav_type,
|
|
},
|
|
)
|
|
|
|
|
|
def fund_performance_returns(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
"""Fund interval-return summary for one explicitly typed target."""
|
|
return _fund_detail("/api/fund/performance/returns", thscode, fund_type)
|
|
|
|
|
|
def fund_holders_detail(
|
|
thscode: str,
|
|
*,
|
|
fund_type: FundType,
|
|
merge_scope: FundHolderMergeScope | str = "all",
|
|
) -> dict[str, Any]:
|
|
"""Fund holder structure by merged, separate, or all disclosure scopes."""
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
normalized_scope = (
|
|
merge_scope.strip().lower() if isinstance(merge_scope, str) else ""
|
|
)
|
|
if normalized_scope not in _FUND_HOLDER_MERGE_SCOPES:
|
|
raise ValueError(
|
|
"merge_scope must be one of all/merged/separate; "
|
|
f"got {merge_scope!r}"
|
|
)
|
|
return _get(
|
|
"/api/fund/holders/detail",
|
|
{
|
|
"fund_type": normalized_type,
|
|
"thscode": normalized_code,
|
|
"merge_scope": normalized_scope,
|
|
},
|
|
)
|
|
|
|
|
|
def fund_market_snapshot(thscode: str) -> dict[str, Any]:
|
|
"""Market snapshot for one exchange-traded ETF/LOF target."""
|
|
if isinstance(thscode, str) and "," in thscode:
|
|
raise ValueError("single-thscode endpoint does not accept comma-separated input")
|
|
normalized = _validate_exchange_fund_code(thscode)
|
|
return _get("/api/fund/market/snapshot", {"thscode": normalized})
|
|
|
|
|
|
def fund_market_historical(
|
|
thscode: str,
|
|
start_ms: int,
|
|
end_ms: int,
|
|
*,
|
|
interval: Literal["1d"] = "1d",
|
|
) -> dict[str, Any]:
|
|
"""Daily ETF price history for a single target and a maximum five-year window."""
|
|
normalized = _validate_exchange_fund_code(thscode)
|
|
if interval != "1d":
|
|
raise ValueError("interval must be 1d")
|
|
if not isinstance(start_ms, int) or not isinstance(end_ms, int):
|
|
raise ValueError("start_ms / end_ms must be int milliseconds")
|
|
if end_ms < start_ms:
|
|
raise ValueError("end_ms must be >= start_ms")
|
|
if end_ms > _five_year_limit_ms(start_ms):
|
|
raise ValueError("fund history window must not exceed five years")
|
|
return _get(
|
|
"/api/fund/market/historical",
|
|
{
|
|
"thscode": normalized,
|
|
"interval": interval,
|
|
"start": start_ms,
|
|
"end": end_ms,
|
|
},
|
|
)
|
|
|
|
|
|
def _required_identifier(value: str, field_name: str) -> str:
|
|
normalized = value.strip() if isinstance(value, str) else ""
|
|
if not normalized:
|
|
raise ValueError(f"{field_name} must be non-empty")
|
|
return normalized
|
|
|
|
|
|
def fund_companies_detail(company_id: str) -> dict[str, Any]:
|
|
return _get(
|
|
"/api/fund/companies/detail",
|
|
{"company_id": _required_identifier(company_id, "company_id")},
|
|
)
|
|
|
|
|
|
def fund_portfolio_industry_allocation(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail(
|
|
"/api/fund/portfolio/industry-allocation", thscode, fund_type
|
|
)
|
|
|
|
|
|
def fund_performance_indicators_historical(
|
|
thscode: str,
|
|
start_ms: int,
|
|
end_ms: int,
|
|
*,
|
|
fund_type: FundType,
|
|
) -> dict[str, Any]:
|
|
"""Return DataPayload data with timestamp and item only; no top-level thscode/interval."""
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
if not isinstance(start_ms, int) or not isinstance(end_ms, int):
|
|
raise ValueError("start_ms / end_ms must be int milliseconds")
|
|
if end_ms < start_ms:
|
|
raise ValueError("end_ms must be >= start_ms")
|
|
if end_ms > _five_year_limit_ms(start_ms):
|
|
raise ValueError("indicator history window must not exceed five years")
|
|
return _get(
|
|
"/api/fund/performance/indicators-historical",
|
|
{
|
|
"fund_type": normalized_type,
|
|
"thscode": normalized_code,
|
|
"start": start_ms,
|
|
"end": end_ms,
|
|
},
|
|
)
|
|
|
|
|
|
def fund_performance_drawdowns(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail("/api/fund/performance/drawdowns", thscode, fund_type)
|
|
|
|
|
|
def fund_holders_top(
|
|
thscode: str, *, fund_type: FundType, limit: int | None = None
|
|
) -> dict[str, Any]:
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
if limit is not None and (not isinstance(limit, int) or not 1 <= limit <= 10):
|
|
raise ValueError("limit must be in [1, 10]")
|
|
return _get(
|
|
"/api/fund/holders/top",
|
|
{"fund_type": normalized_type, "thscode": normalized_code, "limit": limit},
|
|
)
|
|
|
|
|
|
def fund_corporate_actions_dividends(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail(
|
|
"/api/fund/corporate-actions/dividends", thscode, fund_type
|
|
)
|
|
|
|
|
|
def fund_diagnostics_detail(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail("/api/fund/diagnostics/detail", thscode, fund_type)
|
|
|
|
|
|
def fund_financials_indicators(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail("/api/fund/financials/indicators", thscode, fund_type)
|
|
|
|
|
|
def fund_financials_income_statements(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail(
|
|
"/api/fund/financials/income-statements", thscode, fund_type
|
|
)
|
|
|
|
|
|
def fund_financials_balance_sheets(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail("/api/fund/financials/balance-sheets", thscode, fund_type)
|
|
|
|
|
|
def _fund_manager(path: str, manager_id: str) -> dict[str, Any]:
|
|
return _get(path, {"manager_id": _required_identifier(manager_id, "manager_id")})
|
|
|
|
|
|
def fund_managers_investment_style(manager_id: str) -> dict[str, Any]:
|
|
return _fund_manager("/api/fund/managers/investment-style", manager_id)
|
|
|
|
|
|
def fund_managers_performance(
|
|
manager_id: str,
|
|
*,
|
|
range: Literal["month", "tmonth", "year", "nowyear", "now"],
|
|
) -> dict[str, Any]:
|
|
if range not in ("month", "tmonth", "year", "nowyear", "now"):
|
|
raise ValueError("range must be month/tmonth/year/nowyear/now")
|
|
return _get(
|
|
"/api/fund/managers/performance",
|
|
{
|
|
"manager_id": _required_identifier(manager_id, "manager_id"),
|
|
"range": range,
|
|
},
|
|
)
|
|
|
|
|
|
def fund_managers_experience(manager_id: str) -> dict[str, Any]:
|
|
return _fund_manager("/api/fund/managers/experience", manager_id)
|
|
|
|
|
|
def fund_managers_detail(manager_id: str) -> dict[str, Any]:
|
|
return _fund_manager("/api/fund/managers/detail", manager_id)
|
|
|
|
|
|
def fund_news_article_list(
|
|
thscode: str,
|
|
*,
|
|
fund_type: FundType,
|
|
limit: int = 20,
|
|
offset: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return cursor-paginated news data with has_more and no total field."""
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
if not isinstance(limit, int) or not 1 <= limit <= 100:
|
|
raise ValueError("limit must be in [1, 100]")
|
|
if offset is not None and (not isinstance(offset, str) or not offset):
|
|
raise ValueError("offset must be a non-empty opaque cursor")
|
|
return _get(
|
|
"/api/fund/news/article-list",
|
|
{
|
|
"fund_type": normalized_type,
|
|
"thscode": normalized_code,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
)
|
|
|
|
|
|
def fund_offerings_list(
|
|
subscribe: Literal["active", "upcoming"],
|
|
) -> dict[str, Any]:
|
|
if subscribe not in ("active", "upcoming"):
|
|
raise ValueError("subscribe must be active or upcoming")
|
|
return _get("/api/fund/offerings/list", {"subscribe": subscribe})
|
|
|
|
|
|
def _fund_portfolio_history(
|
|
path: str,
|
|
thscode: str,
|
|
report_type: str,
|
|
end_date: str,
|
|
fund_type: FundType,
|
|
) -> dict[str, Any]:
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
return _get(
|
|
path,
|
|
{
|
|
"fund_type": normalized_type,
|
|
"thscode": normalized_code,
|
|
"report_type": _required_identifier(report_type, "report_type"),
|
|
"end_date": _required_identifier(end_date, "end_date"),
|
|
},
|
|
)
|
|
|
|
|
|
def fund_portfolio_stock_history(
|
|
thscode: str, report_type: str, end_date: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_portfolio_history(
|
|
"/api/fund/portfolio/stock-history",
|
|
thscode,
|
|
report_type,
|
|
end_date,
|
|
fund_type,
|
|
)
|
|
|
|
|
|
def fund_portfolio_bond_history(
|
|
thscode: str, report_type: str, end_date: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_portfolio_history(
|
|
"/api/fund/portfolio/bond-history",
|
|
thscode,
|
|
report_type,
|
|
end_date,
|
|
fund_type,
|
|
)
|
|
|
|
|
|
def _fund_report_dates(
|
|
path: str,
|
|
thscode: str,
|
|
fund_type: FundType,
|
|
report_type: str | None,
|
|
) -> dict[str, Any]:
|
|
normalized_type, normalized_code = _validate_fund_target(fund_type, thscode)
|
|
if report_type is not None:
|
|
report_type = _required_identifier(report_type, "report_type")
|
|
return _get(
|
|
path,
|
|
{
|
|
"fund_type": normalized_type,
|
|
"thscode": normalized_code,
|
|
"report_type": report_type,
|
|
},
|
|
)
|
|
|
|
|
|
def fund_portfolio_stock_report_dates(
|
|
thscode: str, *, fund_type: FundType, report_type: str | None = None
|
|
) -> dict[str, Any]:
|
|
return _fund_report_dates(
|
|
"/api/fund/portfolio/stock-report-dates", thscode, fund_type, report_type
|
|
)
|
|
|
|
|
|
def fund_portfolio_bond_report_dates(
|
|
thscode: str, *, fund_type: FundType, report_type: str | None = None
|
|
) -> dict[str, Any]:
|
|
return _fund_report_dates(
|
|
"/api/fund/portfolio/bond-report-dates", thscode, fund_type, report_type
|
|
)
|
|
|
|
|
|
def fund_portfolio_asset_allocation(
|
|
thscode: str, *, fund_type: FundType
|
|
) -> dict[str, Any]:
|
|
return _fund_detail("/api/fund/portfolio/asset-allocation", thscode, fund_type)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 22/23. Special data — limit-up pool & limit-up ladder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_LIMIT_UP_SORT_FIELDS = ("last_price", "continue_day_cnt", "seal_money", "limit_up_time")
|
|
|
|
|
|
def special_data_limit_up_pool(
|
|
*,
|
|
date_ms: int | None = None,
|
|
page: int = 1,
|
|
size: int = 50,
|
|
sort_field: Literal[
|
|
"last_price", "continue_day_cnt", "seal_money", "limit_up_time"
|
|
] = "last_price",
|
|
sort_dir: Literal["asc", "desc"] = "desc",
|
|
) -> dict[str, Any]:
|
|
"""涨停股票池 — returns the full envelope {timestamp, pagination, item: [...]}.
|
|
|
|
Pagination is exposed (size 1-200) so callers can drive their own loop.
|
|
Omit date_ms to fall back to today (Asia/Shanghai).
|
|
"""
|
|
if page < 1:
|
|
raise ValueError("page must be >= 1")
|
|
if not (1 <= size <= 200):
|
|
raise ValueError("size must be in [1, 200]")
|
|
if sort_field not in _LIMIT_UP_SORT_FIELDS:
|
|
raise ValueError(
|
|
f"sort_field must be one of {_LIMIT_UP_SORT_FIELDS}; got {sort_field!r}"
|
|
)
|
|
if sort_dir not in ("asc", "desc"):
|
|
raise ValueError("sort_dir must be 'asc' or 'desc'")
|
|
return _get(
|
|
"/api/a-share/special-data/limit-up-pool",
|
|
{
|
|
"date_ms": date_ms,
|
|
"page": page,
|
|
"size": size,
|
|
"sort_field": sort_field,
|
|
"sort_dir": sort_dir,
|
|
},
|
|
)
|
|
|
|
|
|
def _special_data_pool(
|
|
path: str,
|
|
sort_fields: tuple[str, ...],
|
|
*,
|
|
date_ms: int | None,
|
|
page: int,
|
|
size: int,
|
|
sort_field: str,
|
|
sort_dir: str,
|
|
) -> dict[str, Any]:
|
|
if page < 1:
|
|
raise ValueError("page must be >= 1")
|
|
if not 1 <= size <= 200:
|
|
raise ValueError("size must be in [1, 200]")
|
|
if sort_field not in sort_fields:
|
|
raise ValueError(f"sort_field must be one of {sort_fields}")
|
|
if sort_dir not in ("asc", "desc"):
|
|
raise ValueError("sort_dir must be asc or desc")
|
|
return _get(
|
|
path,
|
|
{
|
|
"date_ms": date_ms,
|
|
"page": page,
|
|
"size": size,
|
|
"sort_field": sort_field,
|
|
"sort_dir": sort_dir,
|
|
},
|
|
)
|
|
|
|
|
|
def special_data_limit_down_pool(
|
|
*,
|
|
date_ms: int | None = None,
|
|
page: int = 1,
|
|
size: int = 50,
|
|
sort_field: str = "last_limit_time",
|
|
sort_dir: Literal["asc", "desc"] = "desc",
|
|
) -> dict[str, Any]:
|
|
return _special_data_pool(
|
|
"/api/a-share/special-data/limit-down-pool",
|
|
(
|
|
"last_limit_time",
|
|
"first_limit_time",
|
|
"last_price",
|
|
"price_change_ratio_pct",
|
|
"turnover_ratio_pct",
|
|
),
|
|
date_ms=date_ms,
|
|
page=page,
|
|
size=size,
|
|
sort_field=sort_field,
|
|
sort_dir=sort_dir,
|
|
)
|
|
|
|
|
|
def special_data_limit_break_pool(
|
|
*,
|
|
date_ms: int | None = None,
|
|
page: int = 1,
|
|
size: int = 50,
|
|
sort_field: str = "price_change_ratio_pct",
|
|
sort_dir: Literal["asc", "desc"] = "desc",
|
|
) -> dict[str, Any]:
|
|
return _special_data_pool(
|
|
"/api/a-share/special-data/limit-break-pool",
|
|
(
|
|
"price_change_ratio_pct",
|
|
"open_times",
|
|
"last_price",
|
|
"turnover_ratio_pct",
|
|
"turnover",
|
|
),
|
|
date_ms=date_ms,
|
|
page=page,
|
|
size=size,
|
|
sort_field=sort_field,
|
|
sort_dir=sort_dir,
|
|
)
|
|
|
|
|
|
def special_data_limit_up_ladder() -> dict[str, Any]:
|
|
"""连板天梯 — returns full envelope {timestamp, window, item: [...]}.
|
|
|
|
No input params; upstream fixes the window at 30 trading days, board cap 4 each.
|
|
"""
|
|
return _get("/api/a-share/special-data/limit-up-ladder", {})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 17/18. Special data — same-day anomaly analysis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_ANOMALY_TAG_CODES = (
|
|
"LIMIT_UP",
|
|
"LIMIT_DOWN",
|
|
"SHARP_RISE",
|
|
"SHARP_FALL",
|
|
"RAPID_RALLY",
|
|
"RAPID_DECLINE",
|
|
)
|
|
_A_SHARE_THSCODE_PATTERN = re.compile(r"^[0-9]{6}\.(SH|SZ|BJ)$")
|
|
_ANOMALY_STOCK_MAX_THSCODES = 50
|
|
|
|
|
|
def special_data_anomaly_analysis_list(
|
|
tag_codes: Iterable[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Same-day anomaly list; optional tags are combined with OR semantics."""
|
|
raw_codes = [tag_codes] if isinstance(tag_codes, str) else list(tag_codes or [])
|
|
normalized: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw in raw_codes:
|
|
code = raw.strip().upper() if isinstance(raw, str) else ""
|
|
if not code:
|
|
raise ValueError("tag_codes contains an empty token")
|
|
if code not in _ANOMALY_TAG_CODES:
|
|
raise ValueError(
|
|
f"tag_codes must contain only {_ANOMALY_TAG_CODES}; got {raw!r}"
|
|
)
|
|
if code not in seen:
|
|
seen.add(code)
|
|
normalized.append(code)
|
|
return _get(
|
|
"/api/a-share/special-data/anomaly-analysis-list",
|
|
{"tag_codes": ",".join(normalized) if normalized else None},
|
|
)
|
|
|
|
|
|
def special_data_anomaly_analysis_stock(
|
|
thscodes: Iterable[str],
|
|
) -> dict[str, Any]:
|
|
"""Same-day anomaly rows for 1..50 raw A-share thscode tokens."""
|
|
raw_codes = [thscodes] if isinstance(thscodes, str) else list(thscodes or [])
|
|
if not raw_codes:
|
|
raise ValueError("thscodes must contain at least one code")
|
|
if len(raw_codes) > _ANOMALY_STOCK_MAX_THSCODES:
|
|
raise ValueError(
|
|
f"thscodes count must not exceed {_ANOMALY_STOCK_MAX_THSCODES}"
|
|
)
|
|
|
|
normalized: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw in raw_codes:
|
|
code = raw.strip().upper() if isinstance(raw, str) else ""
|
|
if not code:
|
|
raise ValueError("thscodes contains an empty token")
|
|
if not _A_SHARE_THSCODE_PATTERN.fullmatch(code):
|
|
raise ValueError(f"Invalid thscode: {raw!r}")
|
|
if code not in seen:
|
|
seen.add(code)
|
|
normalized.append(code)
|
|
|
|
return _get(
|
|
"/api/a-share/special-data/anomaly-analysis-stock",
|
|
{"thscodes": ",".join(normalized)},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 19-23. Special data — hot lists, rank trend & dragon-tiger list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_HOT_LIST_PERIODS = ("day", "hour")
|
|
_DRAGON_TIGER_BOARD_TYPES = ("all", "org", "hot_money")
|
|
|
|
|
|
def _normalize_hot_list_period(period: str) -> str:
|
|
normalized = period.strip().lower() if isinstance(period, str) else ""
|
|
if normalized not in _HOT_LIST_PERIODS:
|
|
raise ValueError(f"period must be one of {_HOT_LIST_PERIODS}; got {period!r}")
|
|
return normalized
|
|
|
|
|
|
def _parse_iso_date(value: str, field_name: str) -> Date:
|
|
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
|
raise ValueError(f"{field_name} must use YYYY-MM-DD format")
|
|
try:
|
|
return Date.fromisoformat(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{field_name} must be a valid calendar date") from exc
|
|
|
|
|
|
def _normalize_a_share_thscode(thscode: str) -> str:
|
|
normalized = thscode.strip().upper() if isinstance(thscode, str) else ""
|
|
if not _A_SHARE_THSCODE_PATTERN.fullmatch(normalized):
|
|
raise ValueError(f"Invalid thscode: {thscode!r}")
|
|
return normalized
|
|
|
|
|
|
def special_data_skyrocket_list(
|
|
period: Literal["day", "hour"] = "day",
|
|
) -> dict[str, Any]:
|
|
"""Current skyrocket ranking for the day or hour period."""
|
|
return _get(
|
|
"/api/a-share/special-data/skyrocket-list",
|
|
{"period": _normalize_hot_list_period(period)},
|
|
)
|
|
|
|
|
|
def special_data_hot_stock_list(
|
|
period: Literal["day", "hour"] = "day",
|
|
) -> dict[str, Any]:
|
|
"""Current hot-stock ranking for the day or hour period."""
|
|
return _get(
|
|
"/api/a-share/special-data/hot-stock-list",
|
|
{"period": _normalize_hot_list_period(period)},
|
|
)
|
|
|
|
|
|
def special_data_hot_stock_list_history(date: str) -> dict[str, Any]:
|
|
"""Historical hot-stock ranking for one date within the server's window."""
|
|
_parse_iso_date(date, "date")
|
|
return _get(
|
|
"/api/a-share/special-data/hot-stock-list-history",
|
|
{"date": date},
|
|
)
|
|
|
|
|
|
def special_data_hot_stock_rank_trend(
|
|
thscode: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> dict[str, Any]:
|
|
"""Daily hot-stock rank trend for one A-share code over at most one year."""
|
|
normalized_thscode = _normalize_a_share_thscode(thscode)
|
|
start = _parse_iso_date(start_date, "start_date")
|
|
end = _parse_iso_date(end_date, "end_date")
|
|
if start > end:
|
|
raise ValueError("start_date must be before or equal to end_date")
|
|
try:
|
|
one_year_later = start.replace(year=start.year + 1)
|
|
except ValueError:
|
|
one_year_later = start.replace(year=start.year + 1, day=28)
|
|
if end > one_year_later:
|
|
raise ValueError("date range must not exceed one year")
|
|
return _get(
|
|
"/api/a-share/special-data/hot-stock-rank-trend",
|
|
{
|
|
"thscode": normalized_thscode,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
},
|
|
)
|
|
|
|
|
|
def special_data_dragon_tiger_list(
|
|
*,
|
|
board_type: Literal["all", "org", "hot_money"] = "all",
|
|
date: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Dragon-tiger list, optionally filtered by board type and trade date."""
|
|
normalized_board_type = (
|
|
board_type.strip().lower() if isinstance(board_type, str) else ""
|
|
)
|
|
if normalized_board_type not in _DRAGON_TIGER_BOARD_TYPES:
|
|
raise ValueError(
|
|
f"board_type must be one of {_DRAGON_TIGER_BOARD_TYPES}; got {board_type!r}"
|
|
)
|
|
if date is not None:
|
|
_parse_iso_date(date, "date")
|
|
return _get(
|
|
"/api/a-share/special-data/dragon-tiger-list",
|
|
{"board_type": normalized_board_type, "date": date},
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"FuyaoApiError",
|
|
"tickers_search",
|
|
"tickers_list",
|
|
"prices_snapshot",
|
|
"prices_historical",
|
|
"corp_actions_adjustment_factors",
|
|
"financials_income_statements",
|
|
"financials_balance_sheets",
|
|
"financials_cash_flow_statements",
|
|
"financials_indicators",
|
|
"a_share_valuations_snapshot",
|
|
"a_share_auction_snapshot",
|
|
"a_share_auction_short_term_benchmark",
|
|
"calendar_trading_days",
|
|
"index_catalog_ths_index_list",
|
|
"index_constituents_ths_stock_list",
|
|
"index_prices_snapshot",
|
|
"index_prices_historical",
|
|
"fund_profile_detail",
|
|
"fund_portfolio_holdings",
|
|
"fund_performance_nav",
|
|
"fund_performance_returns",
|
|
"fund_holders_detail",
|
|
"fund_market_snapshot",
|
|
"fund_market_historical",
|
|
"fund_companies_detail",
|
|
"fund_portfolio_industry_allocation",
|
|
"fund_performance_indicators_historical",
|
|
"fund_performance_drawdowns",
|
|
"fund_holders_top",
|
|
"fund_corporate_actions_dividends",
|
|
"fund_diagnostics_detail",
|
|
"fund_financials_indicators",
|
|
"fund_financials_income_statements",
|
|
"fund_financials_balance_sheets",
|
|
"fund_managers_investment_style",
|
|
"fund_managers_performance",
|
|
"fund_managers_experience",
|
|
"fund_managers_detail",
|
|
"fund_news_article_list",
|
|
"fund_offerings_list",
|
|
"fund_portfolio_stock_history",
|
|
"fund_portfolio_stock_report_dates",
|
|
"fund_portfolio_bond_history",
|
|
"fund_portfolio_bond_report_dates",
|
|
"fund_portfolio_asset_allocation",
|
|
"special_data_limit_up_pool",
|
|
"special_data_limit_down_pool",
|
|
"special_data_limit_break_pool",
|
|
"special_data_limit_up_ladder",
|
|
"special_data_anomaly_analysis_list",
|
|
"special_data_anomaly_analysis_stock",
|
|
"special_data_skyrocket_list",
|
|
"special_data_hot_stock_list",
|
|
"special_data_hot_stock_list_history",
|
|
"special_data_hot_stock_rank_trend",
|
|
"special_data_dragon_tiger_list",
|
|
]
|