diff --git a/.gitignore b/.gitignore index bf76f76..670fad8 100755 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,4 @@ tmp/ /core /.core.hmbtNy /.core.dump +.v2-demo-backup/ diff --git a/backend/main.py b/backend/main.py index f2ce74e..f5b8262 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager from dotenv import load_dotenv from database import init_db -from routes import stock, collections, shares, themes, core_stocks +from routes import stock, collections, shares, themes, core_stocks, fuyao from services.daily_collector import collector_loop, cache_cleanup_loop load_dotenv() @@ -45,18 +45,26 @@ app.include_router(collections.router, prefix="/api/collections") app.include_router(shares.router, prefix="/api/share") app.include_router(themes.router, prefix="/api/themes") app.include_router(core_stocks.router, prefix="/api/core-stocks") +app.include_router(fuyao.router, prefix="/api/v2") # 生产模式:后端同时托管前端静态文件 # catch-all 路由在 API 路由之后注册,所以 API 优先级更高 dist_path = os.path.join(os.path.dirname(__file__), "dist") + +# HTML 不缓存:保证 index.html 永远最新(引用的资源文件名带 hash,可长缓存) +_NO_CACHE_HTML = {"Cache-Control": "no-cache, no-store, must-revalidate"} + if os.path.isdir(dist_path): @app.get("/{full_path:path}") async def serve_spa(full_path: str): file_path = os.path.join(dist_path, full_path) if full_path else os.path.join(dist_path, "index.html") if os.path.isfile(file_path): + # 静态资源(带 hash 的文件名)可缓存;HTML 不缓存 + if file_path.endswith(".html"): + return FileResponse(file_path, headers=_NO_CACHE_HTML) return FileResponse(file_path) # SPA fallback: 非文件路径统一返回 index.html index_path = os.path.join(dist_path, "index.html") if os.path.isfile(index_path): - return FileResponse(index_path, media_type="text/html") + return FileResponse(index_path, media_type="text/html", headers=_NO_CACHE_HTML) return JSONResponse({"detail": "Not Found"}, status_code=404) diff --git a/backend/requirements.txt b/backend/requirements.txt index ee4045a..cda15a7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,6 @@ uvicorn==0.30.0 httpx==0.27.0 python-dotenv==1.0.1 akshare==1.18.64 -mootdx \ No newline at end of file +mootdx +requests>=2.31,<3 +pypinyin \ No newline at end of file diff --git a/backend/routes/fuyao.py b/backend/routes/fuyao.py new file mode 100644 index 0000000..095bc59 --- /dev/null +++ b/backend/routes/fuyao.py @@ -0,0 +1,165 @@ +"""v2 数据接口路由:同花顺官方金融数据 API(/api/v2) + +保留现有 /api/* 为 v1(腾讯/东财/新浪等抓取源),本模块提供独立 v2。 +数据源:https://fuyao.aicubes.cn(同花顺官方),密钥仅后端持有。 + +接口返回统一使用现有 v1 的 data 信封风格:{"data": ..., "count": ...}, +上游错误转 HTTPException,密钥永不出现在响应中。 +""" + +from fastapi import APIRouter, Query, HTTPException +from fastapi.responses import JSONResponse +from services import fuyao_client + +router = APIRouter() + +# 上游反代/CDN 可能按 path 缓存,显式禁止缓存 +_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"} + + +async def _guard(awaitable): + """调用 fuyao_client,把上游/校验错误统一转 HTTPException""" + try: + return await awaitable + except fuyao_client.FuyaoError as e: + # 上游业务错误,message 来自上游,不含密钥 + raise HTTPException(status_code=502, detail=f"同花顺API: {e}") + except ValueError as e: + # SDK 参数校验错误(如 thscode 格式) + raise HTTPException(status_code=400, detail=f"参数错误: {e}") + + +# --------------------------------------------------------------- +# 基础数据 +# --------------------------------------------------------------- + +@router.get("/meta/tickers/search", summary="v2 标的检索") +async def v2_ticker_search( + q: str = Query(..., description="代码/名称/拼音关键词"), + exchange: str = Query(None, description="交易所 SH/SZ/BJ"), + asset_type: str = Query(None, description="资产类型 a-share 等"), + limit: int = Query(10, ge=1, le=50), +): + items = await _guard(fuyao_client.ticker_search(q, exchange, asset_type, limit)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/meta/tickers/list", summary="v2 标的列表(分页)") +async def v2_ticker_list( + asset_type: str = Query(None, description="资产类型"), + limit: int = Query(100, ge=1, le=10000), + offset: int = Query(0, ge=0), +): + items = await _guard(fuyao_client.ticker_list(asset_type, limit, offset)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +# --------------------------------------------------------------- +# A股行情 / 日历 / 竞价 +# --------------------------------------------------------------- + +@router.get("/prices/snapshot", summary="v2 行情快照(单只/多只)") +async def v2_prices_snapshot(thscodes: str = Query(..., description="逗号分隔的 thscode,如 600519.SH,000001.SZ")): + items = await _guard(fuyao_client.prices_snapshot(thscodes)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/prices/historical", summary="v2 历史日K(窗口≤10年)") +async def v2_prices_historical( + thscode: str = Query(..., description="标的 thscode,单只"), + start: int = Query(..., description="起始时间,毫秒 Unix 时间戳"), + end: int = Query(..., description="结束时间,毫秒 Unix 时间戳"), + adjust: str = Query("forward", description="复权 none/forward/backward"), +): + try: + items = await fuyao_client.prices_historical(thscode, start, end, adjust) + except fuyao_client.FuyaoError as e: + raise HTTPException(status_code=502, detail=f"同花顺API: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=f"参数错误: {e}") + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/calendar/trading-days", summary="v2 近一年交易日序列") +async def v2_calendar(): + items = await _guard(fuyao_client.calendar_trading_days()) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/auction/snapshot", summary="v2 集合竞价快照") +async def v2_auction_snapshot( + thscodes: str = Query(..., description="逗号分隔 thscode"), + stage: str = Query("final", description="live 实时 / final 终态"), +): + data = await _guard(fuyao_client.auction_snapshot(thscodes, stage)) + return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS) + + +@router.get("/auction/short-term-benchmark", summary="v2 短线风向标竞价基准") +async def v2_auction_benchmark(date: str = Query(None, description="日期 yyyy-MM-dd")): + data = await _guard(fuyao_client.auction_short_term_benchmark(date)) + return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS) + + +# --------------------------------------------------------------- +# A股财务 / 估值 +# --------------------------------------------------------------- + +@router.get("/financials/{statement}", summary="v2 三大财务报表") +async def v2_financials( + statement: str, + thscode: str = Query(..., description="标的 thscode"), + period: str = Query("annual", description="annual 年报 / quarterly 季报"), + limit: int = Query(6, ge=1, le=20), +): + if statement not in ("income-statements", "balance-sheets", "cash-flow-statements"): + raise HTTPException(status_code=400, detail="statement 仅支持 income-statements/balance-sheets/cash-flow-statements") + items = await _guard(fuyao_client.financials(statement, thscode, period, limit)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/financials/indicators", summary="v2 五类财务指标") +async def v2_financial_indicators( + thscode: str = Query(..., description="标的 thscode"), + report: str = Query(..., description="报告期 yyyy-1 ~ yyyy-4"), +): + items = await _guard(fuyao_client.financial_indicators(thscode, report)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/valuations/snapshot", summary="v2 估值快照") +async def v2_valuations_snapshot(thscodes: str = Query(..., description="逗号分隔 thscode")): + items = await _guard(fuyao_client.valuations_snapshot(thscodes)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +# --------------------------------------------------------------- +# 指数 / 板块 +# --------------------------------------------------------------- + +@router.get("/index/catalog", summary="v2 同花顺指数清单") +async def v2_index_catalog(tag: str = Query("industry", description="cn_concept/region/tszs/industry")): + items = await _guard(fuyao_client.index_catalog(tag)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/index/constituents", summary="v2 指数成分股") +async def v2_index_constituents(thscode: str = Query(..., description="指数 thscode")): + items = await _guard(fuyao_client.index_constituents(thscode)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/index/prices/snapshot", summary="v2 指数行情快照") +async def v2_index_prices_snapshot(thscodes: str = Query(..., description="逗号分隔指数 thscode")): + items = await _guard(fuyao_client.index_prices_snapshot(thscodes)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) + + +@router.get("/index/prices/historical", summary="v2 指数历史K线") +async def v2_index_prices_historical( + thscode: str = Query(..., description="指数 thscode"), + start: int = Query(..., description="起始时间,毫秒 Unix 时间戳"), + end: int = Query(..., description="结束时间,毫秒 Unix 时间戳"), +): + items = await _guard(fuyao_client.index_prices_historical(thscode, start, end)) + return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS) \ No newline at end of file diff --git a/backend/sdk/__init__.py b/backend/sdk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/sdk/credentials.py b/backend/sdk/credentials.py new file mode 100644 index 0000000..22b8eec --- /dev/null +++ b/backend/sdk/credentials.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +import sys +from collections.abc import Mapping +from pathlib import Path + +CANONICAL_API_KEY_ENV = "HITHINK_FINANCE_API_KEY" +# AUV 项目自有约定:同花顺 key 存在 .env 的 fuyao_apikey(由 load_dotenv 加载) +PROJECT_API_KEY_ENV = "fuyao_apikey" +LEGACY_API_KEY_ENVS = ("FUYAO_TOKEN", "API_KEY") + + +class CredentialFileError(RuntimeError): + """Raised when the user credential file exists but cannot be read safely.""" + + +def credential_file_path( + *, + platform: str | None = None, + env: Mapping[str, str] | None = None, + home: Path | None = None, +) -> Path: + resolved_platform = platform or sys.platform + resolved_env = env if env is not None else os.environ + resolved_home = home or Path.home() + + if resolved_platform == "win32": + config_root = resolved_env.get("APPDATA", "").strip() + base = Path(config_root) if config_root else resolved_home / "AppData" / "Roaming" + elif resolved_platform == "darwin": + base = resolved_home / "Library" / "Application Support" + else: + config_root = resolved_env.get("XDG_CONFIG_HOME", "").strip() + base = Path(config_root).expanduser() if config_root else resolved_home / ".config" + return base / "hithink-finance" / "credentials.env" + + +def _non_blank(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _read_credential_file(path: Path) -> str | None: + if not path.exists(): + return None + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + raise CredentialFileError( + f"Unable to read hithink finance credential file: {path}" + ) from exc + + for line_number, raw_line in enumerate(content.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + if name.strip() != CANONICAL_API_KEY_ENV: + continue + normalized = value.strip() + if "'" in normalized or '"' in normalized: + raise CredentialFileError( + f"Invalid hithink finance credential file at {path}:{line_number}: " + f"{CANONICAL_API_KEY_ENV} must not be quoted." + ) + return _non_blank(normalized) + return None + + +def resolve_api_key( + *, + env: Mapping[str, str] | None = None, + credential_path: Path | None = None, +) -> str | None: + resolved_env = env if env is not None else os.environ + + canonical = _non_blank(resolved_env.get(CANONICAL_API_KEY_ENV)) + if canonical is not None: + return canonical + + # AUV 项目约定:优先读 .env 的 fuyao_apikey + project_key = _non_blank(resolved_env.get(PROJECT_API_KEY_ENV)) + if project_key is not None: + return project_key + + stored = _read_credential_file( + credential_path + if credential_path is not None + else credential_file_path(env=resolved_env) + ) + if stored is not None: + return stored + + for name in LEGACY_API_KEY_ENVS: + legacy = _non_blank(resolved_env.get(name)) + if legacy is not None: + return legacy + return None diff --git a/backend/sdk/fuyao_client.py b/backend/sdk/fuyao_client.py new file mode 100644 index 0000000..741e974 --- /dev/null +++ b/backend/sdk/fuyao_client.py @@ -0,0 +1,1537 @@ +"""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", +] diff --git a/backend/services/fuyao_client.py b/backend/services/fuyao_client.py new file mode 100644 index 0000000..d4d6c4f --- /dev/null +++ b/backend/services/fuyao_client.py @@ -0,0 +1,202 @@ +"""同花顺金融数据 API 客户端(v2 数据源,接口前缀 /api/v2) + +本模块是基于官方 Python SDK(vendor 在 backend/sdk/)的薄封装, +对外暴露与 /api/v2 路由匹配的 async 函数,并统一返回 list/dict。 + +- SDK 提供:历史K线 >10 年自动切片、重试、参数校验、标的缓存。 +- SDK 内部用同步 requests,这里用 asyncio.to_thread 桥接,避免阻塞事件循环。 +- API Key 由 SDK 的 credentials resolver 读取(兼容 .env 的 fuyao_apikey), + **绝不写入代码、日志、错误信息或 git。** + +官方 SDK 来源:https://github.com/HiThink-Tech/Financial-API (MIT) +""" + +import asyncio +import time +from typing import Any, Iterable, Optional + +from sdk import fuyao_client as _sdk +from sdk.fuyao_client import FuyaoApiError # 复用官方错误类型 + +# 兼容旧路由引用名(routes/fuyao.py 用 FuyaoError) +FuyaoError = FuyaoApiError + + +# --------------------------------------------------------------- +# 拼音首字母检索(同花顺接口不支持拼音,本地补齐) +# --------------------------------------------------------------- + +# 全市场 A 股标的列表缓存(用于拼音首字母匹配) +_ticker_cache: dict = {"data": None, "at": 0.0} +_TICKER_CACHE_TTL = 12 * 3600 # 12 小时 + + +async def _ensure_a_share_tickers() -> list[dict]: + """拉取全市场 A 股标的列表并缓存(幂等,TTL 内复用)""" + now = time.time() + if _ticker_cache["data"] is not None and now - _ticker_cache["at"] < _TICKER_CACHE_TTL: + return _ticker_cache["data"] + items = await _run(_sdk.tickers_list, asset_type="a-share", limit=10000, offset=0) + data = _item_list(items) + _ticker_cache["data"] = data + _ticker_cache["at"] = now + return data + + +def _pinyin_initials(name: str) -> str: + """中文名 → 拼音首字母(如 深科技→skj,TCL科技→tkj)""" + try: + from pypinyin import lazy_pinyin + except ImportError: + return "" + return "".join( + w[0] for w in lazy_pinyin(name) + if w and w[0].isalpha() + ).lower() + + +def _match_by_pinyin(query: str, tickers: list[dict], limit: int) -> list[dict]: + """按拼音首字母匹配:精确=前缀优先,包含匹配次之""" + q = query.lower() + exact: list[dict] = [] + prefix: list[dict] = [] + contains: list[dict] = [] + for t in tickers: + name = t.get("name") or "" + if not name: + continue + initials = _pinyin_initials(name) + if not initials: + continue + if initials == q: + exact.append(t) + elif initials.startswith(q): + prefix.append(t) + elif q in initials: + contains.append(t) + return (exact + prefix + contains)[:limit] + + +def _run(fn, *args, **kwargs): + """同步 SDK 调用桥接到 async""" + return asyncio.to_thread(fn, *args, **kwargs) + + +def _item_list(data, key="item"): + """把 SDK 返回的 dict 信封或 list 统一成 item list""" + if isinstance(data, dict): + return data.get(key, []) or [] + if isinstance(data, list): + return data or [] + return [] + + +# --------------------------------------------------------------- +# 基础数据:标的检索 / 标的列表 +# --------------------------------------------------------------- + +async def ticker_search(q: str, exchange: Optional[str] = None, + asset_type: Optional[str] = None, limit: int = 10) -> list[dict]: + items = await _run(_sdk.tickers_search, q, exchange=exchange, + asset_type=asset_type, limit=limit) + result = _item_list(items) + # 拼音首字母兜底:同花顺无结果 且 输入是纯字母(如 skj)时,本地按拼音首字母匹配 + if not result and q and q.strip().isalpha(): + try: + tickers = await _ensure_a_share_tickers() + result = await asyncio.to_thread(_match_by_pinyin, q, tickers, limit) + except Exception: + pass # 拼音匹配失败不影响主流程 + return result + + +async def ticker_list(asset_type: Optional[str] = None, limit: int = 100, + offset: int = 0) -> list[dict]: + items = await _run(_sdk.tickers_list, asset_type=asset_type or "a-share", + limit=limit, offset=offset) + return _item_list(items) + + +# --------------------------------------------------------------- +# A股行情 / 日历 / 竞价 +# --------------------------------------------------------------- + +async def prices_snapshot(thscodes: str) -> list[dict]: + """行情快照(单只/多只,逗号分隔)""" + codes = [c.strip() for c in thscodes.split(",") if c.strip()] + items = await _run(_sdk.prices_snapshot, codes) + return _item_list(items) + + +async def prices_historical(thscode: str, start_ms: int, end_ms: int, + adjust: str = "forward", offset: int = 0) -> list[dict]: + """历史日K(毫秒时间戳)。SDK 自动处理 >10 年窗口切片与去重排序。""" + return await _run(_sdk.prices_historical, thscode, start_ms, end_ms, + interval="1d", adjust=adjust) + + +async def calendar_trading_days() -> list[dict]: + return await _run(_sdk.calendar_trading_days) + + +async def auction_snapshot(thscodes: str, stage: str = "final") -> dict: + codes = [c.strip() for c in thscodes.split(",") if c.strip()] + data = await _run(_sdk.a_share_auction_snapshot, codes, stage=stage) + return data or {} + + +async def auction_short_term_benchmark(date: Optional[str] = None) -> dict: + data = await _run(_sdk.a_share_auction_short_term_benchmark, date=date) + return data or {} + + +# --------------------------------------------------------------- +# A股财务 / 估值 +# --------------------------------------------------------------- + +async def financials(statement: str, thscode: str, period: str = "annual", + limit: int = 6) -> list[dict]: + fn_map = { + "income-statements": _sdk.financials_income_statements, + "balance-sheets": _sdk.financials_balance_sheets, + "cash-flow-statements": _sdk.financials_cash_flow_statements, + } + fn = fn_map[statement] + items = await _run(fn, thscode, period=period, limit=limit) + return _item_list(items) + + +async def financial_indicators(thscode: str, report: str) -> list[dict]: + data = await _run(_sdk.financials_indicators, thscode, report) + return _item_list(data) + + +async def valuations_snapshot(thscodes: str) -> list[dict]: + codes = [c.strip() for c in thscodes.split(",") if c.strip()] + data = await _run(_sdk.a_share_valuations_snapshot, codes) + return _item_list(data) + + +# --------------------------------------------------------------- +# 指数 / 板块 +# --------------------------------------------------------------- + +async def index_catalog(tag: str = "industry") -> list[dict]: + items = await _run(_sdk.index_catalog_ths_index_list, tag=tag) + return _item_list(items) + + +async def index_constituents(thscode: str) -> list[dict]: + items = await _run(_sdk.index_constituents_ths_stock_list, thscode) + return _item_list(items) + + +async def index_prices_snapshot(thscodes: str) -> list[dict]: + codes = [c.strip() for c in thscodes.split(",") if c.strip()] + items = await _run(_sdk.index_prices_snapshot, codes) + return _item_list(items) + + +async def index_prices_historical(thscode: str, start_ms: int, end_ms: int) -> list[dict]: + items = await _run(_sdk.index_prices_historical, thscode, start_ms, end_ms) + return _item_list(items) diff --git a/docs/api-data-sources.md b/docs/api-data-sources.md index a51f7cb..1d111f2 100644 --- a/docs/api-data-sources.md +++ b/docs/api-data-sources.md @@ -1,5 +1,7 @@ # 数据接口 & 数据源一览 +> 📖 同花顺官方金融数据 API 能力整理见 [hithink-financial-api.md](./hithink-financial-api.md) + ## 架构概览 ``` diff --git a/docs/hithink-financial-api.md b/docs/hithink-financial-api.md new file mode 100644 index 0000000..1657093 --- /dev/null +++ b/docs/hithink-financial-api.md @@ -0,0 +1,188 @@ +# 同花顺金融数据 API(HiThink Financial API)接口能力整理 + +> 数据源参考:https://github.com/HiThink-Tech/Financial-API · 文档:https://fuyao.aicubes.cn/docs/quickstart/ +> 本文档整理官方 REST / MCP / CLI / Python / 本地库 各接入方式的能力清单,供后续开发评估使用。 + +## 一句话概览 + +同花顺官方维护的 **A 股金融数据服务**,面向 AI Agent、量化研究和应用开发者。 +一个 API Key 即可访问数据,提供 **六种接入方式**:REST API、托管 MCP、Node.js CLI、Python SDK、 +本地 DuckDB 数据库(marketdb)和 Agent Skill。MIT 许可,约 1.9k stars。 + +**支持**:A股股票、指数与板块、公募基金(含 ETF/LOF)、衍生特色数据(涨跌停/连板/异动/热榜/龙虎榜)、全市场数据导出。 +**暂不支持**:期货、分钟K、tick、海外市场、宏观数据、新闻公告原文、研报原文。 + +--- + +## 认证与调用 + +| 项目 | 内容 | +|------|------| +| Base URL | `https://fuyao.aicubes.cn` | +| 认证 | 请求头 `X-api-key: `(与同花顺账号绑定) | +| Key 获取 | 官网注册 → 「API Key 管理」创建;关闭弹窗后无法再看完整 Key,需妥善保存 | +| 响应信封 | `{ code, message, request_id, data }`,`code=0` 表示成功(HTTP 恒为 200) | +| 环境变量 | `HITHINK_FINANCE_API_KEY` | +| 常见错误 | `2001` Key 缺失/无效;`2003` 无权限需开通 | + +**Agent 集成(推荐)**:`npx skills add HiThink-Tech/Financial-API --skill hithink-finance -g --yes` +Agent 会在 API / MCP / CLI / Python 间自动选择合适方式,并对大结果自动落盘。 + +--- + +## 接入方式一览 + +| 方式 | 说明 | +|------|------| +| **REST API** | `GET` + `X-api-key`,最通用 | +| **MCP** | 4 个服务端点,供 AI Agent 直接调用 | +| **Node.js CLI** | npm 包 `@hithink-tech/hithink-finance-cli` | +| **Python SDK** | `pip install -e ./python` | +| **本地 DuckDB** | `marketdb` 本地库,支持增量同步 + SQL + 复权 | +| **Agent Skill** | 一键安装,自动选择最优接入 | + +--- + +## REST API 能力清单(GET + X-api-key) + +### 基础 / 元信息 + +| 接口路径 | 功能 | 主要参数 | +|----------|------|---------| +| `/api/meta/tickers/search` | 跨市场标的检索(代码/名称/拼音) | `q`*、`exchange`、`asset_type`、`limit`(≤50) | +| `/api/meta/tickers/list` | 分页获取代码表 | `asset_type`、`limit`(≤10000)、`offset` | + +### A股行情 + +| 接口路径 | 功能 | 主要参数 | +|----------|------|---------| +| `/api/a-share/prices/snapshot` | 行情快照(单只/多只/全市场) | `thscodes`、`limit`、`offset` | +| `/api/a-share/prices/historical` | 单只标的日K(窗口≤10年) | `thscode`*、`interval`(1d)、`start`*、`end`*、`adjust`(none/forward/backward)、`offset` | +| `/api/a-share/auction/snapshot` | 集合竞价快照 | `thscodes`*、`stage`(live/final) | +| `/api/a-share/auction/short-term-benchmark` | 短线风向标竞价基准 | `date`(yyyy-MM-dd) | +| `/api/a-share/calendar/trading-days` | 近一年交易日序列 | 无 | + +### A股财务 / 复权 / 估值 + +| 接口路径 | 功能 | 主要参数 | +|----------|------|---------| +| `/api/a-share/financials/income-statements` | 合并利润表多期序列 | `thscode`*、`period`(annual/quarterly)*、`limit`(1-20) 或 `start`+`end` | +| `/api/a-share/financials/balance-sheets` | 合并资产负债表 | 同上 | +| `/api/a-share/financials/cash-flow-statements` | 合并现金流量表 | 同上 | +| `/api/a-share/financials/indicators` | 五类财务指标 | `thscode`*、`report`*(yyyy-1~yyyy-4) | +| `/api/a-share/corporate-actions/adjustment-factors` | 分红/送股/配股事件流 | `thscode`*、`from`、`to` | +| `/api/a-share/valuations/snapshot` | 估值快照(PE TTM/MRQ、PB、PS、PCF) | 批量查询 | + +### 指数与板块 + +| 接口路径 | 功能 | 主要参数 | +|----------|------|---------| +| `/api/a-share-index/catalog/ths-index-list` | 同花顺指数清单 | `tag`(cn_concept/region/tszs/industry) | +| `/api/a-share-index/constituents/ths-stock-list` | 指数成分股 | `thscode`* | +| `/api/a-share-index/prices/snapshot` | 指数行情快照 | `thscodes`* | +| `/api/a-share-index/prices/historical` | 指数历史K线(无复权参数) | `thscode`*、`interval`、`start`*、`end`* | + +### 特色数据(`/api/a-share/special-data/…`) + +| 接口路径后缀 | 功能 | +|------|------| +| `limit-up-pool` | 涨停池(涨停/连板股) | +| `limit-down-pool` | 跌停池 | +| `limit-break-pool` | 涨停炸板池 | +| `limit-up-ladder` | 近30日连板天梯 | +| `skyrocket-list` | 热度飙升榜 Top30(日榜/小时榜) | +| `hot-stock-list` | A股热股榜 Top30(24h/小时) | +| `hot-stock-list-history` | 按自然日历史热股排行 | +| `hot-stock-rank-trend` | 单股热榜排名走势 | +| `anomaly-analysis-list` | 当日个股异动原因列表(`tag_codes` 如 `LIMIT_UP,SHARP_FALL`)※仅 REST | +| `anomaly-analysis-stock` | 按股票批量查异动原因(`thscodes`* ≤50) | +| `dragon-tiger-list` | 龙虎榜(`board_type` all/org/hot_money、`date`) | + +### 公募基金(21 项,仅列核心) + +| 接口路径 | 功能 | +|----------|------| +| `/api/fund/profile/**` | 基金基本资料 | +| `/api/fund/portfolio/holdings` | 定期披露重仓持仓 | +| `/api/fund/performance/nav` / `returns` / `indicators-historical` | 净值 / 区间收益与同类排名 / 历史业绩指标 | +| `/api/fund/holders/detail` / `top` | 持有人结构 / 前十大持有人 | +| `/api/fund/corporate-actions/dividends` | 基金分红记录 | +| `/api/fund/managers/investment-style` / `performance` / `experience` / `detail` | 经理投资风格 / 业绩 / 经历 / 详情 | +| `/api/fund/companies/detail` | 基金公司详情 | +| `/api/fund/diagnostics/detail` | 基金诊断 | +| `/api/fund/offerings/list` | 新发基金募集列表 | +| `/api/fund/news/article-list` | 基金资讯(游标分页) | +| `/api/fund/financials/indicators` / `income-statements` / `balance-sheets` | 基金财务指标 / 利润表 / 资产负债表 | +| `/api/fund/market/snapshot` / `historical` | ETF 行情快照 / 历史日线(窗口≤5年) | + +### 全市场数据导出(Market Dumps) + +| 接口路径 | 功能 | +|----------|------| +| `/api/dump/market-dumps/daily-k/download-url` | 全市场 10 年日K Parquet 下载链接 | +| `/api/dump/market-dumps/daily-k-10d/download-url` | 最近 10 交易日日K Parquet | +| `/api/dump/market-dumps/adjustment-factors/download-url` | 全量复权因子 Parquet | + +--- + +## MCP 服务端点(4 个) + +| 服务 | 端点 | +|------|------| +| `hithink-finance-a-share` | `/mcp/a-share` | +| `hithink-finance-a-share-index` | `/mcp/a-share-index` | +| `hithink-finance-meta` | `/mcp/meta` | +| `hithink-finance-fund` | `/mcp/fund` | + +鉴权:环境变量 `API_KEY` 注入,与 REST 同一 Key。 + +--- + +## CLI(`@hithink-tech/hithink-finance-cli`) + +| 命令 | 功能 | +|------|------| +| `auth login` | 录入 API Key | +| `capabilities` | 能力目录 | +| `symbol search --q <代码>` | 标的检索 | +| `market snapshot --thscodes 600519.SH` | 行情快照 | +| `financials income --thscode ... --limit 4` | 利润表 | +| `data init` / `db query --sql "..."` | 初始化本地库 / SQL 查询(视图如 `v_daily_qfq`) | + +--- + +## Python SDK 与本地 DuckDB + +```bash +pip install -e ./python +python python/bootstrap.py # 初始化本地 DuckDB(marketdb) +``` + +常用脚本:`fuyao.py tickers-search --q "贵州茅台"`、`fuyao.py prices-snapshot ...` +marketdb 支持增量同步、SQL 查询、复权计算、数据导出。 + +--- + +## 合规要求 + +- 输出需注明数据源、时间范围与复权口径,标注"非投资建议" +- 真实数据不可用时不得用模拟数据冒充 +- API Key 不可写入代码、日志或 Git 仓库 + +--- + +## 与我们现有系统的潜在结合点(供后续评估) + +> 现有数据源依赖腾讯/东方财富/新浪/AkShare,均为非官方抓取,存在限流与反爬问题。 +> 同花顺官方 API 可作**更稳定、结构化的权威数据源**,或补充新能力。 + +| 现有模块 | 同花顺对应能力 | 潜在价值 | +|----------|--------------|---------| +| 实时行情 `/api/stock/quote`(腾讯) | `a-share/prices/snapshot` | 官方行情快照,避免腾讯兼容性/抓取风险 | +| 历史K线 `/api/stock/history`(东财/腾讯) | `a-share/prices/historical`(10年、复权可选) | 权威日K + 明确复权口径,替代被反爬的东财 kline | +| 财务指标 `/api/stock/financial`(东财) | `a-share/financials/*` + `valuations/snapshot` | 官方三大报表、五类指标、估值快照 | +| 题材热点 / 热点穿透(东财) | `a-share/special-data/*` 热榜、涨停池、连板天梯 | 官方热榜/涨停/连板数据,可增强题材热度判断 | +| 核心股追踪 / 每日采集 | `a-share/special-data/dragon-tiger-list`、`hot-stock-*` | 龙虎榜、热股榜可作为核心股采样的补充来源 | +| 新增能力 | `a-share-index/*`(指数/板块成分)、`fund/*`(基金)、Market Dumps(全市场导出) | 指数行情、基金筛选、本地 marketdb 全市场回测 | + +> 注意:特色数据(涨停池等)tag_codes 语义、thscode 与现有 code 体系的映射,接入前需先做字段对齐验证。 diff --git a/run_local.sh b/run_local.sh new file mode 100755 index 0000000..49edbb1 --- /dev/null +++ b/run_local.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# 本地直接拉起后端(不用 docker) +# 用法: ./run_local.sh # 默认 0.0.0.0:8000 +# PORT=8010 ./run_local.sh # 指定端口 +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${ROOT}/backend" + +PORT="${PORT:-8000}" +PY="${PY:-${ROOT}/.venv/bin/python}" + +# 同步前端 dist(若后端 dist 落后于仓库根 dist) +if [ -d "${ROOT}/dist" ]; then + mkdir -p dist + cp -r "${ROOT}/dist/." dist/ + echo "✓ 已同步前端 dist → backend/dist" +fi + +echo "启动后端: http://0.0.0.0:${PORT} (python: ${PY})" +exec "${PY}" -m uvicorn main:app --host 0.0.0.0 --port "${PORT}" --log-level info diff --git a/src/lib/fuyao-api.ts b/src/lib/fuyao-api.ts new file mode 100644 index 0000000..0945041 --- /dev/null +++ b/src/lib/fuyao-api.ts @@ -0,0 +1,233 @@ +// v2 数据接口(同花顺官方 API)前端客户端 +// 仅调用后端 /api/v2 代理,密钥由后端持有,前端永远接触不到。 +import { getApiBaseUrl } from "@/lib/api-client"; + +/* ── 通用信封(后端返回 { data, count })── */ + +interface V2Response { + data: T; + count?: number; +} + +async function v2Get(path: string): Promise { + const baseUrl = getApiBaseUrl(); + try { + const resp = await fetch(`${baseUrl}/api/v2${path}`, { method: "GET", cache: "no-store" }); + if (!resp.ok) { + // 尝试读取 detail(FastAPI 错误信息) + try { + const err = await resp.json(); + throw new Error(err.detail || `请求失败 (${resp.status})`); + } catch (e) { + if (e instanceof Error) throw e; + throw new Error(`请求失败 (${resp.status})`); + } + } + const result: V2Response = await resp.json(); + return result.data; + } catch (err) { + console.error("[fuyao-api] v2 请求失败:", err); + throw err; + } +} + +/* ── 类型 ── */ + +export interface V2Ticker { + thscode: string; + ticker: string; + name: string; + exchange: string; + asset_type: string; + currency: string; +} + +export interface V2PriceSnapshot { + thscode: string; + ticker: string; + volume: number; + turnover: number; + last_price: number; + price_change: number; + price_change_ratio_pct: number; + open_price: number; + high_price: number; + low_price: number; + prev_price: number; +} + +export interface V2Valuation { + thscode: string; + ticker: string; + name: string; + pe_ttm: number; + pe_mrq: number; + pb_mrq: number; + ps_ttm: number; + pcf_ttm: number; +} + +export interface V2Financial { + thscode: string; + ticker: string; + fiscal_year: number; + fiscal_period: string; + operating_income: number; + operating_costs: number; + net_profit: number; + [key: string]: unknown; +} + +export interface V2TradingDay { + date: string; // YYYYMMDD + date_ms: number; +} + +export interface V2IndexItem { + thscode: string; + name: string; + [key: string]: unknown; +} + +/* ── 基础 / 检索 ── */ + +export function v2TickerSearch(q: string, limit = 10): Promise { + return v2Get(`/meta/tickers/search?q=${encodeURIComponent(q)}&limit=${limit}`); +} + +/** 判断是否已是标准 thscode(如 600519.SH / 000021.SZ / 830xxx.BJ) */ +function isThscode(token: string): boolean { + return /^\d{6}\.(SH|SZ|BJ)$/i.test(token); +} + +/** + * 把用户输入解析成 thscode 列表。 + * 支持:标准 thscode(600519.SH)、纯代码(600519)、名称(茅台)、拼音首字母(gzmt)。 + * 输入用逗号/空格分隔多个标的;每个 token 单独解析。 + * 解析失败(找不到)的 token 会被跳过。 + */ +export async function resolveThscodes(input: string): Promise { + const tokens = input + .split(/[,,\s]+/) + .map((t) => t.trim()) + .filter(Boolean); + const out: string[] = []; + for (const token of tokens) { + if (isThscode(token)) { + out.push(token.toUpperCase()); + } else { + try { + const hits = await v2TickerSearch(token, 1); + if (hits.length > 0) out.push(hits[0].thscode); + } catch { + // 忽略单个 token 解析失败 + } + } + } + return out; +} + +/* ── A股 ── */ + +/** 6位代码 → thscode(按代码前缀推断交易所后缀) */ +export function codeToThscode(code: string): string { + const c = code.trim(); + if (/^\d{6}\.(SH|SZ|BJ)$/i.test(c)) return c.toUpperCase(); + if (!/^\d{6}$/.test(c)) return c; + if (/^(60|68|9)/.test(c)) return `${c}.SH`; + if (/^(00|30|20|12)/.test(c)) return `${c}.SZ`; + if (/^(4|8|92)/.test(c)) return `${c}.BJ`; + return `${c}.SH`; +} + +export interface V2PriceBar { + date_ms: number; + open_price: number; + high_price: number; + low_price: number; + close_price: number; + volume: number; + turnover: number; +} + +export function v2PriceSnapshot(thscodes: string): Promise { + return v2Get(`/prices/snapshot?thscodes=${encodeURIComponent(thscodes)}`); +} + +/** + * 历史日K(毫秒时间戳)。后端用官方 SDK,>10 年窗口自动切片。 + */ +export function v2PriceHistorical( + thscode: string, + startMs: number, + endMs: number, + adjust = "forward", +): Promise { + return v2Get(`/prices/historical?thscode=${encodeURIComponent(thscode)}&start=${startMs}&end=${endMs}&adjust=${adjust}`); +} + +/** 毫秒时间戳 → 北京时间(UTC+8)日期 YYYY-MM-DD */ +function bjDate(ms: number): string { + const d = new Date(ms + 8 * 60 * 60 * 1000); + return d.toISOString().slice(0, 10); +} + +/** + * v2 版股票日K(供股票详情页复用现有图表结构)。 + * 返回与 v1 KLineData 兼容的结构;涨跌幅按昨收计算。 + */ +export async function fetchStockHistoryV2( + code: string, + days: number = 120, + adjust: string = "forward", +): Promise> { + const thscode = codeToThscode(code); + const end = Date.now(); + const start = end - days * 24 * 60 * 60 * 1000; + const bars = await v2PriceHistorical(thscode, start, end, adjust); + const sorted = bars.slice().sort((a, b) => a.date_ms - b.date_ms); + return sorted.map((b, i) => { + const prevClose = i > 0 ? sorted[i - 1].close_price : b.open_price; + const changePercent = prevClose > 0 ? ((b.close_price - prevClose) / prevClose) * 100 : 0; + return { + date: bjDate(b.date_ms), + open: b.open_price, + close: b.close_price, + high: b.high_price, + low: b.low_price, + volume: b.volume / 100, // 股 → 手(与 v1 口径一致,现有图表按手展示) + changePercent, + }; + }); +} + +export function v2Valuations(thscodes: string): Promise { + return v2Get(`/valuations/snapshot?thscodes=${encodeURIComponent(thscodes)}`); +} + +export function v2Financials( + statement: "income-statements" | "balance-sheets" | "cash-flow-statements", + thscode: string, + period = "annual", + limit = 6, +): Promise { + return v2Get(`/financials/${statement}?thscode=${encodeURIComponent(thscode)}&period=${period}&limit=${limit}`); +} + +export function v2TradingDays(): Promise { + return v2Get(`/calendar/trading-days`); +} + +/* ── 指数 / 板块 ── */ + +export function v2IndexCatalog(tag = "industry"): Promise { + return v2Get(`/index/catalog?tag=${tag}`); +} diff --git a/src/routes/stock.$code.tsx b/src/routes/stock.$code.tsx index 8396807..73e361f 100755 --- a/src/routes/stock.$code.tsx +++ b/src/routes/stock.$code.tsx @@ -1,7 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState, useEffect, useMemo, Fragment } from "react"; import { collectionsApi } from "@/lib/api-client"; -import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api"; +import { fetchStockQuote, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api"; +import { fetchStockHistoryV2 } from "@/lib/fuyao-api"; import StockProfileTabs from "@/components/stock-profile-tabs"; import { getUserId } from "@/lib/user-id"; import { formatMoney } from "@/lib/utils"; @@ -182,7 +183,7 @@ function StockDetail() { setFundFlowLoading(true); const [historyResult, fundFlowResult] = await Promise.allSettled([ - fetchStockHistory(code, chartDays), + fetchStockHistoryV2(code, chartDays), fetchStockFundFlow(code, quote.name, 21), ]);