Files
tick-stock-panel/backend/app/plugins/fuyao/client.py
T
shy3130 3d627aba5e feat(recap): 盘前风向标服务与 AI 复盘注入
- fuyao 客户端新增 short-term-benchmark 端点透传
- auction_benchmark 服务: 历史按日 JSON 缓存(当日不缓存)、交易日回退、
  四态降级(ok/fallback_prev/source_unavailable/no_data)
- 读取时从相邻日K分区现算当日/次日收益对照, 不落缓存
- 复盘 user prompt 注入「盘前风向标(竞价)」上下文
- 11 个测试覆盖回退/缓存/富化数学/上下文
2026-08-30 19:05:31 +08:00

281 lines
12 KiB
Python

"""扶摇(同花顺金融数据 API) HTTP 客户端。
职责: 认证、统一信封解包、快照分页、单标的日K、市场 dump 下载。不知道 provider / services 层。
文档: https://fuyao.aicubes.cn/docs — REST + X-api-key, 响应信封 {code, message, data}。
时间字段口径: 所有 *ms 字段(含 start/end 入参与 date_ms/ex_date_ms 出参)均为
北京时间零点对应的 epoch ms(= UTC 前一日 16:00), 由 provider 层统一 +8h 换算。
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
BASE_URL = "https://fuyao.aicubes.cn"
# 单页 6000 覆盖全市场(实测 ~5600 含北交所, 2026-08 服务端不截断 limit=6000),
# 一次请求拉完; 分页循环兜底未来标的扩容或服务端改为截断的场景。
_SNAPSHOT_PAGE_SIZE = 6000
_SNAPSHOT_MAX_PAGES = 50
_PAGE_INTERVAL_S = 0.15 # 页间隔, 降低触发限频 (code=4001) 的概率
class FuyaoError(Exception):
"""扶摇接口错误(配置缺失 / 网络失败 / 信封 code != 0)。"""
class FuyaoClient:
"""扶摇 REST 客户端 (线程安全: httpx.Client 可并发复用)。"""
def __init__(self, api_key: str, base_url: str = BASE_URL, timeout: float = 20.0) -> None:
if not api_key:
raise FuyaoError("未配置 FUYAO_API_KEY")
self.last_server_ts = 0 # 最近一页响应里的服务端时间戳(ms), 供行情归属
self._http = httpx.Client(
base_url=base_url,
headers={"X-api-key": api_key},
timeout=timeout,
)
def close(self) -> None:
self._http.close()
# ---- 内部 ----
def _get(self, path: str, params: dict) -> dict:
"""GET + 信封解包。code != 0 时抛 FuyaoError(含 code 与 message)。"""
try:
resp = self._http.get(path, params=params)
except httpx.HTTPError as e:
raise FuyaoError(f"网络请求失败: {e}") from e
if resp.status_code != 200:
raise FuyaoError(f"HTTP {resp.status_code}: {path}")
try:
payload = resp.json()
except ValueError as e:
raise FuyaoError(f"响应不是 JSON: {path}") from e
code = payload.get("code")
if code not in (0, "0", None):
raise FuyaoError(f"扶摇接口错误 code={code}: {payload.get('message', '')} ({path})")
return payload.get("data") or {}
# ---- 快照 ----
def snapshot_page(
self, limit: int = _SNAPSHOT_PAGE_SIZE, offset: int = 0
) -> tuple[list[dict], int]:
"""拉取一页 A 股全市场快照。返回 (rows, total), total 为全市场总数。
实测响应(2026-08): data={timestamp, total, item}; 官方文档示例为
data={count, data}。两者都兼容, 以实测为准。
"""
data = self._get("/api/a-share/prices/snapshot", {"limit": limit, "offset": offset})
try:
self.last_server_ts = int(data.get("timestamp") or 0)
except (TypeError, ValueError):
self.last_server_ts = 0
rows = data.get("item")
if not isinstance(rows, list):
rows = data.get("data") if isinstance(data.get("data"), list) else []
raw_total = data.get("total")
if raw_total is None:
raw_total = data.get("count") or 0
try:
total = int(raw_total or 0)
except (TypeError, ValueError):
total = 0
return rows, total
def snapshot_all(self) -> tuple[list[dict], int]:
"""分页拉取全市场快照。返回 (rows, 服务端时间戳ms)。
服务端时间戳用于行情归属; 缺失时返回 0, 由调用方退回本地时间。
空数据 / 中途失败时抛 FuyaoError。
"""
out: list[dict] = []
server_ts = 0
offset = 0
for page in range(_SNAPSHOT_MAX_PAGES):
if page > 0:
time.sleep(_PAGE_INTERVAL_S)
rows, total = self.snapshot_page(offset=offset)
if not rows:
break
out.extend(rows)
if not server_ts:
server_ts = self.last_server_ts
if total and len(out) >= total:
break
offset += len(rows)
if not out:
raise FuyaoError("全市场快照为空")
return out, server_ts
# ---- 历史日K ----
def historical_kline(
self, thscode: str, start_ms: int, end_ms: int, adjust: str = "none"
) -> list[dict]:
"""单标的日K(interval=1d 固定)。单次窗口 ≤10 年, 超出由调用方分片。
adjust 必须显式传 "none" 取原始价 — 服务端默认是 forward(前复权),
官方前复权序列事件间存在逐日漂移, 项目内禁止使用。
返回 data.item 原始行: {date_ms, open_price, high_price, low_price,
close_price, volume(股), turnover(元)}。
"""
data = self._get(
"/api/a-share/prices/historical",
{
"thscode": thscode,
"interval": "1d",
"adjust": adjust,
"start": int(start_ms),
"end": int(end_ms),
},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
# ---- 财务 ----
# 端点均单标的(thscode 不接受逗号)。取数模式二选一: limit=最近N期 或 start/end 区间,
# 这里只用 limit。period=quarterly 覆盖每个季度末(含年报期), 与项目"各报告期累积"口径一致。
_STATEMENT_ENDPOINTS = {
"income": "income-statements",
"balance_sheet": "balance-sheets",
"cash_flow": "cash-flow-statements",
}
def financial_statements(
self, stmt: str, thscode: str, limit: int = 1
) -> list[dict]:
"""单标的财务报表多期序列。stmt: income | balance_sheet | cash_flow。
返回 data.item 原始行: 共有元数据(thscode/period/fiscal_year/fiscal_period/
report_date_ms/period_end_ms/currency) + 各表字段。行内 null 表示该期未披露。
"""
endpoint = self._STATEMENT_ENDPOINTS.get(stmt)
if endpoint is None:
raise FuyaoError(f"未知财务报表类型: {stmt}")
data = self._get(
f"/api/a-share/financials/{endpoint}",
{"thscode": thscode, "period": "quarterly", "limit": max(1, min(20, limit))},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
def financial_indicators(self, thscode: str, report: str) -> list[dict]:
"""单标的单报告期财务指标(report 格式 yyyy-N, N=1..4 对应一季报..年报)。
返回 data.abilities 原始列表 [{ability, indicators: [{index_id, value}]}];
value 为保留原始精度的数值字符串(百分制指标即百分点数), 缺失为 null。
未披露报告期实测返回 code=5003(文档写 3002, 以实测为准) → 经 _get 抛 FuyaoError,
由调用方按"该期无数据"处理。
"""
data = self._get(
"/api/a-share/financials/indicators",
{"thscode": thscode, "report": report},
)
abilities = data.get("abilities")
return abilities if isinstance(abilities, list) else []
def valuations_snapshot(self, thscodes: list[str]) -> list[dict]:
"""批量估值快照(pe_ttm/pe_mrq/pb_mrq/ps_ttm/pcf_ttm), 数值为最新口径。
服务端单次上限 100 只(超出 code=1003), 分批由调用方负责。
返回 data.item 原始行。
"""
data = self._get(
"/api/a-share/valuations/snapshot",
{"thscodes": ",".join(thscodes[:100])},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
def price_snapshot_batch(self, thscodes: list[str]) -> list[dict]:
"""按 thscodes 批量行情快照(最新价等), 用于估值推导的分母。
与全市场分页快照同一端点; thscodes 显式传入时不分页。
返回 data.item 原始行。
"""
data = self._get(
"/api/a-share/prices/snapshot",
{"thscodes": ",".join(thscodes[:100])},
)
rows = data.get("item")
return rows if isinstance(rows, list) else []
def trading_days(self) -> list[dict]:
"""近一年 A 股交易日序列 (固定窗口 [今日-1年, 今日], 无入参)。
返回 data.item 原始行: {date_ms(上海零点), date(yyyyMMdd)}。
供交易日探针判定「今天在列表内 ⇔ 交易日」。
"""
data = self._get("/api/a-share/calendar/trading-days", {})
rows = data.get("item")
return rows if isinstance(rows, list) else []
def dragon_tiger_list(self, board_type: str = "all", date: str | None = None) -> dict:
"""龙虎榜榜单 (特色数据)。board_type: all | org | hot_money。
返回 data 原始容器: {trade_date, count, stock_count, stock_items[],
hot_money_items[]}。省略 date 时服务端自动取最近已发布交易日;
显式传非交易日返回 code=1002 (由调用方做交易日回退)。
实测字段(2026-08): stock_items 12 个基础字段, org 榜额外带 4 个机构字段;
文档中的 limit_reason / amount 实际不返回。
"""
params: dict = {"board_type": board_type}
if date:
params["date"] = date
return self._get("/api/a-share/special-data/dragon-tiger-list", params)
def short_term_benchmark(self, date: str | None = None) -> dict:
"""短线风向标竞价基准 (同花顺竞价筛选, 每日约 5~6 只)。
返回 data 原始容器: {date, date_ms, item[]}。item 行:
{thscode, ticker, name, auction_pct, tags[]}。支持一年内历史日期;
显式传非交易日返回 code=1002 (由调用方做交易日回退)。
"""
params: dict = {}
if date:
params["date"] = date
return self._get("/api/a-share/auction/short-term-benchmark", params)
# ---- 市场 dump ----
def dump_download_url(self, dump_kind: str) -> dict:
"""获取 dump 预签名下载信息(约 300s 有效)。
dump_kind: adjustment-factors | daily-k-10d | daily-k。
返回 {presigned_url, presigned_url_expires_at, expires_in_seconds};
release 版本号(如 20260828)嵌在 presigned_url 的 releases/<date>/ 路径中,
供调用方做缓存版本管理。
"""
return self._get(f"/api/dump/market-dumps/{dump_kind}/download-url", {})
def download_dump(self, dump_kind: str, dest: Path) -> Path:
"""下载 dump 到 dest(先写 .part 临时文件, 成功后原子改名)。失败抛 FuyaoError。
预签名 URL 指向对象存储, 请求不得携带 X-api-key 头 → 用独立裸请求,
不经过持有认证头的 self._http。
"""
url = str(self.dump_download_url(dump_kind).get("presigned_url") or "")
if not url:
raise FuyaoError(f"dump {dump_kind} 未返回预签名 URL")
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".part")
try:
with httpx.stream("GET", url, timeout=120.0, follow_redirects=True) as resp:
if resp.status_code != 200:
raise FuyaoError(f"dump {dump_kind} 下载失败 HTTP {resp.status_code}")
with open(tmp, "wb") as fh:
for chunk in resp.iter_bytes(1 << 20):
fh.write(chunk)
tmp.replace(dest)
except httpx.HTTPError as e:
raise FuyaoError(f"dump {dump_kind} 下载网络失败: {e}") from e
finally:
tmp.unlink(missing_ok=True)
return dest