- ChartService qfq 基准改为该股最新因子(截至今天)归一:历史区间随最新除权 平移正确(v3 §20.5 Chart Display vs Execution basis 分离) - 显示口径与执行 basis 不一致时,成交/信号 marker 价格按当日因子换算到 K 线坐标系 (fill 早段价格在 qfq 下折算验证 100→50) - tests:qfq 回测 marker 折算 + selection 标记保留;全量 pytest 通过
222 lines
7.8 KiB
Python
222 lines
7.8 KiB
Python
"""Chart Service(v3 §20.1)—— 只聚合与坐标整理,不重算研究结果。
|
||
|
||
- K 线/量/指标:基于主口径(adjust=none)行情,按请求 adjust 在**显示层**折算 qfq/hfq
|
||
(绝不回写研究数据;研究执行仍用 price_adjustment 指定口径)
|
||
- 标记:selections/signals 来自各自落库历史(by-symbol);backtest fills 来自 Experiment
|
||
内 BacktestResult 的 trades/positions
|
||
- 口径纪律(v3 §20.5):显示价 basis 与回测执行价 basis 分别记录;不一致时对 marker 做
|
||
与 K 线相同的坐标换算,保证成交点贴图
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
from app.domain.entities.chart import (
|
||
OHLC,
|
||
ChartMetadata,
|
||
ChartResult,
|
||
EventMarker,
|
||
SeriesPoint,
|
||
VolumePoint,
|
||
)
|
||
from app.domain.entities.market import AdjustFactor, DailyBar, Stock
|
||
from app.domain.entities.research import BacktestResult
|
||
from app.domain.repositories.market import (
|
||
AdjustFactorRepository,
|
||
DailyBarRepository,
|
||
StockRepository,
|
||
)
|
||
|
||
_MA_WINDOWS = (20, 60)
|
||
|
||
|
||
def _main_bars(bars: list[DailyBar]) -> list[DailyBar]:
|
||
"""仅取主口径行(adjust=none),丢弃新浪 qfq 兜底行 —— 显示层一律自 none 折算。"""
|
||
return [b for b in bars if b.adjust == "none"]
|
||
|
||
|
||
def _factor_multipliers(
|
||
adj_repo: AdjustFactorRepository,
|
||
symbol: str,
|
||
start: date,
|
||
end: date,
|
||
mode: str,
|
||
) -> dict[date, float]:
|
||
"""返回 {trade_date: 显示折算系数};mode=none → 空。
|
||
|
||
基准(v3 §20.5):qfq 以**该股最新因子**(截至今天,而非图表区间末)归一,
|
||
保证历史区间随最新除权平移正确;hfq 直接用累积因子。
|
||
"""
|
||
if mode == "none":
|
||
return {}
|
||
factors: list[AdjustFactor] = adj_repo.get_range(symbol, date(1990, 1, 1), date.today())
|
||
if not factors:
|
||
return {}
|
||
by_day = {f.trade_date: float(f.factor) for f in factors}
|
||
latest = max(by_day.values())
|
||
out: dict[date, float] = {}
|
||
for day, f in by_day.items():
|
||
out[day] = f / latest if mode == "qfq" else f
|
||
return out
|
||
|
||
|
||
class ChartService:
|
||
def __init__(
|
||
self,
|
||
stock_repo: StockRepository,
|
||
daily_repo: DailyBarRepository,
|
||
adj_repo: AdjustFactorRepository,
|
||
) -> None:
|
||
self._stock_repo = stock_repo
|
||
self._daily_repo = daily_repo
|
||
self._adj_repo = adj_repo
|
||
|
||
def stock(self, symbol: str) -> Stock | None:
|
||
return self._stock_repo.get_by_symbol(symbol)
|
||
|
||
def stock_chart(
|
||
self,
|
||
symbol: str,
|
||
start: date,
|
||
end: date,
|
||
adjust: str = "none",
|
||
execution_price_basis: str | None = None,
|
||
extra_markers: list[EventMarker] | None = None,
|
||
) -> ChartResult:
|
||
"""基础个股 K 线图(可叠加 fills 等外部标记)。"""
|
||
stock = self.stock(symbol)
|
||
name = stock.name if stock else ""
|
||
raw = _main_bars(self._daily_repo.get_range(symbol, start, end))
|
||
mult = _factor_multipliers(self._adj_repo, symbol, start, end, adjust)
|
||
bars: list[OHLC] = []
|
||
volume: list[VolumePoint] = []
|
||
for b in raw:
|
||
m = mult.get(b.trade_date, 1.0)
|
||
bars.append(
|
||
OHLC(
|
||
time=b.trade_date,
|
||
open=_v(b.open, m),
|
||
high=_v(b.high, m),
|
||
low=_v(b.low, m),
|
||
close=_v(b.close, m),
|
||
)
|
||
)
|
||
volume.append(VolumePoint(time=b.trade_date, value=_v(b.volume, 1.0)))
|
||
|
||
markers = _convert_markers(extra_markers or [], mult)
|
||
indicators = _ma_indicators(bars)
|
||
return ChartResult(
|
||
metadata=ChartMetadata(
|
||
symbol=symbol,
|
||
name=name,
|
||
adjust_mode=adjust,
|
||
execution_price_basis=execution_price_basis,
|
||
start=start,
|
||
end=end,
|
||
bar_count=len(bars),
|
||
indicator_windows=list(_MA_WINDOWS),
|
||
),
|
||
bars=bars,
|
||
volume=volume,
|
||
indicators=indicators,
|
||
fills=[m for m in markers if m.kind.startswith("fill_")],
|
||
signals=[m for m in markers if m.kind.startswith("signal_")],
|
||
selections=[m for m in markers if m.kind == "selection"],
|
||
holding_periods=_holding_periods(bars),
|
||
)
|
||
|
||
# ---- 由已存历史构造标记(不重算) ----
|
||
|
||
def backtest_stock_chart(
|
||
self,
|
||
result: BacktestResult,
|
||
symbol: str,
|
||
start: date,
|
||
end: date,
|
||
adjust: str = "none",
|
||
) -> ChartResult:
|
||
"""回测个股视图:K 线 + 选股意图/未成交信号/实际成交三类标记(v3 §20.3)。"""
|
||
basis = (result.config_snapshot or {}).get("price_adjustment", "none")
|
||
markers = _result_to_markers(result, symbol)
|
||
return self.stock_chart(symbol, start, end, adjust, execution_price_basis=basis,
|
||
extra_markers=markers)
|
||
|
||
|
||
def _result_to_markers(result: BacktestResult, symbol: str) -> list[EventMarker]:
|
||
"""由回测 history 生成个股标记:fills(成交)/ signals(未成交意图)/ selections(选股)。"""
|
||
markers: list[EventMarker] = []
|
||
# 实际成交(fills)与未成交信号(signal_history 中 filled=False)
|
||
for a in result.signal_history:
|
||
if a.symbol != symbol:
|
||
continue
|
||
if a.filled:
|
||
kind = "fill_buy" if a.signal == "BUY" else "fill_sell"
|
||
text = [f"{'买入' if a.signal=='BUY' else '卖出'} @ {a.price:.2f}(basis={_basis_of(result)})"]
|
||
markers.append(
|
||
EventMarker(time=a.date, kind=kind, symbol=symbol, price=a.price, text=text)
|
||
)
|
||
else:
|
||
kind = "signal_buy" if a.signal == "BUY" else "signal_sell"
|
||
text = [a.reject_reason or f"{a.signal} 未成交"]
|
||
markers.append(EventMarker(time=a.date, kind=kind, symbol=symbol, price=a.price, text=text))
|
||
# 选股意图(selection_history 中该 symbol 的命中)
|
||
for pk in result.selection_history:
|
||
if pk.symbol != symbol:
|
||
continue
|
||
markers.append(
|
||
EventMarker(
|
||
time=pk.date,
|
||
kind="selection",
|
||
symbol=symbol,
|
||
score=pk.score,
|
||
text=[f"选股意图 rank #{pk.rank}"],
|
||
)
|
||
)
|
||
return markers
|
||
|
||
|
||
def _basis_of(result: BacktestResult) -> str:
|
||
return (result.config_snapshot or {}).get("price_adjustment", "none")
|
||
|
||
|
||
def _convert_markers(markers: list[EventMarker], mult: dict[date, float]) -> list[EventMarker]:
|
||
"""显示口径与执行价 basis 不一致时,把 marker 价格折算到 K 线坐标系(v3 §20.5)。"""
|
||
out: list[EventMarker] = []
|
||
for m in markers:
|
||
if m.price is not None and mult:
|
||
k = mult.get(m.time)
|
||
if k is not None:
|
||
m = m.model_copy(update={"price": round(m.price * k, 4)})
|
||
out.append(m)
|
||
return out
|
||
|
||
|
||
def _holding_periods(bars: list[OHLC]) -> list[dict]:
|
||
"""v1 空实现占位(持仓区间渲染 v3 §20.4 后续细化)。"""
|
||
return []
|
||
|
||
|
||
def _ma_indicators(bars: list[OHLC]) -> dict[str, list[SeriesPoint]]:
|
||
import statistics
|
||
|
||
closes = [b.close for b in bars]
|
||
out: dict[str, list[SeriesPoint]] = {}
|
||
for w in _MA_WINDOWS:
|
||
series: list[SeriesPoint] = []
|
||
for i, b in enumerate(bars):
|
||
if i + 1 < w:
|
||
continue
|
||
window = closes[i + 1 - w : i + 1]
|
||
if all(v is not None for v in window):
|
||
series.append(SeriesPoint(time=b.time, value=round(statistics.fmean(window), 4)))
|
||
out[f"ma{w}"] = series
|
||
return out
|
||
|
||
|
||
def _v(v, m: float) -> float | None:
|
||
if v is None:
|
||
return None
|
||
f = float(v)
|
||
return round(f * m, 4)
|