This commit is contained in:
2026-09-09 15:07:58 +08:00
parent d656c05b3d
commit 71a0f6e404
31 changed files with 3657 additions and 9 deletions

View File

@@ -0,0 +1,30 @@
"""HTTP 路由OpenAPI 契约的载体)——按域拆分:
stocks.py /api/stocks*(列表/筛选项/公司/财务/分红/参考数据)+ /api/data/sync
etfs.py /api/etfs + /api/etf/sync*
market.py /api/market/*(总览/打板/概念板块/指数 K 线与详情)
backtest.py /api/backtest + /api/backtest/event
screener.py /api/screener/*(选股/历史/同步/个股预览)
user.py /api/preferences + /api/watchlist* + /api/trades*
_deps.py 共享件JSON 直返缓存、复权换算、行转 Bar、共享常量与 SQL
统一约定prefix=/api 与 require_user 鉴权在本层挂一次,子路由不带前缀;
路由注册顺序 = include 顺序(各域路径前缀互不重叠,顺序不影响匹配)。
"""
from fastapi import APIRouter, Depends
from ..auth import require_user
from .backtest import router as backtest_router
from .etfs import router as etfs_router
from .market import router as market_router
from .screener import router as screener_router
from .stocks import router as stocks_router
from .user import router as user_router
router = APIRouter(prefix="/api", dependencies=[Depends(require_user)])
router.include_router(stocks_router)
router.include_router(etfs_router)
router.include_router(market_router)
router.include_router(backtest_router)
router.include_router(screener_router)
router.include_router(user_router)

145
backend/app/api/_deps.py Normal file
View File

@@ -0,0 +1,145 @@
"""路由包共享件JSON 直返缓存、复权换算、行转 Bar、共享常量与 SQL。
各域路由模块stocks/etfs/market/backtest/screener/user从这里取公共工具
域内私有工具留在各自文件里。
"""
from __future__ import annotations
import bisect
import pandas as pd
from fastapi import Response
from sqlalchemy import text
from .. import cache
from ..domain import Bar
# 复权模式白名单
ADJUST_MODES = ("bfq", "qfq", "hfq")
# MA 全量集合(前端已改为本地计算 MA后端始终返回此集合以保证缓存一致
FULL_MA_SET = (5, 10, 20, 30, 60, 120, 250)
# 指数 K 线支持的周期(日线基底聚合)
INDEX_TIMEFRAMES = ("1d", "1w", "1M", "1y")
def raw_json(resp) -> str:
"""pydantic-coreRust序列化与 response_model 直返时的字节完全一致(紧凑分隔符、
非 ASCII 直出、浮点小数形式),且比 stdlib json.dumps 快。大响应preview ~250KB
命中缓存时直接 Response 原样返回,跳过校验/再序列化。"""
return resp.model_dump_json()
async def cached_json_response(key: str) -> Response | None:
"""两级缓存读(进程内 → Redis命中返回可直接吐给客户端的 Response。
存的均为序列化好的 JSON 字符串Redis 侧 json.loads 后仍是 strRedis 命中顺手晋级本地。"""
raw = cache.local_get(key)
if raw is None:
raw = await cache.cache_get(key)
if not isinstance(raw, str):
return None
cache.local_set(key, raw, ttl=120)
return Response(content=raw, media_type="application/json")
def series_to_jsonable(s: pd.Series) -> list[float | None]:
"""NaN -> Nonelightweight-charts 的 whitespace data跳过指标预热期"""
out: list[float | None] = []
for v in s.tolist():
if v is None or (isinstance(v, float) and v != v):
out.append(None)
else:
out.append(float(v))
return out
def rows_to_bars(rows) -> list[Bar]:
return [
Bar(
ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume,
amount=getattr(r, "amount", None), turnover=getattr(r, "turnover", None),
)
for r in rows
]
# 信息卡一条 SQL 拿全stock_basic 基本信息 + 「优先与行情同日、缺则最新日」的 daily_snapshot
# LATERAL 单条替换原两条查询语义不变target 为 NULL 时全按最新日兜底)。
# ETF 走 etf_basic 分支(代码前缀与股票不重叠,两分支至多一个命中):
# 名称/上市日来自表内,市值(元)换算成万元与快照口径一致,无 PE/PB。
INFO_SQL = text(
"""
SELECT ts_code, symbol, name, industry, area, market, list_date,
turnover_rate, pe_ttm, pb, total_mv, circ_mv
FROM (
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.area, sb.market, sb.list_date,
ds.turnover_rate, ds.pe_ttm, ds.pb, ds.total_mv, ds.circ_mv
FROM stock_basic sb
LEFT JOIN LATERAL (
SELECT turnover_rate, pe_ttm, pb, total_mv, circ_mv
FROM daily_snapshot
WHERE ts_code = sb.ts_code
ORDER BY (trade_date = cast(:target AS timestamp)) DESC, trade_date DESC
LIMIT 1
) ds ON true
WHERE sb.ts_code = :code
UNION ALL
SELECT eb.ts_code, eb.symbol, eb.name, NULL, NULL,
CASE eb.exchange WHEN 'SH' THEN '沪市' ELSE '深市' END, eb.list_date,
eb.turnover_rate, NULL, NULL,
eb.total_mv / 10000.0, eb.circ_mv / 10000.0
FROM etf_basic eb
WHERE eb.ts_code = :code
) t
LIMIT 1
"""
)
# 复权因子是阶梯函数(除权日之间不变):只取「变化点」行,把每符号 ~7000 行日级因子压到
# 几十行600118 仅 32 行传输量再降两个数量级lag 窗口在覆盖索引上走 Index Only Scan。
# upto 传全局最新交易日(非分页)或 end 日期(分页);窗口首行 prev 为 NULL 恒被保留(窗口基线因子)。
FACTOR_STEP_SQL = text(
"""
SELECT trade_date, adj_factor FROM (
SELECT trade_date, adj_factor,
lag(adj_factor) OVER (ORDER BY trade_date) AS prev
FROM adj_factor
WHERE ts_code = :code AND trade_date <= :upto
) t
WHERE adj_factor IS DISTINCT FROM prev
ORDER BY trade_date
"""
)
def adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list[Bar]:
"""按复权因子把 K 线从 from_mode 换算到 to_modebfq/qfq/hfq
相对不复权的乘数bfq=1qfq=f(t)/f(latest)hfq=f(t)。
因子缺失的日期向前沿用最近因子(因子是阶梯函数,除权日之间不变)。
"""
fd = sorted((f[0].date(), float(f[1])) for f in factors)
fdates = [d for d, _ in fd]
f_latest = fd[-1][1]
def _f_at(d) -> float:
i = bisect.bisect_right(fdates, d) - 1
return fd[i][1] if i >= 0 else fd[0][1]
def _mult(mode: str, f: float) -> float:
if mode == "bfq":
return 1.0
return f / f_latest if mode == "qfq" else f
out: list[Bar] = []
for b in bars:
f = _f_at(b.ts.date())
m = _mult(to_mode, f) / _mult(from_mode, f)
out.append(Bar(
ts=b.ts,
open=round(b.open * m, 3), high=round(b.high * m, 3),
low=round(b.low * m, 3), close=round(b.close * m, 3),
volume=b.volume,
# 成交额/换手率是名义量,不随复权缩放
amount=b.amount, turnover=b.turnover,
))
return out

152
backend/app/api/backtest.py Normal file
View File

@@ -0,0 +1,152 @@
"""回测域路由:策略回测(旧 APIK线+指标+买卖点+净值)+ 自然语言事件回测。"""
from __future__ import annotations
import json
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from ..backtest.engine import BacktestConfig, run_backtest
from ..backtest.events import EventEngineError, run_event_backtest
from ..backtest.strategies import build_strategy
from ..data import fetcher, repository
from ..data.aggregation import bars_per_year, resample_bars
from ..data.symbols import is_etf_symbol
from ..db import get_session
from ..models import BacktestRun
from ..schemas import (
BacktestRequest,
BacktestResponse,
CandleOut,
EquityPoint,
EventBacktestRequest,
EventBacktestResponse,
IndicatorOut,
MetricsOut,
SignalOut,
)
from ..screener.llm import parse_event_spec, ScreenerError
from ._deps import rows_to_bars, series_to_jsonable
router = APIRouter()
@router.post("/backtest", response_model=BacktestResponse)
async def backtest(
req: BacktestRequest,
session: AsyncSession = Depends(get_session),
) -> BacktestResponse:
# 真实数据:本地无缓存则先拉取
if not await fetcher.is_cached(session, req.symbol):
try:
await fetcher.sync_symbol(session, req.symbol, source="auto")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"数据拉取失败: {e}")
# 日线为基底,聚合到请求周期
rows = await repository.get_candles(
session, req.symbol, "1d", start=req.start, end=req.end, limit=100000
)
if not rows:
raise HTTPException(status_code=404, detail=f"无数据: symbol={req.symbol}")
bars = resample_bars(rows_to_bars(rows), req.timeframe)
if len(bars) < 2:
raise HTTPException(status_code=400, detail=f"周期 {req.timeframe} 下数据不足,无法回测")
try:
strategy = build_strategy(req.strategy, req.params)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"策略构建失败: {e}")
cfg = BacktestConfig(
initial_cash=req.initial_cash,
fast_mode=req.fast_mode,
bars_per_year=bars_per_year(req.timeframe),
is_fund=is_etf_symbol(req.symbol), # ETF 免印花税/过户费
)
result = run_backtest(bars, strategy, cfg)
df: pd.DataFrame = result["df"]
m = result["metrics"]
# 记录到回测运行注册表(可复现/可审计的基础)
session.add(
BacktestRun(
symbol=req.symbol,
strategy=req.strategy,
timeframe=req.timeframe,
params_json=json.dumps(req.params, ensure_ascii=False),
initial_cash=req.initial_cash,
total_return=m["total_return"],
max_drawdown=m["max_drawdown"],
sharpe=m["sharpe"],
num_trades=m["num_trades"],
)
)
await session.commit()
candles = [
CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"],
close=r["close"], volume=r["volume"],
amount=r["amount"] if "amount" in df.columns else None,
turnover=r["turnover"] if "turnover" in df.columns else None)
for _, r in df.iterrows()
]
signals = [
SignalOut(ts=f.ts, side=f.side.value, price=f.price, qty=f.qty)
for f in result["fills"]
]
indicators = IndicatorOut(
strategy=req.strategy,
data={col: series_to_jsonable(df[col]) for col in result["indicator_cols"]},
)
equity = [EquityPoint(ts=t.to_pydatetime(), value=float(v))
for t, v in result["equity"].items()]
return BacktestResponse(
symbol=req.symbol,
timeframe=req.timeframe,
strategy=req.strategy,
candles=candles,
indicators=indicators,
signals=signals,
equity=equity,
metrics=MetricsOut(**m),
final_cash=result["final_cash"],
final_position=result["final_position"],
initial_cash=req.initial_cash,
)
@router.post("/backtest/event", response_model=EventBacktestResponse)
async def backtest_event(
req: EventBacktestRequest,
session: AsyncSession = Depends(get_session),
) -> EventBacktestResponse:
"""自然语言事件回测:入场条件命中 -> 次日买入 -> 持有 N 日,单股或全市场汇总统计。
直传 spec 则跳过 LLM前端调参重跑"""
try:
spec = req.spec or await parse_event_spec(req.text)
result = await run_event_backtest(
session, spec,
ts_code=req.ts_code,
start=req.start.date() if req.start else None,
end=req.end.date() if req.end else None,
)
except ScreenerError as e:
raise HTTPException(status_code=502, detail=str(e))
except EventEngineError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"事件回测失败: {e}")
return EventBacktestResponse(
text=req.text,
spec=result["spec"],
universe=result["universe"],
start=result["start"],
end=result["end"],
stats=result["stats"],
trades=result["trades"],
total=result["total"],
)

133
backend/app/api/etfs.py Normal file
View File

@@ -0,0 +1,133 @@
"""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 最新 barLATERAL必须在分页前 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}(白名单,其他值回落 symbolorder ∈ 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))

252
backend/app/api/market.py Normal file
View File

@@ -0,0 +1,252 @@
"""行情专题路由:大盘总览 / 打板 / 概念板块 / 指数(上证 K 线、国际指数、指数详情与权重)。"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import TypeAdapter
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import cache
from ..data import index_global as index_mod
from ..data import limit_board as limit_board_mod
from ..data import ths_board as ths_board_mod
from ..data.index_series import SH_INDEX, get_index_daily
from ..data.aggregation import resample_bars
from ..data.limit_board import LimitBoardError
from ..data.market_overview import MarketOverviewError, fetch_overview
from ..data.ths_board import ThsBoardError
from ..db import async_session, get_session
from ..models import StockBasic, TradeCalendar
from ..schemas import (
CandleOut,
GlobalIndexListResponse,
IndexBasicOut,
IndexDetailResponse,
IndexQuoteBriefOut,
IndexValuationPointOut,
IndexWeightItemOut,
IndexWeightsResponse,
LimitBoardResponse,
MarketOverviewResponse,
ThsBoardListResponse,
ThsBoardMembersResponse,
)
from ._deps import INDEX_TIMEFRAMES, cached_json_response
router = APIRouter()
async def _is_trading_day_today() -> bool | None:
"""今日是否 A 股交易日trade_date 为 String(8) unique 索引,等值查亚毫秒级);
DB 不可用时返回 None调用方回退 weekday 启发式(只影响盘中 TTL 精度)。"""
today8 = datetime.now().strftime("%Y%m%d")
try:
async with async_session() as s:
return bool(await s.scalar(
select(TradeCalendar.id).where(TradeCalendar.trade_date == today8).limit(1)
))
except Exception: # noqa: BLE001
return None
@router.get("/market/overview", response_model=MarketOverviewResponse)
async def get_market_overview(session: AsyncSession = Depends(get_session)) -> MarketOverviewResponse:
"""主页大盘总览A 股 + 港美指数实时价腾讯叠加收盘历史走势tushare
沪深两市市值/成交统计 + 成交额历史。部分来源失败不影响其余。
"""
is_trading_day: bool | None = None
try:
is_trading_day = bool(await session.scalar(
select(TradeCalendar.id).where(
TradeCalendar.trade_date == datetime.now().strftime("%Y%m%d")).limit(1)
))
except Exception: # noqa: BLE001 —— 判定失败只影响「今日盘中 bar」是否追加
pass
try:
data = await fetch_overview(is_trading_day=is_trading_day)
except MarketOverviewError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
return MarketOverviewResponse(**data)
@router.get("/market/limit-board", response_model=LimitBoardResponse)
async def get_limit_board(session: AsyncSession = Depends(get_session)) -> LimitBoardResponse:
"""首页打板专题(同花顺口径):涨停/炸板/跌停三池 + 连板天梯 + 涨停最强板块,
当日快照(盘中 5 分钟 / 盘后 4 小时,整包 SWR 缓存)。部分池失败不影响其余。"""
is_trading_day: bool | None = None
try:
is_trading_day = bool(await session.scalar(
select(TradeCalendar.id).where(
TradeCalendar.trade_date == datetime.now().strftime("%Y%m%d")).limit(1)
))
except Exception: # noqa: BLE001 —— 判定失败回退 weekday 启发式(影响盘中 TTL 精度而已)
pass
try:
data = await limit_board_mod.fetch_limit_board(is_trading_day)
except LimitBoardError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
return LimitBoardResponse(**data)
@router.get("/market/boards", response_model=ThsBoardListResponse)
async def list_ths_boards() -> ThsBoardListResponse:
"""概念/行业板块列表(同花顺口径,全部类型一次给全,前端本地过滤):
ths_index 列表直缓存 24h + ths_daily 当日快照 SWR盘中 5 分钟 / 盘后 4 小时)。"""
try:
data = await ths_board_mod.fetch_boards(await _is_trading_day_today())
except ThsBoardError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
return ThsBoardListResponse(**data)
@router.get("/market/boards/{code}/members", response_model=ThsBoardMembersResponse)
async def list_ths_board_members(code: str, session: AsyncSession = Depends(get_session)) -> ThsBoardMembersResponse:
"""板块成分股ths_member 懒加载缓存 24h+ 最新现价/涨跌幅candles LATERAL 现算)。"""
bc = code.strip().upper()
try:
boards = await ths_board_mod.get_board_list()
except ThsBoardError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
board = next((b for b in boards if b["ts_code"] == bc), None)
if board is None:
raise HTTPException(status_code=404, detail=f"未知板块: {bc}")
try:
members = await ths_board_mod.get_members(session, bc)
except Exception:
raise HTTPException(status_code=503, detail="板块成分拉取失败,请稍后重试")
return ThsBoardMembersResponse(code=bc, name=board.get("name"), members=members)
@router.get("/market/index-candles", response_model=list[CandleOut])
async def get_index_candles(timeframe: str = "1d") -> Response:
"""上证指数全量 K 线日线为基底tushare index_daily进程内+Redis 缓存),
聚合到 1d/1w/1M/1y。收盘口径数据随 EOD 更新,与总览 spark 一致)。"""
if timeframe not in INDEX_TIMEFRAMES:
raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(INDEX_TIMEFRAMES)}")
key = f"idxkj:{cache.digest('idxc', SH_INDEX, timeframe)}"
cached = await cached_json_response(key)
if cached is not None:
return cached
try:
bars = resample_bars(await get_index_daily(), timeframe)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}")
outs = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=None)
for b in bars
]
# pydantic-core 序列化(与 raw_json 同款历史不可变、TTL 兜到当日更新
raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode()
cache.local_set(key, raw, ttl=300)
await cache.cache_set(key, raw, ttl=7200)
return Response(content=raw, media_type="application/json")
@router.get("/market/global-indexes", response_model=GlobalIndexListResponse)
async def get_global_indexes() -> GlobalIndexListResponse:
"""国际指数卡片列表index_global 21 个指数最新收盘 + 45 日 sparkSWR 缓存)。"""
try:
data = await index_mod.fetch_global_list()
except index_mod.GlobalIndexError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
payload = dict(data)
payload["updated_at"] = payload.pop("fetched_at")
return GlobalIndexListResponse(**payload)
def _index_or_404(code: str) -> str:
"""详情/K线/权重接口只放行白名单内的指数 code。"""
if not index_mod.ensure_known(code):
raise HTTPException(status_code=404, detail=f"不支持的指数代码: {code}")
return code
@router.get("/market/indexes/{code}", response_model=IndexDetailResponse)
async def get_index_detail(code: str) -> IndexDetailResponse:
"""指数详情聚合:最新行情(收盘口径)+ 基本信息(国内 index_basic / 国际静态表)
+ 估值指标index_dailybasic仅部分国内指数。各层自带缓存直接组装。"""
code = _index_or_404(code)
if index_mod.is_cn_index(code):
name, region = index_mod.CN_INDEXES.get(code, code), "cn"
else:
g = index_mod.GLOBAL_META[code]
name, region = g["name"], g["region"]
try:
quote = await index_mod.fetch_index_quote(code)
except index_mod.GlobalIndexError as e:
raise HTTPException(status_code=502, detail=f"指数行情获取失败: {e}") from e
basic_raw = await index_mod.get_index_basic(code)
basic = IndexBasicOut(**basic_raw) if basic_raw else None
valuation_rows = await index_mod.get_index_valuation(code)
valuation = IndexValuationPointOut(**valuation_rows[-1]) if valuation_rows else None
history = [IndexValuationPointOut(**r) for r in valuation_rows]
return IndexDetailResponse(
code=code, name=name, region=region,
quote=IndexQuoteBriefOut(**quote),
basic=basic, valuation=valuation, valuation_history=history,
)
@router.get("/market/indexes/{code}/candles", response_model=list[CandleOut])
async def get_any_index_candles(code: str, timeframe: str = "1d") -> Response:
"""白名单指数全量 K 线(国内 index_daily / 国际 index_global1d/1w/1M/1y 聚合。
收盘口径历史不可变、TTL 兜到当日更新(与首页上证 K 线同款缓存策略)。"""
code = _index_or_404(code)
if timeframe not in INDEX_TIMEFRAMES:
raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(INDEX_TIMEFRAMES)}")
key = f"idxck:{cache.digest('idxk2', code, timeframe)}"
cached = await cached_json_response(key)
if cached is not None:
return cached
try:
bars = resample_bars(await index_mod.get_index_bars(code), timeframe)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}")
outs = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=None)
for b in bars
]
raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode()
cache.local_set(key, raw, ttl=300)
await cache.cache_set(key, raw, ttl=7200)
return Response(content=raw, media_type="application/json")
@router.get("/market/indexes/{code}/weights", response_model=IndexWeightsResponse)
async def get_index_weights(
code: str,
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> IndexWeightsResponse:
"""指数成分股权重index_weight 最近月度快照,按权重降序取前 limit
仅国内指数有数据;成分股名称从本地 stock_basic 回填。"""
code = _index_or_404(code)
limit = max(1, min(limit, 300))
data = await index_mod.get_index_weights(code)
if data is None:
raise HTTPException(status_code=404, detail=f"该指数暂无成分权重数据: {code}")
items = data["items"][:limit]
codes = [it["con_code"] for it in items]
names: dict[str, str] = {}
if codes:
try:
rows = await session.execute(
select(StockBasic.ts_code, StockBasic.name).where(StockBasic.ts_code.in_(codes))
)
names = {r[0]: r[1] for r in rows.all()}
except Exception: # noqa: BLE001 —— 名称缺失不阻塞权重展示
pass
return IndexWeightsResponse(
trade_date=data["trade_date"], total=data["total"],
items=[IndexWeightItemOut(con_code=c, name=names.get(c), weight=w)
for c, w in ((it["con_code"], it["weight"]) for it in items)],
)

422
backend/app/api/screener.py Normal file
View File

@@ -0,0 +1,422 @@
"""智能选股域路由自然语言选股NDJSON 流式)+ 提问历史 + 全市场同步 + 个股预览。"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from .. import cache
from .. import indicators as ind
from ..auth import require_user
from ..config import settings
from ..data import fetcher, repository
from ..data.aggregation import resample_bars
from ..data.symbols import plain_code
from ..db import async_session, get_session
from ..models import AdjFactor, Candle, ScreenerQuery
from ..schemas import (
CandleOut,
PreviewInfoOut,
PreviewResponse,
ScreenerQueryListResponse,
ScreenerQueryOut,
ScreenerRunRequest,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
)
from ..screener import engine, market_sync
from ..screener.engine import DataNotReadyError
from ..screener.llm import ScreenerError, parse_conditions
from ._deps import (
ADJUST_MODES,
FACTOR_STEP_SQL,
FULL_MA_SET,
INFO_SQL,
adjust_bars,
cached_json_response,
raw_json,
rows_to_bars,
series_to_jsonable,
)
router = APIRouter()
# ---------- 智能选股 ----------
@router.post("/screener/run")
async def screener_run(
req: ScreenerRunRequest,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> StreamingResponse:
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM微调再跑
NDJSON 流式响应(每行一个 JSON 事件,前端逐行渲染进度):
{"type":"stage","key":"llm|date|prefilter|bars|filter_done|done","msg":"","ms":123}
{"type":"parsed","conditions":{…},"ms":456} LLM 解析出的结构化条件
{"type":"candidates","count":5400,"msg":"","ms":…} SQL 预筛后的候选数
{"type":"progress","done":500,"total":5400} 逐股指标过滤进度
{"type":"result","result":{…ScreenerRunResponse…},"ms":…}
{"type":"error","message":"","code":400} 流中途失败HTTP 已 200
成功的提问(含解析出的条件与命中数)记录到 screener_queries供历史一键重跑。
"""
limit = settings.screener_default_limit
async def gen():
try:
if req.conditions:
conds = req.conditions
else:
yield _ndjson({"type": "stage", "key": "llm",
"msg": f"AI 解析条件中({settings.llm_model})…"})
conds = await parse_conditions(req.text)
if not conds.indicator and not conds.snapshot:
yield _ndjson({"type": "error", "code": 400,
"message": "AI 未从描述中解析出任何筛选条件,请换种说法"})
return
yield _ndjson({"type": "parsed", "conditions": conds.model_dump()})
result = None
async for ev in engine.run_screen_events(session, conds, limit):
if ev["type"] == "result":
result = ev["result"]
yield _ndjson({"type": "stage", "key": "done", "ms": ev.get("ms"),
"msg": f"筛选完成:{result['total']} 只命中(数据基准 {result['trade_date']:%Y-%m-%d}"})
else:
yield _ndjson(ev)
if result is None:
yield _ndjson({"type": "error", "code": 500, "message": "选股流程未产出结果"})
return
yield _ndjson({"type": "result", "result": ScreenerRunResponse(**result).model_dump(mode="json")})
# 相同文本 + 相同条件的上一条不重复记录(一键重跑场景)
exists = (
await session.execute(
select(ScreenerQuery.id).where(
ScreenerQuery.user_id == user.id,
ScreenerQuery.text == req.text.strip(),
ScreenerQuery.conditions_json == json.dumps(conds.model_dump(), ensure_ascii=False),
)
)
).scalar_one_or_none()
if exists is None:
session.add(ScreenerQuery(
user_id=user.id,
text=req.text.strip(),
conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False),
hit_count=result.get("total", 0),
))
await session.commit()
except DataNotReadyError as e:
yield _ndjson({"type": "error", "code": 409, "message": str(e)})
except ValueError as e: # 未知指标/字段、条件为空
yield _ndjson({"type": "error", "code": 400, "message": str(e)})
except ScreenerError as e:
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
yield _ndjson({"type": "error", "code": code, "message": str(e)})
except Exception as e: # noqa: BLE001
yield _ndjson({"type": "error", "code": 500, "message": f"选股失败: {e}"})
return StreamingResponse(gen(), media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
def _ndjson(obj: dict) -> str:
"""dict -> NDJSON 行json.dumps 保证 default=str 兜底 datetime 等)。"""
return json.dumps(obj, ensure_ascii=False, default=str) + "\n"
@router.get("/screener/queries", response_model=ScreenerQueryListResponse)
async def screener_queries(
limit: int = 20,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> ScreenerQueryListResponse:
"""当前用户的提问历史(最新在前,含解析出的条件与命中数,可一键重跑)。"""
limit = max(1, min(limit, 100))
rows = (
await session.execute(
select(ScreenerQuery)
.where(ScreenerQuery.user_id == user.id)
.order_by(ScreenerQuery.created_at.desc())
.limit(limit)
)
).scalars().all()
items = []
for r in rows:
conds = None
if r.conditions_json:
try:
from ..schemas import ScreenConditions
conds = ScreenConditions.model_validate_json(r.conditions_json)
except Exception: # noqa: BLE001 —— 旧格式/解析失败则只展示文本
conds = None
items.append(ScreenerQueryOut(
id=r.id, text=r.text, conditions=conds, hit_count=r.hit_count, created_at=r.created_at
))
return ScreenerQueryListResponse(items=items)
@router.delete("/screener/queries/{query_id}", status_code=204)
async def screener_query_delete(
query_id: int,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> None:
await session.execute(
text("DELETE FROM screener_queries WHERE id = :i AND user_id = :u"),
{"i": query_id, "u": user.id},
)
await session.commit()
# ---------- 全市场数据同步 ----------
@router.post("/screener/sync", response_model=ScreenerSyncStatus)
async def screener_sync_start(
req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session)
) -> ScreenerSyncStatus:
"""启动全市场数据同步(后台任务,立即返回状态)。"""
try:
await market_sync.start_sync(session, req.days, req.force)
except ScreenerError as e:
raise HTTPException(status_code=503, detail=str(e))
status = await market_sync.get_sync_status(session)
return ScreenerSyncStatus(**{k: status.get(k) for k in ScreenerSyncStatus.model_fields})
@router.get("/screener/sync/status", response_model=ScreenerSyncStatus)
async def screener_sync_status(session: AsyncSession = Depends(get_session)) -> ScreenerSyncStatus:
"""同步任务状态 + 数据实况(最新交易日/行数/ready"""
status = await market_sync.get_sync_status(session)
return ScreenerSyncStatus(**{k: status.get(k) for k in ScreenerSyncStatus.model_fields})
# ---------- 个股详情预览 ----------
@router.get("/screener/preview/{ts_code}", response_model=PreviewResponse)
async def screener_preview(
ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60",
zx: str = "10,20,30,60", end: str | None = None,
session: AsyncSession = Depends(get_session),
) -> Response:
"""个股详情预览日线candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq
未缓存自动拉取,落后全市场最新交易日则强制刷新)+ 全套指标 + 最新截面信息卡。
timeframe 聚合到周/月/年先复权再聚合mas 指定主图 MA 周期(逗号分隔)。
end=YYYY-MM-DD 时为「向前翻页」:返回该日之前最近 limit 根(含预热计算指标),
has_more 标记窗口前是否还有更早历史,前端据此继续向左滚动加载。"""
if adjust not in ADJUST_MODES:
raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(ADJUST_MODES)}")
if timeframe not in ("1d", "1w", "1M", "1y"):
raise HTTPException(status_code=400, detail="timeframe 仅支持 1d/1w/1M/1y")
try:
ma_periods = sorted({int(p) for p in mas.split(",") if p.strip().isdigit() and 1 <= int(p) <= 500})
except ValueError:
raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60")
if not ma_periods:
ma_periods = [5, 10, 20, 60]
try:
zx_periods = sorted({int(p) for p in zx.split(",") if p.strip().isdigit() and 1 <= int(p) <= 500})
except ValueError:
raise HTTPException(status_code=400, detail="zx 格式应为逗号分隔的数字,如 10,20,30,60")
if not zx_periods:
zx_periods = [10, 20, 30, 60]
limit = max(30, min(limit, 5000))
end_dt: datetime | None = None
if end:
try:
end_dt = datetime.strptime(end.strip()[:10], "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="end 格式应为 YYYY-MM-DD")
symbol = plain_code(ts_code)
# --- 两级读缓存历史窗口end 翻页)只增不改,最新窗口每日由全市场同步推进;
# 键含 ver:candles 版本号同步完成后自增旧缓存全部失效TTL 兜底cache.py
# 存序列化好的 JSON 直返j: 前缀),跳过 json.loads + pydantic 校验/序列化(热路径数百 ms → 个位数)。
# 注ma_periods 不参与缓存键 —— 前端已改为本地计算 MA后端始终返回全量 MA 集合
cache_key = cache.digest(
"preview", ts_code, timeframe, limit, adjust,
end_dt.strftime("%Y-%m-%d") if end_dt else None,
await cache.get_version("candles"),
)
cached = await cached_json_response(f"pvj:{cache_key}")
if cached is not None:
return cached
# --- 日线candles(全量不复权底座);未缓存拉取,落后于全市场最新交易日则强制刷新 ---
# fetcher 只做「不复权」增量 upsert底座口径恒为 bfqTDX 全量 + Tushare 增量),
# 复权qfq/hfq读取时按 adj_factor 表本地换算。
# 每次只取「窗口 + 400 根预热」行MA250/MACD EMA 在 400 根内充分收敛),不拉全量:
# 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。
frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe]
fetch_n = min(100000, limit * frame_mult + 400)
source = "bfq"
mode = "bfq"
# 并发约定:注入 session 与 s2 各占一条连接,每次 gather 里每个 session 恰好跑一条查询
# AsyncSession 单连接非并发安全),把 ~6 次串行 DB RTT 折叠成 2 个波次。
async with async_session() as s2:
if end_dt is not None:
# 向前翻页:取 end 之前的历史窗口不触发同步历史浏览max(ts) 用不到
rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n)
global_latest = None
else:
# Wave 1candles 窗口(注入 session+ 全市场最新交易日s2并行
rows, global_latest = await asyncio.gather(
repository.get_recent_candles(session, symbol, "1d", limit=fetch_n),
s2.scalar(select(func.max(Candle.ts)).where(Candle.timeframe == "1d")),
)
try:
if not rows:
await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
elif global_latest is not None and rows[-1].ts.date() < global_latest.date():
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback()
if not rows:
rows = []
bars = rows_to_bars(rows)
if not bars and end_dt is None:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# 信息卡取未聚合的日线最新 bar聚合后 ts 是周期起点,不适用于「最新交易日」)
last_daily = bars[-1] if bars else None
prev_daily = bars[-2] if len(bars) > 1 else None
# 翻页到底end 之前无数据):返回空页 + has_more=False前端停止向前翻页
# --- Wave 2复权因子s2覆盖索引 Index Only Scan+ 信息卡(注入 sessionLATERAL 一条)并行 ---
async def _fetch_factors() -> list | None:
if adjust == mode or not bars:
return None
# 只取因子「变化点」行(覆盖索引 Index Only Scan免堆访问——adj_factor 堆碎片化
# 严重bisect 在阶梯函数上取值与日级序列逐字节一致
if end_dt is not None:
# 分页:窗口 ≤ end 的变化点 + 全局最新因子qfq 以最新因子归一)
win = list((await s2.execute(FACTOR_STEP_SQL, {"code": ts_code, "upto": end_dt})).all())
if win:
latest_f = (await s2.execute(
select(AdjFactor.trade_date, AdjFactor.adj_factor)
.where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)).first()
if latest_f is not None:
win.append(latest_f)
return win or None
# 非分页:上界 global_latest≥ 最新 bar末项变化点即全局最新因子
# 「窗口 ≤ bars[-1].ts + 单独 latest」少一次查询
return list((await s2.execute(
FACTOR_STEP_SQL, {"code": ts_code, "upto": global_latest}
)).all()) or None
factors, info_row = await asyncio.gather(
_fetch_factors(),
session.execute(INFO_SQL, {"code": ts_code, "target": last_daily.ts if last_daily else None}),
)
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
if factors:
bars = adjust_bars(bars, factors, mode, adjust)
mode = adjust
source = adjust
# --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 ---
bars = resample_bars(bars, timeframe)
# --- 指标(在预热窗口上计算后截尾,保证预热正确;翻页到底的空页跳过) ---
has_more = len(bars) > limit # 返回窗口之前还有更早历史(含预热行)
indicators: dict[str, dict[str, list[float | None]]] = {}
if bars:
df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]})
closes, highs, lows = df["close"], df["high"], df["low"]
macd = ind.macd(closes)
kdj = ind.kdj(highs, lows, closes)
boll = ind.bollinger(closes)
indicators = {
# MA 始终返回全量集合(前端本地计算 MA此处仅保留兼容缓存键不依赖 ma_periods
"ma": {f"ma{p}": series_to_jsonable(ind.ma(closes, p)) for p in FULL_MA_SET},
"macd": {
"dif": series_to_jsonable(macd["macd"]),
"dea": series_to_jsonable(macd["signal"]),
"hist": series_to_jsonable(macd["hist"]),
},
"kdj": {k: series_to_jsonable(kdj[k]) for k in ("k", "d", "j")},
"rsi": {
"rsi6": series_to_jsonable(ind.rsi(closes, 6)),
"rsi12": series_to_jsonable(ind.rsi(closes, 12)),
"rsi24": series_to_jsonable(ind.rsi(closes, 24)),
},
"boll": {k: series_to_jsonable(boll[k]) for k in ("upper", "mid", "lower")},
"zx": {
"short": series_to_jsonable(ind.ema2(closes)),
"duokong": series_to_jsonable(ind.avg_ma(closes, tuple(zx_periods))),
},
}
limit = max(30, min(limit, len(bars)))
for group in indicators.values():
for key in group:
group[key] = group[key][-limit:]
# --- 信息卡Wave 2 已并行取回stock_basic + 与行情同日对齐的快照、缺则最新日,见 INFO_SQL ---
row = info_row.first()
if row is not None:
m = row._mapping
sb_name, sb_industry, sb_area, sb_market, sb_list_date = (
m["name"], m["industry"], m["area"], m["market"], m["list_date"]
)
ds_turnover, ds_pe, ds_pb, ds_tmv, ds_cmv = (
m["turnover_rate"], m["pe_ttm"], m["pb"], m["total_mv"], m["circ_mv"]
)
else:
sb_name = sb_industry = sb_area = sb_market = sb_list_date = None
ds_turnover = ds_pe = ds_pb = ds_tmv = ds_cmv = None
def _yi(v) -> float | None:
if v is None:
return None
v = float(v)
return None if v != v else round(v / 1e4, 2) # 万元 -> 亿元
info = PreviewInfoOut(
ts_code=ts_code,
symbol=symbol,
name=sb_name or ts_code,
industry=sb_industry,
area=sb_area,
market=sb_market,
list_date=sb_list_date,
trade_date=last_daily.ts if last_daily else None,
open=last_daily.open if last_daily else None,
high=last_daily.high if last_daily else None,
low=last_daily.low if last_daily else None,
close=last_daily.close if last_daily else None,
pre_close=prev_daily.close if prev_daily else None,
pct_chg=((last_daily.close / prev_daily.close - 1) * 100)
if last_daily and prev_daily and prev_daily.close else None,
volume_hand=round(last_daily.volume / 100, 0) if last_daily else None, # 股 -> 手
amount_yi=round(last_daily.amount / 1e8, 2) if last_daily and last_daily.amount else None, # 元 -> 亿元
turnover_rate=ds_turnover,
pe_ttm=ds_pe,
pb=ds_pb,
total_mv=_yi(ds_tmv),
circ_mv=_yi(ds_cmv),
)
candles = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=b.turnover)
for b in bars[-limit:]
]
resp = PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info,
candles=candles, indicators=indicators, has_more=has_more)
# 只序列化一次本地同步120s+ Redis后台写600s TTL 兜底跨进程/重启)
raw = raw_json(resp)
cache.local_set(f"pvj:{cache_key}", raw, ttl=120)
cache.set_bg(f"pvj:{cache_key}", raw, ttl=600)
return Response(content=raw, media_type="application/json")

283
backend/app/api/stocks.py Normal file
View 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:
"""公司简介:库内有新鲜行直返;否则锁内单查 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)

291
backend/app/api/user.py Normal file
View File

@@ -0,0 +1,291 @@
"""用户数据路由:偏好 / 自选股 / 交割单(个人实盘买卖点)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from .. import cache
from ..auth import require_user
from ..db import get_session
from ..models import HoldingItem, StockBasic, UserPreference, UserTrade, WatchlistItem
from ..schemas import (
HoldingOp,
PreferencesOut,
PreferencesUpdate,
TradesClearResponse,
TradesImportResponse,
UserTradeOut,
WatchlistOp,
)
from ..trades import parse_statement
router = APIRouter()
# ---------- 用户偏好 ----------
@router.get("/preferences", response_model=PreferencesOut)
async def get_preferences(
session: AsyncSession = Depends(get_session), user=Depends(require_user)
) -> PreferencesOut:
prefs: dict[str, object] = {}
rows = (
await session.execute(select(UserPreference).where(UserPreference.user_id == user.id))
).scalars().all()
for r in rows:
try:
prefs[r.key] = json.loads(r.value_json)
except Exception: # noqa: BLE001
prefs[r.key] = None
return PreferencesOut(prefs=prefs)
@router.put("/preferences", response_model=PreferencesOut)
async def put_preferences(
req: PreferencesUpdate,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> PreferencesOut:
"""部分更新:只覆盖出现的 key值为 null 表示删除该 key。返回更新后的全量。"""
for key, value in req.prefs.items():
if not key or len(key) > 64:
continue
if value is None:
await session.execute(
text("DELETE FROM user_preferences WHERE user_id = :u AND key = :k"),
{"u": user.id, "k": key},
)
continue
existing = (
await session.execute(
select(UserPreference).where(
UserPreference.user_id == user.id, UserPreference.key == key
)
)
).scalars().first()
vj = json.dumps(value, ensure_ascii=False)
if existing:
existing.value_json = vj
else:
session.add(UserPreference(user_id=user.id, key=key, value_json=vj))
await session.commit()
return await get_preferences(session=session, user=user)
# ---------- 自选股 ----------
@router.get("/watchlist", response_model=list[str])
async def get_watchlist(
session: AsyncSession = Depends(get_session), user=Depends(require_user)
) -> list[str]:
"""当前用户自选股 ts_code 列表(加入时间倒序)。"""
rows = (
await session.execute(
select(WatchlistItem.ts_code)
.where(WatchlistItem.user_id == user.id)
.order_by(WatchlistItem.created_at.desc(), WatchlistItem.id.desc())
)
).scalars().all()
return list(rows)
@router.post("/watchlist", response_model=list[str])
async def add_watchlist(
req: WatchlistOp,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
exists = (
await session.execute(
select(WatchlistItem.id).where(
WatchlistItem.user_id == user.id, WatchlistItem.ts_code == req.ts_code
)
)
).scalar_one_or_none()
if exists is None:
session.add(WatchlistItem(user_id=user.id, ts_code=req.ts_code))
await session.commit()
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
return await get_watchlist(session=session, user=user)
@router.delete("/watchlist/{ts_code}", response_model=list[str])
async def remove_watchlist(
ts_code: str,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
await session.execute(
text("DELETE FROM watchlist_items WHERE user_id = :u AND ts_code = :c"),
{"u": user.id, "c": ts_code},
)
await session.commit()
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
return await get_watchlist(session=session, user=user)
# ---------- 持仓股 ----------
@router.get("/holdings", response_model=list[str])
async def get_holdings(
session: AsyncSession = Depends(get_session), user=Depends(require_user)
) -> list[str]:
"""当前用户持仓股 ts_code 列表(加入时间倒序)。"""
rows = (
await session.execute(
select(HoldingItem.ts_code)
.where(HoldingItem.user_id == user.id)
.order_by(HoldingItem.created_at.desc(), HoldingItem.id.desc())
)
).scalars().all()
return list(rows)
@router.post("/holdings", response_model=list[str])
async def add_holding(
req: HoldingOp,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
exists = (
await session.execute(
select(HoldingItem.id).where(
HoldingItem.user_id == user.id, HoldingItem.ts_code == req.ts_code
)
)
).scalar_one_or_none()
if exists is None:
session.add(HoldingItem(user_id=user.id, ts_code=req.ts_code))
await session.commit()
await cache.bump_version(f"holding:{user.id}") # 作废该用户的股票列表缓存
return await get_holdings(session=session, user=user)
@router.delete("/holdings/{ts_code}", response_model=list[str])
async def remove_holding(
ts_code: str,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
await session.execute(
text("DELETE FROM holding_items WHERE user_id = :u AND ts_code = :c"),
{"u": user.id, "c": ts_code},
)
await session.commit()
await cache.bump_version(f"holding:{user.id}") # 作废该用户的股票列表缓存
return await get_holdings(session=session, user=user)
# ---------- 交割单(个人实盘买卖点) ----------
def _trade_out(r: UserTrade) -> UserTradeOut:
return UserTradeOut(
id=r.id, ts_code=r.ts_code, name=r.name, trade_date=r.trade_date,
direction=r.direction, price=r.price, qty=r.qty, amount=r.amount, fee=r.fee,
)
@router.get("/trades", response_model=list[UserTradeOut])
async def list_trades(
ts_code: str | None = None,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[UserTradeOut]:
"""当前用户导入的实盘成交(可选 ts_code 过滤按日期升序K线买卖点数据源"""
q = (
select(UserTrade)
.where(UserTrade.user_id == user.id)
.order_by(UserTrade.trade_date, UserTrade.id)
)
if ts_code:
q = q.where(UserTrade.ts_code == ts_code)
rows = (await session.execute(q)).scalars().all()
return [_trade_out(r) for r in rows]
@router.post("/trades/import", response_model=TradesImportResponse)
async def import_trades(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> TradesImportResponse:
"""上传券商交割单CSV/Excel/HTML 表格均可,自动识别列名),解析出买卖成交入库。
同一笔成交(同日同股同向同价同量)重复上传会跳过,重复导出幂等。
"""
data = await file.read()
if not data:
raise HTTPException(status_code=422, detail="文件是空的")
if len(data) > 20 * 1024 * 1024:
raise HTTPException(status_code=413, detail="文件超过 20MB请分时间段导出")
parsed = parse_statement(data, file.filename or "")
# 无证券代码列的导出(招商式):按证券名称反查 stock_basic 补 ts_code同名多码或查不到则弃行
unnamed = {t.name for t in parsed.trades if not t.ts_code and t.name}
if unnamed:
name_map: dict[str, str] = {}
for ts_code, name in (await session.execute(
select(StockBasic.ts_code, StockBasic.name).where(StockBasic.name.in_(unnamed))
)).all():
name_map[name] = "" if name in name_map else ts_code
for t in parsed.trades:
if not t.ts_code and t.name:
tc = name_map.get(t.name, "")
if tc:
t.ts_code, t.code = tc, tc.split(".")[0]
else:
parsed.skipped_bad.append(f"{t.trade_date} {t.name} 名称无法唯一对应代码,未入库")
def _key(t) -> tuple:
return (t.trade_date, t.ts_code, t.direction, None if t.price is None else round(t.price, 4), round(t.qty, 4))
# Python 侧去重兜底(唯一约束对 NULL price 不生效)
existing = {
(r.trade_date, r.ts_code, r.direction, None if r.price is None else round(r.price, 4), round(r.qty, 4))
for r in (
await session.execute(
select(UserTrade.trade_date, UserTrade.ts_code, UserTrade.direction, UserTrade.price, UserTrade.qty)
.where(UserTrade.user_id == user.id, UserTrade.ts_code.in_({t.ts_code for t in parsed.trades}))
)
).all()
}
inserted: list[UserTrade] = []
seen: set[tuple] = set()
skipped_dup = 0
for t in parsed.trades:
if not t.ts_code:
continue # 名称反查失败的行,已在 bad 里说明
k = _key(t)
if k in existing or k in seen:
skipped_dup += 1
continue
seen.add(k)
inserted.append(UserTrade(
user_id=user.id, ts_code=t.ts_code, code=t.code, name=t.name or None,
trade_date=t.trade_date, direction=t.direction, price=t.price,
qty=t.qty, amount=t.amount, fee=t.fee,
raw_json=json.dumps(t.raw, ensure_ascii=False, default=str),
))
if inserted:
session.add_all(inserted)
await session.commit()
return TradesImportResponse(
inserted=len(inserted),
skipped_dup=skipped_dup,
skipped_other=parsed.skipped_other,
stocks=len({t.ts_code for t in parsed.trades}),
bad=parsed.skipped_bad[:5],
sample=[_trade_out(r) for r in inserted[:5]],
)
@router.delete("/trades", response_model=TradesClearResponse)
async def clear_trades(
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> TradesClearResponse:
"""清空当前用户导入的全部成交(重新导入前用)。"""
res = await session.execute(delete(UserTrade).where(UserTrade.user_id == user.id))
await session.commit()
return TradesClearResponse(deleted=res.rowcount or 0)