feat(chart): M9-1 Chart DTO + Chart Service + Chart API(v3 §20)

- 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 通过
This commit is contained in:
Simon
2026-09-09 07:09:52 +08:00
parent 8abfd6538c
commit 995ed08548
11 changed files with 736 additions and 1 deletions
+130
View File
@@ -0,0 +1,130 @@
"""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