fix(backtest): 用户报告两处修复 — close_t 收盘封涨停拦截 + 移动止盈回撤不再静默钳制

报告①(严重, 收益虚高): _can_buy 只拦「一字板」(收盘涨停且四价合一),
entry_fill=close_t 时尾盘封板股(盘中正常交易、收盘=涨停价)以涨停收盘价
"成交" — 现实中收盘封板的买队排队排不进, 动量类策略信号集中在涨停日,
回测收益系统性虚高(报告实测 55.7% 买单落在涨停收盘日, close_t +586% 对比
open_t+1 -71%)。修复: close_t 口径下凡收盘涨停(limit_up_locked, 含一字)
一律拦截, 拦截原因沿用 buy_limit_up; open_t+1 按次日开盘成交保持只拦次日
一字板。engine.py 四处同构 _can_buy(_simulate_independent_matrix /
simulate_independent_candidates_legacy / _simulate_portfolio_matrix /
simulate_portfolio_legacy)同步修改。旧 simple 路径 simulate() 无任何涨停
守卫但生产代码无调用方, 未动。

报告②(严重, 配置不生效): strategy.py 移动止盈的 min(drawdown, activate)
把用户填的回撤值静默改写成激活值, 无任何提示。改为显式校验报错
「移动止盈回撤(x%)不能大于激活涨幅(y%)」, 与同函数内其他参数组合错误的
 _err 风格一致。

测试: test_limit_up_close_fill.py 四场景(封板拦截/正常日成交/open_t+1
信号日封板不影响次日开盘买入/次日一字仍拦截) + test_trailing_drawdown_
validation.py 两场景(超限报错/未超限放行)。本地与 TZ=UTC 双跑全量
2476 passed 6 skipped
This commit is contained in:
shy3130 committed 2026-10-04 18:01:52 +08:00
1 parent eefd9521bb
commit a64f573ef2
4 files changed
+174 -2

No files matched your search

+20
View File
@@ -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, ""
+11 -2
View File
@@ -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"),
@@ -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 == []
@@ -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