"""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))