From 5bde8f9f5f4d2869a0d2ca4295b1b0bde89d4973 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 9 Sep 2026 07:15:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(chart):=20M9-5=20=E5=A4=8D=E6=9D=83?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=9D=90=E6=A0=87=E6=8D=A2=E7=AE=97=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=EF=BC=88qfq=20=E5=9F=BA=E5=87=86=3D=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E5=9B=A0=E5=AD=90=20+=20marker=20=E8=B4=B4=E5=9B=BE=E6=8D=A2?= =?UTF-8?q?=E7=AE=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChartService qfq 基准改为该股最新因子(截至今天)归一:历史区间随最新除权 平移正确(v3 §20.5 Chart Display vs Execution basis 分离) - 显示口径与执行 basis 不一致时,成交/信号 marker 价格按当日因子换算到 K 线坐标系 (fill 早段价格在 qfq 下折算验证 100→50) - tests:qfq 回测 marker 折算 + selection 标记保留;全量 pytest 通过 --- .../app/application/services/chart_service.py | 8 ++- backend/tests/test_charts.py | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/backend/app/application/services/chart_service.py b/backend/app/application/services/chart_service.py index 1088fb1..631b335 100644 --- a/backend/app/application/services/chart_service.py +++ b/backend/app/application/services/chart_service.py @@ -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} diff --git a/backend/tests/test_charts.py b/backend/tests/test_charts.py index db293fc..596da0f 100644 --- a/backend/tests/test_charts.py +++ b/backend/tests/test_charts.py @@ -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)