Files
stock/backend/app/api/etfs.py
2026-09-09 15:07:58 +08:00

134 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))