提交
This commit is contained in:
283
backend/app/api/stocks.py
Normal file
283
backend/app/api/stocks.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""股票域路由:全市场列表 + 筛选项 + 个股公司/财务/分红/参考数据懒加载 + 手动数据同步。"""
|
||||
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)
|
||||
Reference in New Issue
Block a user