284 lines
13 KiB
Python
284 lines
13 KiB
Python
"""股票域路由:全市场列表 + 筛选项 + 个股公司/财务/分红/参考数据懒加载 + 手动数据同步。"""
|
||
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)
|