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)