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:
1 parent
eefd9521bb
commit
a64f573ef2
4 files changed
+174
-2
No files matched your search
@@ -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, ""
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user