feat: 接入同花顺官方SDK,新增v2数据接口,股票详情K线改用v2(前复权日K)

- vendor 同花顺官方 SDK 到 backend/sdk(含K线>10年自动切片、重试、拼音首字母检索兜底)
- 新增 /api/v2 路由:行情/估值/财务/日历/指数/K线/标的检索
- 股票详情页K线改用 v2 同花顺接口(前复权日K+总手+按昨收涨跌幅)
- 密钥仅后端持有,响应/日志无泄露
- 新增 run_local.sh 本地直接拉起(不再依赖 docker)
This commit is contained in:
Sakurasan
2026-08-28 02:09:49 +08:00
parent 82006f7cf9
commit d5516b72d7
13 changed files with 2465 additions and 5 deletions
+202
View File
@@ -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)