diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 5ec07b2..457cf4d 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -944,6 +944,11 @@ class BacktestEngine: return False, "buy_suspended" if not _valid_price(entry_prices[time_id, asset_id]): return False, "buy_invalid_price" + if config.entry_fill == "close_t" and bool(matrix.limit_up_locked[time_id, asset_id]): + # close_t 按信号日收盘价成交: 收盘封板(含一字)在收盘价的买队排队 + # 现实中排不进, 一律拦截 — 旧实现只拦一字板, 尾盘封板股按涨停价 + # "成交"导致回测收益虚高。open_t+1 按次日开盘成交, 不受此限。 + return False, "buy_limit_up" if _one_price_limit(time_id, asset_id, "up"): return False, "buy_limit_up" return True, "" @@ -1362,6 +1367,11 @@ class BacktestEngine: return False, "buy_suspended" if not _valid_price(entry_prices[idx]): return False, "buy_invalid_price" + if config.entry_fill == "close_t" and bool(limit_up_flags[idx]): + # close_t 按信号日收盘价成交: 收盘封板(含一字)在收盘价的买队排队 + # 现实中排不进, 一律拦截 — 旧实现只拦一字板, 尾盘封板股按涨停价 + # "成交"导致回测收益虚高。open_t+1 按次日开盘成交, 不受此限。 + return False, "buy_limit_up" if _is_one_price_limit(idx, "up"): return False, "buy_limit_up" return True, "" @@ -1869,6 +1879,11 @@ class BacktestEngine: return False, "buy_suspended" if not _valid_price(entry_prices[time_id, asset_id]): return False, "buy_invalid_price" + if config.entry_fill == "close_t" and bool(matrix.limit_up_locked[time_id, asset_id]): + # close_t 按信号日收盘价成交: 收盘封板(含一字)在收盘价的买队排队 + # 现实中排不进, 一律拦截 — 旧实现只拦一字板, 尾盘封板股按涨停价 + # "成交"导致回测收益虚高。open_t+1 按次日开盘成交, 不受此限。 + return False, "buy_limit_up" if _one_price_limit(time_id, asset_id, "up"): return False, "buy_limit_up" return True, "" @@ -2424,6 +2439,11 @@ class BacktestEngine: return False, "buy_suspended" if not _valid_price(entry_prices[idx]): return False, "buy_invalid_price" + if config.entry_fill == "close_t" and bool(limit_up_flags[idx]): + # close_t 按信号日收盘价成交: 收盘封板(含一字)在收盘价的买队排队 + # 现实中排不进, 一律拦截 — 旧实现只拦一字板, 尾盘封板股按涨停价 + # "成交"导致回测收益虚高。open_t+1 按次日开盘成交, 不受此限。 + return False, "buy_limit_up" if _is_one_price_limit(idx, "up"): return False, "buy_limit_up" return True, "" diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 69cea72..3eb76b1 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -1127,8 +1127,17 @@ class StrategyBacktestService: 0.005, 0.5, ) - if trailing_take_profit_activate is not None and trailing_take_profit_drawdown is not None: - trailing_take_profit_drawdown = min(trailing_take_profit_drawdown, trailing_take_profit_activate) + if ( + trailing_take_profit_activate is not None + and trailing_take_profit_drawdown is not None + and trailing_take_profit_drawdown > trailing_take_profit_activate + ): + # 不再静默钳制: 旧 min() 会把用户填的回撤改写成激活值, 配置"不生效" + # 却无任何提示。组合无意义时显式报错, 让用户调整参数后重跑。 + return _err( + f"移动止盈回撤({trailing_take_profit_drawdown:.0%})不能大于" + f"激活涨幅({trailing_take_profit_activate:.0%}), 请调整后再试" + ) max_hold_days = self._override_value(overrides, "max_hold_days", s.max_hold_days) score_min, score_max = self._normalize_score_range( overrides.get("score_min"), diff --git a/backend/tests/backtest/test_limit_up_close_fill.py b/backend/tests/backtest/test_limit_up_close_fill.py new file mode 100644 index 0000000..b91ffd8 --- /dev/null +++ b/backend/tests/backtest/test_limit_up_close_fill.py @@ -0,0 +1,90 @@ +"""回归: entry_fill=close_t 时收盘封涨停(非一字)的信号日不得按收盘价成交。 + +用户 bug 报告①: _can_buy 只拦「一字板」(四价合一), 尾盘封板(盘中正常交易、 +收盘=涨停价)的股票在 close_t 口径下以涨停收盘价"成交" — 现实中收盘封板的 +买队排队排不进, 回测收益系统性虚高(报告实测 55.7% 买单落在涨停收盘日)。 + +修复: close_t 口径下凡收盘涨停(limit_up_locked, 含一字)一律拦截; +open_t+1 口径按次日开盘成交, 保持只拦次日一字板的既有行为。 +""" +from __future__ import annotations + +from datetime import date, timedelta + +import polars as pl + +from app.backtest.engine import BacktestEngine, MatcherConfig + + +def _panel(limit_day_idx: int | None, one_price: bool) -> pl.DataFrame: + """单标的 10 日面板; limit_day_idx 当日收盘涨停(one_price=True 为一字)。""" + start = date(2024, 1, 1) + rows = [] + for i in range(10): + px = 10.0 + i * 0.05 + if i == limit_day_idx: + prev_close = 10.0 + (i - 1) * 0.05 + limit = round(prev_close * 1.1, 2) + if one_price: + o = h = l = c = limit + else: + o, l = px, px - 0.1 + h = c = limit + rows.append({ + "symbol": "A", "date": start + timedelta(days=i), + "open": o, "high": h, "low": l, "close": c, + "volume": 100_000, + "signal_limit_up": True, "signal_limit_down": False, + }) + else: + rows.append({ + "symbol": "A", "date": start + timedelta(days=i), + "open": px, "high": px + 0.02, "low": px - 0.02, "close": px, + "volume": 100_000, + "signal_limit_up": False, "signal_limit_down": False, + }) + return pl.DataFrame(rows).sort(["symbol", "date"]) + + +def _run(panel: pl.DataFrame, signal_day: date, matching: str): + entry_mask = pl.Series( + [r["date"] == signal_day for r in panel.select(["symbol", "date"]).iter_rows(named=True)], + dtype=pl.Boolean, + ) + exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean) + return BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type] + panel, entry_mask, exit_mask, + MatcherConfig(matching=matching, fees_pct=0, slippage_bps=0, max_hold_days=1), + ) + + +def test_close_t_blocks_sealed_limit_up_close(): + """非一字、收盘封涨停 → close_t 不得成交(修复点)。""" + result = _run(_panel(limit_day_idx=5, one_price=False), date(2024, 1, 6), "close_t") + assert result.stats.get("n_trades") == 0 + assert result.trades == [] + + +def test_close_t_fills_normal_close(): + """正常信号日(无涨停) → close_t 照常以当日收盘价成交。""" + result = _run(_panel(limit_day_idx=None, one_price=False), date(2024, 1, 6), "close_t") + assert result.stats.get("n_trades") == 1 + trade = result.trades[0] + assert trade.entry_date == "2024-01-06" + assert trade.entry_price == 10.25 # 当日收盘 + + +def test_open_t1_allows_entry_after_limit_up_close_signal(): + """信号日收盘封板不阻碍次日开盘买入(open_t+1 不受新拦截影响)。""" + result = _run(_panel(limit_day_idx=5, one_price=False), date(2024, 1, 6), "open_t+1") + assert result.stats.get("n_trades") == 1 + trade = result.trades[0] + assert trade.entry_date == "2024-01-07" + assert trade.entry_price == 10.30 # 次日开盘 + + +def test_open_t1_still_blocks_one_price_limit_fill_day(): + """次日一字涨停 → open_t+1 既有拦截保持不变。""" + result = _run(_panel(limit_day_idx=6, one_price=True), date(2024, 1, 6), "open_t+1") + assert result.stats.get("n_trades") == 0 + assert result.trades == [] diff --git a/backend/tests/backtest/test_trailing_drawdown_validation.py b/backend/tests/backtest/test_trailing_drawdown_validation.py new file mode 100644 index 0000000..9b2f49b --- /dev/null +++ b/backend/tests/backtest/test_trailing_drawdown_validation.py @@ -0,0 +1,53 @@ +"""回归: 移动止盈「回撤 > 激活涨幅」时显式报错, 不再静默钳制。 + +用户 bug 报告②: 旧实现用 min(drawdown, activate) 把用户填的回撤值静默改写为 +激活值, 配置"不生效"且无任何提示。修复后该组合直接报错, 用户需调整参数。 +""" +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from app.backtest.engine import BacktestEngine +from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService +from app.strategy.engine import StrategyEngine + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _service() -> StrategyBacktestService: + return StrategyBacktestService( + BacktestEngine(repo=None), + StrategyEngine(strategy_dirs=[REPO_ROOT / "backend" / "app" / "strategy" / "builtin"]), + ) + + +def _config(activate, drawdown) -> StrategyBacktestConfig: + return StrategyBacktestConfig( + strategy_id="macd_golden", + symbols=None, + start=date(2024, 1, 1), + end=date(2024, 3, 1), + overrides={ + "trailing_take_profit_activate": activate, + "trailing_take_profit_drawdown": drawdown, + }, + ) + + +def test_drawdown_greater_than_activate_errors(): + """回撤(10%) > 激活(5%) → 报错并点名两参数, 而非静默按 5% 跑。""" + result = _service().run(_config(0.05, 0.10)) + assert result.error + assert "移动止盈回撤" in result.error + assert "激活涨幅" in result.error + + +def test_drawdown_not_greater_than_activate_passes_validation(): + """回撤 ≤ 激活 → 校验放行(后续数据阶段是否失败与本回归点无关)。""" + outcome = "" + try: + outcome = _service().run(_config(0.10, 0.05)).error or "" + except Exception as exc: # noqa: BLE001 — repo=None, 数据阶段异常在所难免 + outcome = str(exc) + assert "移动止盈回撤" not in outcome