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:
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user