Files
stock/backend/app/api.py
2026-08-14 17:34:26 +08:00

352 lines
14 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.
"""HTTP 路由OpenAPI 契约的载体)。
GET /api/health 健康检查
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
POST /api/screener/sync 启动全市场数据同步(后台任务)
GET /api/screener/sync/status 同步任务状态与数据实况
"""
from __future__ import annotations
import json
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .backtest.engine import BacktestConfig, run_backtest
from .backtest.strategies import build_strategy
from .config import settings
from .data import fetcher, repository
from .data.aggregation import bars_per_year, resample_bars
from .data.symbols import plain_code
from .data.synthetic import seed_if_empty
from .db import get_session
from .domain import Bar
from . import indicators as ind
from .models import BacktestRun, DailySnapshot, MarketDaily, StockBasic
from .schemas import (
BacktestRequest,
BacktestResponse,
CandleOut,
EquityPoint,
IndicatorOut,
MetricsOut,
PreviewInfoOut,
PreviewResponse,
ScreenerRunRequest,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
SignalOut,
SyncRequest,
SyncResponse,
)
from .screener import engine, market_sync
from .screener.engine import DataNotReadyError
from .screener.llm import ScreenerError, parse_conditions
router = APIRouter(prefix="/api")
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) for r in rows]
@router.get("/health")
async def health() -> dict:
return {"status": "ok"}
@router.get("/candles/{symbol}", response_model=list[CandleOut])
async def get_candles(
symbol: str,
timeframe: str = "1d",
limit: int = 5000,
session: AsyncSession = Depends(get_session),
) -> list[CandleOut]:
await seed_if_empty(session, symbol="DEMO")
# 始终以日线为基底,再聚合到目标周期
rows = await repository.get_candles(session, symbol, "1d", limit=limit)
bars = resample_bars(_rows_to_bars(rows), timeframe)
return [CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) for b in bars]
@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))
@router.post("/backtest", response_model=BacktestResponse)
async def backtest(
req: BacktestRequest,
session: AsyncSession = Depends(get_session),
) -> BacktestResponse:
await seed_if_empty(session, symbol="DEMO")
# 非演示标的:首次自动拉取真实数据并缓存
if req.symbol != "DEMO" and 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),
)
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"])
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("/screener/run", response_model=ScreenerRunResponse)
async def screener_run(
req: ScreenerRunRequest, session: AsyncSession = Depends(get_session)
) -> ScreenerRunResponse:
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM微调再跑"""
try:
conds = req.conditions or await parse_conditions(req.text)
if not conds.indicator and not conds.snapshot:
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
result = await engine.run_screen(session, conds, settings.screener_default_limit)
return ScreenerRunResponse(**result)
except HTTPException:
raise
except DataNotReadyError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e: # 未知指标/字段、条件为空
raise HTTPException(status_code=400, detail=str(e))
except ScreenerError as e:
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
raise HTTPException(status_code=code, detail=str(e))
@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 = 260, session: AsyncSession = Depends(get_session)
) -> PreviewResponse:
"""个股详情预览日线qfq 全量缓存,未缓存/过期自动拉取,失败退 market_daily 近段)
+ 全套指标indicators.py 单一事实源)+ 最新截面信息卡。"""
symbol = plain_code(ts_code)
# 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源
md = (
await session.execute(
select(MarketDaily).where(MarketDaily.ts_code == ts_code).order_by(MarketDaily.trade_date.desc()).limit(1)
)
).scalars().first()
# --- 日线candles(qfq 全量) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
source = "qfq"
try:
if not rows:
await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date():
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback()
if not rows:
rows = []
bars = _rows_to_bars(rows)
if not bars:
source = "market"
res = await session.execute(
select(MarketDaily).where(MarketDaily.ts_code == ts_code).order_by(MarketDaily.trade_date)
)
bars = [
Bar(ts=r.trade_date, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.vol * 100.0)
for r in res.scalars()
]
if not bars:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# --- 指标(在全量历史上计算后截尾,保证预热正确) ---
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: dict[str, dict[str, list[float | None]]] = {
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in (5, 10, 20, 60)},
"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")},
}
limit = max(30, min(limit, len(bars)))
for group in indicators.values():
for key in group:
group[key] = group[key][-limit:]
# --- 信息卡stock_basic + 最新 market_daily + 与其对齐的快照(避免混用不同交易日) ---
sb = (await session.execute(select(StockBasic).where(StockBasic.ts_code == ts_code))).scalars().first()
ds = None
if md is not None:
# 优先取与行情同日的快照;缺当日快照时退最新(字段可能与行情差日期,罕见)
ds = (
await session.execute(
select(DailySnapshot).where(
DailySnapshot.ts_code == ts_code, DailySnapshot.trade_date == md.trade_date
)
)
).scalars().first()
if ds is None:
ds = (
await session.execute(
select(DailySnapshot).where(DailySnapshot.ts_code == ts_code).order_by(DailySnapshot.trade_date.desc()).limit(1)
)
).scalars().first()
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 if sb else ts_code,
industry=sb.industry if sb else None,
area=sb.area if sb else None,
market=sb.market if sb else None,
list_date=sb.list_date if sb else None,
trade_date=md.trade_date if md else None,
open=md.open if md else None,
high=md.high if md else None,
low=md.low if md else None,
close=md.close if md else None,
pre_close=md.pre_close if md else None,
pct_chg=md.pct_chg if md else None,
volume_hand=round(md.vol, 0) if md else None,
amount_yi=round(md.amount / 100000, 2) if md else None, # 千元 -> 亿元
turnover_rate=ds.turnover_rate if ds else None,
pe_ttm=ds.pe_ttm if ds else None,
pb=ds.pb if ds else None,
total_mv=_yi(ds.total_mv) if ds else None,
circ_mv=_yi(ds.circ_mv) if ds else None,
)
candles = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume)
for b in bars[-limit:]
]
return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators)