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:
@@ -121,3 +121,4 @@ tmp/
|
|||||||
/core
|
/core
|
||||||
/.core.hmbtNy
|
/.core.hmbtNy
|
||||||
/.core.dump
|
/.core.dump
|
||||||
|
.v2-demo-backup/
|
||||||
|
|||||||
+10
-2
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from database import init_db
|
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
|
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||||
|
|
||||||
load_dotenv()
|
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(shares.router, prefix="/api/share")
|
||||||
app.include_router(themes.router, prefix="/api/themes")
|
app.include_router(themes.router, prefix="/api/themes")
|
||||||
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||||
|
app.include_router(fuyao.router, prefix="/api/v2")
|
||||||
|
|
||||||
# 生产模式:后端同时托管前端静态文件
|
# 生产模式:后端同时托管前端静态文件
|
||||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||||
dist_path = os.path.join(os.path.dirname(__file__), "dist")
|
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):
|
if os.path.isdir(dist_path):
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def serve_spa(full_path: str):
|
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")
|
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):
|
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)
|
return FileResponse(file_path)
|
||||||
# SPA fallback: 非文件路径统一返回 index.html
|
# SPA fallback: 非文件路径统一返回 index.html
|
||||||
index_path = os.path.join(dist_path, "index.html")
|
index_path = os.path.join(dist_path, "index.html")
|
||||||
if os.path.isfile(index_path):
|
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)
|
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ uvicorn==0.30.0
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
akshare==1.18.64
|
akshare==1.18.64
|
||||||
mootdx
|
mootdx
|
||||||
|
requests>=2.31,<3
|
||||||
|
pypinyin
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 数据接口 & 数据源一览
|
# 数据接口 & 数据源一览
|
||||||
|
|
||||||
|
> 📖 同花顺官方金融数据 API 能力整理见 [hithink-financial-api.md](./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: <your-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 体系的映射,接入前需先做字段对齐验证。
|
||||||
Executable
+20
@@ -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
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// v2 数据接口(同花顺官方 API)前端客户端
|
||||||
|
// 仅调用后端 /api/v2 代理,密钥由后端持有,前端永远接触不到。
|
||||||
|
import { getApiBaseUrl } from "@/lib/api-client";
|
||||||
|
|
||||||
|
/* ── 通用信封(后端返回 { data, count })── */
|
||||||
|
|
||||||
|
interface V2Response<T> {
|
||||||
|
data: T;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function v2Get<T>(path: string): Promise<T> {
|
||||||
|
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<T> = 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<V2Ticker[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<V2PriceSnapshot[]> {
|
||||||
|
return v2Get(`/prices/snapshot?thscodes=${encodeURIComponent(thscodes)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史日K(毫秒时间戳)。后端用官方 SDK,>10 年窗口自动切片。
|
||||||
|
*/
|
||||||
|
export function v2PriceHistorical(
|
||||||
|
thscode: string,
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
adjust = "forward",
|
||||||
|
): Promise<V2PriceBar[]> {
|
||||||
|
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<Array<{
|
||||||
|
date: string;
|
||||||
|
open: number;
|
||||||
|
close: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
volume: number;
|
||||||
|
changePercent: number;
|
||||||
|
}>> {
|
||||||
|
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<V2Valuation[]> {
|
||||||
|
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<V2Financial[]> {
|
||||||
|
return v2Get(`/financials/${statement}?thscode=${encodeURIComponent(thscode)}&period=${period}&limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function v2TradingDays(): Promise<V2TradingDay[]> {
|
||||||
|
return v2Get(`/calendar/trading-days`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 指数 / 板块 ── */
|
||||||
|
|
||||||
|
export function v2IndexCatalog(tag = "industry"): Promise<V2IndexItem[]> {
|
||||||
|
return v2Get(`/index/catalog?tag=${tag}`);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState, useEffect, useMemo, Fragment } from "react";
|
import { useState, useEffect, useMemo, Fragment } from "react";
|
||||||
import { collectionsApi } from "@/lib/api-client";
|
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 StockProfileTabs from "@/components/stock-profile-tabs";
|
||||||
import { getUserId } from "@/lib/user-id";
|
import { getUserId } from "@/lib/user-id";
|
||||||
import { formatMoney } from "@/lib/utils";
|
import { formatMoney } from "@/lib/utils";
|
||||||
@@ -182,7 +183,7 @@ function StockDetail() {
|
|||||||
setFundFlowLoading(true);
|
setFundFlowLoading(true);
|
||||||
|
|
||||||
const [historyResult, fundFlowResult] = await Promise.allSettled([
|
const [historyResult, fundFlowResult] = await Promise.allSettled([
|
||||||
fetchStockHistory(code, chartDays),
|
fetchStockHistoryV2(code, chartDays),
|
||||||
fetchStockFundFlow(code, quote.name, 21),
|
fetchStockFundFlow(code, quote.name, 21),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user