This commit is contained in:
2026-09-09 15:07:58 +08:00
parent d656c05b3d
commit 71a0f6e404
31 changed files with 3657 additions and 9 deletions

133
backend/app/api/etfs.py Normal file
View File

@@ -0,0 +1,133 @@
"""ETF 域路由:全市场列表(东财快照 + candles 行情)+ 同步任务。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import TextClause
from .. import cache
from ..auth import require_user
from ..config import settings
from ..data import etf_sync as etf_sync_mod
from ..db import get_session
from ..schemas import EtfListItemOut, EtfListResponse, EtfSyncRequest, EtfSyncStatus
from ._deps import cached_json_response, raw_json
router = APIRouter()
# ---------- ETF 列表(全市场浏览;行情走 candles 底座,规模/换手走东财快照) ----------
# 与 /stocks 不同:成交额来自 candles 最新 barLATERAL必须在分页前 join 才能参与
# 排序 —— ETF 全市场仅 ~1100 行3 个索引探测/行 也就几 ms可以承受。
# 排序列白名单键→表达式order_by 由白名单拼接进模板,不接收用户原文。
_ETFS_SORTS = {
"symbol": "eb.symbol",
"close": "c.close",
"pct_chg": "pct_chg",
"amount": "c.amount",
"total_mv": "eb.total_mv",
"circ_mv": "eb.circ_mv",
"turnover_rate": "eb.turnover_rate",
}
_ETFS_SQL_TMPL = """
SELECT eb.ts_code, eb.symbol, eb.name, eb.exchange, eb.list_date,
(w.id IS NOT NULL) AS watched,
eb.turnover_rate,
round((eb.total_mv / 100000000.0)::numeric, 2) AS total_mv,
round((eb.circ_mv / 100000000.0)::numeric, 2) AS circ_mv,
c.close AS close, prev.close AS prev_close, c.ts AS last_ts,
CASE WHEN c.close IS NOT NULL AND prev.close IS NOT NULL AND prev.close <> 0
THEN round(((c.close / prev.close - 1) * 100)::numeric, 2) END AS pct_chg,
round((c.amount / 100000000.0)::numeric, 2) AS amount
FROM etf_basic eb
LEFT JOIN watchlist_items w ON w.ts_code = eb.ts_code AND w.user_id = :uid
LEFT JOIN LATERAL (
SELECT close, ts, amount FROM candles
WHERE symbol = eb.symbol AND timeframe = '1d'
ORDER BY ts DESC LIMIT 1
) c ON true
LEFT JOIN LATERAL (
SELECT close FROM candles
WHERE symbol = eb.symbol AND timeframe = '1d' AND ts < c.ts
ORDER BY ts DESC LIMIT 1
) prev ON c.ts IS NOT NULL
WHERE (:search = '' OR eb.symbol LIKE :psearch OR eb.name LIKE :psearch)
AND (:exchange = '' OR eb.exchange = :exchange)
AND (:watched_only = false OR w.id IS NOT NULL)
ORDER BY {order_by}
LIMIT :limit OFFSET :offset
"""
_ETFS_COUNT_SQL = text("""
SELECT count(*) FROM etf_basic eb
LEFT JOIN watchlist_items w ON w.ts_code = eb.ts_code AND w.user_id = :uid
WHERE (:search = '' OR eb.symbol LIKE :psearch OR eb.name LIKE :psearch)
AND (:exchange = '' OR eb.exchange = :exchange)
AND (:watched_only = false OR w.id IS NOT NULL)
""")
def _etfs_sql(sort: str, order: str) -> TextClause:
col = _ETFS_SORTS.get(sort, _ETFS_SORTS["symbol"])
direction = "DESC" if order == "desc" else "ASC"
nulls = " NULLS LAST" if col != "eb.symbol" else "" # 无行情/无快照的排最后
return text(_ETFS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}"))
@router.get("/etfs", response_model=EtfListResponse)
async def list_etfs(
search: str = "",
exchange: str = "",
watched_only: bool = False,
sort: str = "symbol",
order: str = "asc",
limit: int = 100,
offset: int = 0,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> Response:
"""全市场场内 ETF 列表etf_basic 名称/规模(东财快照)+ candles 最新收盘/涨跌幅/成交额。
exchange ∈ {SH, SZ}(空 = 全部sort ∈ {symbol,close,pct_chg,amount,total_mv,circ_mv,
turnover_rate}(白名单,其他值回落 symbolorder ∈ asc/desc快照/行情列排序时
缺失值恒排末尾。缓存:按「用户自选版本 + etf 版本 + 查询参数」缓存整页,
ETF 同步完成bump ver:etf / ver:candles或自选增删即失效。
"""
search = search.strip()
sort = sort if sort in _ETFS_SORTS else "symbol"
order = "desc" if order.lower() == "desc" else "asc"
limit = max(1, min(limit, 500))
offset = max(0, offset)
key = (
f"etfsj:u{user.id}"
f":v{await cache.get_version(f'watchlist:{user.id}')}"
f":v{await cache.get_version('etf')}"
f":{cache.digest(search, exchange, watched_only, sort, order, limit, offset)}"
)
cached = await cached_json_response(key)
if cached is not None:
return cached
params = {
"search": search, "psearch": f"%{search}%",
"exchange": exchange.upper(), "watched_only": watched_only,
"uid": user.id, "limit": limit, "offset": offset,
}
total = (await session.execute(_ETFS_COUNT_SQL, params)).scalar_one()
rows = (await session.execute(_etfs_sql(sort, order), params)).mappings().all()
resp = EtfListResponse(total=total, items=[EtfListItemOut(**r) for r in rows])
raw = raw_json(resp)
cache.local_set(key, raw, ttl=min(120, settings.stocks_cache_ttl))
cache.set_bg(key, raw, ttl=settings.stocks_cache_ttl)
return Response(content=raw, media_type="application/json")
@router.post("/etf/sync", response_model=EtfSyncStatus)
async def etf_sync_start(req: EtfSyncRequest) -> EtfSyncStatus:
"""启动全市场 ETF 同步(后台任务:东财快照 -> etf_basic逐只日线 -> candles"""
return EtfSyncStatus(**await etf_sync_mod.start_sync(full=req.full))
@router.get("/etf/sync/status", response_model=EtfSyncStatus)
async def etf_sync_status(session: AsyncSession = Depends(get_session)) -> EtfSyncStatus:
"""ETF 同步任务状态与数据实况ETF 数 / 最新交易日)。"""
return EtfSyncStatus(**await etf_sync_mod.get_status(session))