352 lines
14 KiB
Python
352 lines
14 KiB
Python
"""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 -> None(lightweight-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)
|