- domain/entities/chart.py:ChartResult/OHLC/Volume/Series/EventMarker/ChartMetadata
(adjust_mode + execution_price_basis 口径元数据)+ SelectionHit
- application/services/chart_service.py:个股 K线/量/MA 指标;显示层 qfq/hfq 折算
(基于主口径 none 行情 × adjust_factor,绝回写研究数据);回测个股视图把实际成交
转 fills 标记并在显示口径不同时做坐标换算(v3 §20.3/§20.5)
- by-symbol 历史查询:SignalRepository/SelectionRepository.list_by_symbol(含溯源 id)
- api/charts.py:/stocks/{symbol}/chart|signals|selections、/backtests/{id}/stocks/{symbol}/chart
|trades|positions
- tests/test_charts.py(指标/qfq-hfq 折算断言/回测 fills/API 集成+404);全量 pytest 通过
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""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
|