"""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)