"""股票域路由:全市场列表 + 筛选项 + 个股公司/财务/分红/参考数据懒加载 + 手动数据同步。""" 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 company as company_mod from ..data import dividend as dividend_mod from ..data import finance as finance_mod from ..data import reference as reference_mod from ..data import fetcher from ..data.symbols import is_etf_symbol, to_ts_code from ..db import get_session from ..schemas import ( FacetItemOut, StockCompanyOut, StockDividendOut, StockFacetsResponse, StockFinanceOut, StockListItemOut, StockListResponse, StockReferenceOut, SyncRequest, SyncResponse, ) from ._deps import cached_json_response, raw_json router = APIRouter() @router.post("/data/sync", response_model=SyncResponse) async def sync_data(req: SyncRequest, session: AsyncSession = Depends(get_session)) -> SyncResponse: """主动拉取并缓存某标的的日线(Tushare 主 -> AKShare 兜底)。""" try: res = await fetcher.sync_symbol( session, req.symbol, start=req.start, end=req.end, source=req.source, force=req.force ) return SyncResponse(**res) except Exception as e: # noqa: BLE001 raise HTTPException(status_code=502, detail=str(e)) # ---------- 股票列表(全市场浏览) ---------- # 过滤/排序/分页在 stock_basic+watchlist+daily_snapshot 上完成(快照按最新交易日 # 走唯一索引 join,便宜),再对「本页」≤limit 只股票补最新价/昨收(LATERAL 扫 # candles,贵)——旧写法对全市场 ~5000 只逐个算,每页都白算 50 倍的行情量。 # 排序列白名单(键→CTE 内表达式);order_by 由白名单拼接进模板,不接收用户原文。 _STOCKS_SORTS = { "symbol": "sb.symbol", "total_mv": "snap.total_mv", "circ_mv": "snap.circ_mv", "pe_ttm": "snap.pe_ttm", "pb": "snap.pb", "turnover_rate": "snap.turnover_rate", } _STOCKS_SQL_TMPL = """ WITH page AS ( SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market, (w.id IS NOT NULL) AS watched, (h.id IS NOT NULL) AS held, snap.turnover_rate, snap.pe_ttm, snap.pb, snap.total_mv, snap.circ_mv FROM stock_basic sb LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid LEFT JOIN holding_items h ON h.ts_code = sb.ts_code AND h.user_id = :uid LEFT JOIN daily_snapshot snap ON snap.ts_code = sb.ts_code AND snap.trade_date = (SELECT max(trade_date) FROM daily_snapshot) WHERE sb.list_status = 'L' AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch) AND (:market = '' OR sb.market = :market) AND (:industry = '' OR sb.industry = :industry) AND (:area = '' OR sb.area = :area) AND (:watched_only = false OR w.id IS NOT NULL) AND (:held_only = false OR h.id IS NOT NULL) ORDER BY {order_by} LIMIT :limit OFFSET :offset ) SELECT p.ts_code, p.symbol, p.name, p.industry, p.market, p.watched, p.held, 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, p.turnover_rate, p.pe_ttm, p.pb, round((p.total_mv / 10000.0)::numeric, 2) AS total_mv, round((p.circ_mv / 10000.0)::numeric, 2) AS circ_mv FROM page p LEFT JOIN LATERAL ( SELECT close, ts FROM candles WHERE symbol = p.symbol AND timeframe = '1d' ORDER BY ts DESC LIMIT 1 ) c ON true LEFT JOIN LATERAL ( SELECT close FROM candles WHERE symbol = p.symbol AND timeframe = '1d' AND ts < c.ts ORDER BY ts DESC LIMIT 1 ) prev ON c.ts IS NOT NULL """ def _stocks_sql(sort: str, order: str) -> TextClause: col = _STOCKS_SORTS.get(sort, _STOCKS_SORTS["symbol"]) direction = "DESC" if order == "desc" else "ASC" nulls = " NULLS LAST" if col != "sb.symbol" else "" # 快照缺失/亏损无 PE 的排最后 return text(_STOCKS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}")) _STOCKS_COUNT_SQL = text(""" SELECT count(*) FROM stock_basic sb LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid LEFT JOIN holding_items h ON h.ts_code = sb.ts_code AND h.user_id = :uid WHERE sb.list_status = 'L' AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch) AND (:market = '' OR sb.market = :market) AND (:industry = '' OR sb.industry = :industry) AND (:area = '' OR sb.area = :area) AND (:watched_only = false OR w.id IS NOT NULL) AND (:held_only = false OR h.id IS NOT NULL) """) @router.get("/stocks", response_model=StockListResponse) async def list_stocks( search: str = "", market: str = "", industry: str = "", area: str = "", watched_only: bool = False, held_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: """全市场股票列表:stock_basic 基本信息 + candles 最新行情 + daily_snapshot 估值指标 (换手率/PE-TTM/PB/市值,无快照则这些列为空)。 watched_only=true 只看自选,held_only=true 只看持仓(各有独立分类入口,列表不再把它们排最前)。 sort ∈ {symbol,total_mv,circ_mv,pe_ttm,pb,turnover_rate}(白名单,其他值回落 symbol), order ∈ asc/desc;快照列排序时缺失值(无快照/亏损无 PE)恒排末尾。 缓存:按「用户自选/持仓版本 + 查询参数(含排序)」缓存整页(含 total);自选/持仓增删即时失效; 与 preview 同款序列化 JSON 直返(j 前缀),命中跳过 pydantic 校验/序列化。""" search = search.strip() sort = sort if sort in _STOCKS_SORTS else "symbol" order = "desc" if order.lower() == "desc" else "asc" limit = max(1, min(limit, 500)) offset = max(0, offset) key = ( f"stocksj:u{user.id}" f":v{await cache.get_version(f'watchlist:{user.id}')}" f":h{await cache.get_version(f'holding:{user.id}')}" f":{cache.digest(search, market, industry, area, watched_only, held_only, sort, order, limit, offset)}" ) cached = await cached_json_response(key) if cached is not None: return cached params = { "search": search, "psearch": f"%{search}%", "market": market, "industry": industry, "area": area, "watched_only": watched_only, "held_only": held_only, "uid": user.id, "limit": limit, "offset": offset, } total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one() rows = (await session.execute(_stocks_sql(sort, order), params)).mappings().all() resp = StockListResponse(total=total, items=[StockListItemOut(**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.get("/stocks/facets", response_model=StockFacetsResponse) async def stock_facets(session: AsyncSession = Depends(get_session)) -> Response: """看股页筛选项:行业 / 地域(含数量,按数量降序)。stock_basic 很少变,长缓存。""" cached = await cached_json_response("facetsj:stocks") if cached is not None: return cached industries = ( await session.execute(text(""" SELECT industry AS name, count(*) AS n FROM stock_basic WHERE list_status = 'L' AND industry IS NOT NULL AND industry <> '' GROUP BY industry ORDER BY n DESC """)) ).mappings().all() areas = ( await session.execute(text(""" SELECT area AS name, count(*) AS n FROM stock_basic WHERE list_status = 'L' AND area IS NOT NULL AND area <> '' GROUP BY area ORDER BY n DESC """)) ).mappings().all() resp = StockFacetsResponse( industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries], areas=[FacetItemOut(name=r["name"], count=r["n"]) for r in areas], ) raw = raw_json(resp) cache.local_set("facetsj:stocks", raw, ttl=min(120, settings.facets_cache_ttl)) cache.set_bg("facetsj:stocks", raw, ttl=settings.facets_cache_ttl) return Response(content=raw, media_type="application/json") @router.get("/stocks/{ts_code}/company", response_model=StockCompanyOut) async def stock_company_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockCompanyOut: """公司简介:库内有新鲜行直返;否则锁内单查 tushare(stock_company)并 upsert(行即缓存, 30 天新鲜度,无此股写墓碑负缓存)。ETF 前置短路;确认无数据 404;tushare 失败且 无旧行可降级时 503(有旧行则在数据层降级返回旧行)。""" code = ts_code.strip().upper() if "." not in code: code = to_ts_code(code) # 防御:兼容 6 位裸代码 if is_etf_symbol(code): raise HTTPException(status_code=404, detail="ETF 无公司简介") try: row = await company_mod.get_company(session, code) except Exception: raise HTTPException(status_code=503, detail="tushare 公司简介拉取失败,请稍后重试") if row is None: raise HTTPException(status_code=404, detail=f"无公司信息: {code}") return StockCompanyOut(**row) @router.get("/stocks/{ts_code}/finance", response_model=StockFinanceOut) async def stock_finance_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockFinanceOut: """财务数据(近五年,按报告期倒序):库内新鲜直返;否则锁内拉 tushare 四源 (fina_indicator/income/balancesheet/cashflow)合并 upsert(7 天新鲜度,无数据写墓碑)。 ETF 前置短路;确认无数据 404;tushare 四源全失败且无旧行可降级时 503。""" code = ts_code.strip().upper() if "." not in code: code = to_ts_code(code) if is_etf_symbol(code): raise HTTPException(status_code=404, detail="ETF 无财务数据") try: rows = await finance_mod.get_finance(session, code) except Exception: raise HTTPException(status_code=503, detail="tushare 财务数据拉取失败,请稍后重试") if not rows: raise HTTPException(status_code=404, detail=f"无财务数据: {code}") return StockFinanceOut(ts_code=code, records=rows) @router.get("/stocks/{ts_code}/dividends", response_model=StockDividendOut) async def stock_dividend_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockDividendOut: """分红送股(全历史,按分红年度倒序):库内新鲜直返;否则锁内拉 tushare dividend 全量替换(7 天新鲜度,无分红写墓碑,空列表是正常返回)。 ETF 前置短路;tushare 失败且无旧行可降级时 503。""" code = ts_code.strip().upper() if "." not in code: code = to_ts_code(code) if is_etf_symbol(code): raise HTTPException(status_code=404, detail="ETF 无分红数据") try: rows = await dividend_mod.get_dividends(session, code) except Exception: raise HTTPException(status_code=503, detail="tushare 分红数据拉取失败,请稍后重试") return StockDividendOut(ts_code=code, records=rows) @router.get("/stocks/{ts_code}/reference/{kind}", response_model=StockReferenceOut) async def stock_reference_info(ts_code: str, kind: str, session: AsyncSession = Depends(get_session)) -> StockReferenceOut: """参考数据(11 类,kind 白名单见 reference.REFERENCE_KINDS):单股单分类 JSON 快照 懒加载(7 天新鲜度,无数据写墓碑,空 records 是正常返回)。repurchase 为全市场 按月回填的特殊管道:首次触发后台回填近 24 个月(本次可能返回空,稍后再看)。 ETF 前置短路;未知 kind 404;tushare 失败且无旧行可降级时 503。""" code = ts_code.strip().upper() if "." not in code: code = to_ts_code(code) if is_etf_symbol(code): raise HTTPException(status_code=404, detail="ETF 无参考数据") if kind not in reference_mod.REFERENCE_KINDS: raise HTTPException(status_code=404, detail=f"未知参考数据分类: {kind}") try: rows = await reference_mod.get_reference(session, code, kind) except Exception: raise HTTPException(status_code=503, detail="tushare 参考数据拉取失败,请稍后重试") return StockReferenceOut(ts_code=code, kind=kind, records=rows)