"""Chart API(v3 §20.2):统一可视化数据只读接口。 - GET /api/stocks/{symbol}/chart?start&end&adjust K 线 + 量 + 指标(显示层折算) - GET /api/stocks/{symbol}/signals 该股历史信号(markers) - GET /api/stocks/{symbol}/selections 该股历史选股命中(markers) - GET /api/backtests/{experiment_id}/stocks/{symbol}/chart 回测个股:K 线 + 实际成交 fills - GET /api/backtests/{experiment_id}/trades|positions 回测成交/持仓展开 """ from __future__ import annotations from datetime import date from typing import Annotated from fastapi import APIRouter, HTTPException, Query from app.api.deps import ( ChartServiceDep, ExperimentRepoDep, SelectionRepoDep, SignalRepoDep, ) from app.domain.entities.chart import ChartResult, EventMarker, SelectionHit from app.domain.entities.research import BacktestResult from app.domain.entities.signal import SignalHit router = APIRouter(tags=["charts"]) _AdjustQuery = Annotated[str, Query(pattern="^(none|qfq|hfq)$")] _StartQuery = Annotated[date | None, Query(description="开始日期(默认 2024-01-01)")] _EndQuery = Annotated[date | None, Query(description="结束日期(默认今天)")] def _signal_markers(hits: list[SignalHit]) -> list[EventMarker]: kind_map = {"BUY": "signal_buy", "SELL": "signal_sell", "WATCH": "signal_watch"} return [ EventMarker( time=h.signal_date, kind=kind_map.get(h.signal_type, "signal_watch"), symbol="", price=h.price, score=h.score, text=h.trigger_reason, ref_id=h.signal_id, ) for h in hits ] def _selection_markers(hits: list[SelectionHit]) -> list[EventMarker]: return [ EventMarker( time=h.as_of, kind="selection", symbol=h.symbol, score=h.score, text=[f"rank #{h.rank}"] + h.selection_reason, ref_id=h.selection_id, ) for h in hits ] @router.get("/stocks/{symbol}/chart", response_model=ChartResult, summary="个股 K 线图数据") def stock_chart( symbol: str, service: ChartServiceDep, start: _StartQuery = None, end: _EndQuery = None, adjust: _AdjustQuery = "none", ) -> ChartResult: start = start or date(2024, 1, 1) end = end or date.today() if service.stock(symbol) is None: raise HTTPException(status_code=404, detail=f"未找到股票 {symbol}") return service.stock_chart(symbol, start, end, adjust) @router.get("/stocks/{symbol}/signals", response_model=list[EventMarker], summary="该股历史信号") def symbol_signals(symbol: str, signal_repo: SignalRepoDep) -> list[EventMarker]: return _signal_markers(signal_repo.list_by_symbol(symbol)) @router.get("/stocks/{symbol}/selections", response_model=list[EventMarker], summary="该股历史选股命中") def symbol_selections(symbol: str, selection_repo: SelectionRepoDep) -> list[EventMarker]: return _selection_markers(selection_repo.list_by_symbol(symbol)) @router.get( "/backtests/{experiment_id}/stocks/{symbol}/chart", response_model=ChartResult, summary="回测个股图(K 线 + 实际成交 fills)", ) def backtest_stock_chart( experiment_id: str, symbol: str, service: ChartServiceDep, experiment_repo: ExperimentRepoDep, start: _StartQuery = None, end: _EndQuery = None, adjust: _AdjustQuery = "none", ) -> ChartResult: start = start or date(2024, 1, 1) end = end or date.today() exp = experiment_repo.get(experiment_id) if exp is None: raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在") try: result = BacktestResult.model_validate_json(exp.result_json) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=f"{experiment_id} 不是 backtest 结果") from exc return service.backtest_stock_chart(result, symbol, start, end, adjust) @router.get("/backtests/{experiment_id}/trades", summary="回测成交明细") def backtest_trades(experiment_id: str, experiment_repo: ExperimentRepoDep): exp = experiment_repo.get(experiment_id) if exp is None: raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在") result = BacktestResult.model_validate_json(exp.result_json) return result.trades @router.get("/backtests/{experiment_id}/positions", summary="回测持仓明细") def backtest_positions(experiment_id: str, experiment_repo: ExperimentRepoDep): exp = experiment_repo.get(experiment_id) if exp is None: raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在") result = BacktestResult.model_validate_json(exp.result_json) return result.positions