134 lines
5.8 KiB
Python
134 lines
5.8 KiB
Python
"""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 最新 bar(LATERAL),必须在分页前 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}(白名单,其他值回落 symbol),order ∈ 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))
|