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

284 lines
13 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.
"""股票域路由:全市场列表 + 筛选项 + 个股公司/财务/分红/参考数据懒加载 + 手动数据同步。"""
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:
"""公司简介:库内有新鲜行直返;否则锁内单查 tusharestock_company并 upsert行即缓存
30 天新鲜度无此股写墓碑负缓存。ETF 前置短路;确认无数据 404tushare 失败且
无旧行可降级时 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合并 upsert7 天新鲜度,无数据写墓碑)。
ETF 前置短路;确认无数据 404tushare 四源全失败且无旧行可降级时 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 404tushare 失败且无旧行可降级时 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)