Files
Simon 995ed08548 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 通过
2026-09-09 07:09:52 +08:00

131 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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