feat(chart): M9-5 复权口径坐标换算落地(qfq 基准=最新因子 + marker 贴图换算)

- ChartService qfq 基准改为该股最新因子(截至今天)归一:历史区间随最新除权
  平移正确(v3 §20.5 Chart Display vs Execution basis 分离)
- 显示口径与执行 basis 不一致时,成交/信号 marker 价格按当日因子换算到 K 线坐标系
  (fill 早段价格在 qfq 下折算验证 100→50)
- tests:qfq 回测 marker 折算 + selection 标记保留;全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 07:15:41 +08:00
parent e1a0a8aa38
commit 5bde8f9f5f
2 changed files with 72 additions and 2 deletions
@@ -43,10 +43,14 @@ def _factor_multipliers(
end: date,
mode: str,
) -> dict[date, float]:
"""返回 {trade_date: 显示折算系数};mode=none → 空。qfq: f/f_latest;hfq: f。"""
"""返回 {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), end)
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}
+66
View File
@@ -231,3 +231,69 @@ class TestChartApi:
positions = client.get("/api/backtests/EXP-CHART-1/positions").json()
assert isinstance(trades, list) and isinstance(positions, list)
assert client.get("/api/backtests/EXP-NOPE/stocks/x/chart").status_code == 404
class TestAdjustCoordinate:
def test_qfq_backtest_marker_converted(self, seeded, tmp_path) -> None:
"""显示前复权时,早期成交 marker 价格按因子折算贴图(v3 §20.5 坐标)。"""
from app.domain.entities.research import (
ActionRecord,
BacktestResult,
BacktestSummary,
CurvePoint,
RankedPick,
Trade,
)
engine, Session, df = _seeded_with_factors(tmp_path)
dates = sorted(df["trade_date"].unique())
split = dates[len(dates) // 2]
with Session() as session:
SqlAlchemyAdjustFactorRepository(session).upsert_many(
[
AdjustFactor(symbol=_SYMS[0], trade_date=d,
factor=Decimal("1.0") if d < split else Decimal("2.0"))
for d in dates
]
)
session.commit()
svc = ChartService(
SqlAlchemyStockRepository(session),
SqlAlchemyDailyBarRepository(session),
SqlAlchemyAdjustFactorRepository(session),
)
# 早期(factor=1.0 段)一笔买入成交价 100
result = BacktestResult(
summary=BacktestSummary(
start=dates[0], end=dates[-1], initial_capital=1e6, final_equity=1e6,
total_return_pct=0, annual_return_pct=0, sharpe=0, max_drawdown_pct=0,
volatility_pct=0, win_rate_pct=0, total_trades=1, avg_turnover_pct=0,
),
equity_curve=[CurvePoint(date=dates[0], value=1e6)],
drawdown=[], monthly_returns=[], yearly_returns=[],
positions=[], trades=[
Trade(entry_date=dates[5], exit_date=dates[-1], symbol=_SYMS[0],
entry_price=100.0, exit_price=200.0, return_pct=100),
],
selection_history=[
RankedPick(date=dates[5], symbol=_SYMS[0], rank=1, score=1.0)
],
signal_history=[
ActionRecord(date=dates[5], symbol=_SYMS[0], signal="BUY", filled=True, price=100.0),
],
fills=[
ActionRecord(date=dates[5], symbol=_SYMS[0], signal="BUY", filled=True, price=100.0),
],
turnover_pct=0,
config_snapshot={"price_adjustment": "none"},
)
chart_none = svc.backtest_stock_chart(result, _SYMS[0], dates[0], dates[-1], "none")
chart_qfq = svc.backtest_stock_chart(result, _SYMS[0], dates[0], dates[-1], "qfq")
# none:fill 价 100;qfq 显示:早期因子 1.0 / 最新 2.0 → 折算 50
none_fill = chart_none.fills[0]
qfq_fill = chart_qfq.fills[0]
assert none_fill.price == 100.0
assert qfq_fill.price is not None and abs(qfq_fill.price - 50.0) < 1e-3
# 选股意图标记保留
assert any(m.kind == "selection" for m in chart_qfq.selections)