From 3e79f94da84a10e167d556e0e5c0299a8caaafe1 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Tue, 23 Jun 2026 14:00:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20=E6=8B=86=E5=88=86=E5=BB=BA?= =?UTF-8?q?=E4=BB=93/=E6=B8=85=E4=BB=93=E6=88=90=E4=BA=A4=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=B9=B6=E4=BF=AE=E5=A4=8D=E9=80=80=E5=87=BA=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成交口径: 单个 matching 拆成 entry_fill/exit_fill, 各自可选 close_t/open_t+1, 默认建仓次日开盘、清仓当日收盘 (最贴近真实交易)。老 matching 参数经 __post_init__ 自动映射为 entry=exit=matching, 完全向后兼容。 退出优先级修复: max_hold 不再抢占卖点信号 (signal 提到 max_hold 之前); 统一为 风控(止损/移动止损/止盈) → signal → max_hold → end, 删除 close/open 模式下风控调用顺序的差异, open_t+1 下到期仓位也能正确触发止损。 新增 3 个测试: signal 优先于 max_hold / open 模式到期触发止损 / 默认口径 买次日开盘卖收盘。现有 13 个测试经兼容映射后行为不变, 全部通过。 --- backend/app/api/backtest.py | 19 +- backend/app/backtest/engine.py | 200 ++++++++++-------- backend/app/backtest/strategy.py | 13 ++ .../tests/backtest/test_engine_portfolio.py | 110 ++++++++++ frontend/src/lib/api.ts | 2 + frontend/src/lib/backtestTask.ts | 4 + frontend/src/lib/storage.ts | 2 + .../src/pages/backtest/StrategyBacktest.tsx | 35 ++- 8 files changed, 284 insertions(+), 101 deletions(-) diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 216a360..0fe0736 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -186,7 +186,10 @@ class StrategyBacktestRequest(BaseModel): end: date | None = None params: dict | None = None overrides: dict | None = None + # matching 向后兼容; 显式传 entry_fill/exit_fill 时以二者为准。 matching: Literal["close_t", "open_t+1"] = "open_t+1" + entry_fill: Literal["close_t", "open_t+1"] | None = None + exit_fill: Literal["close_t", "open_t+1"] | None = None fees_pct: float = 0.0002 slippage_bps: float = 5.0 max_positions: int = 10 @@ -218,6 +221,8 @@ def strategy_run(req: StrategyBacktestRequest, request: Request): params=req.params, overrides=req.overrides, matching=req.matching, + entry_fill=req.entry_fill, + exit_fill=req.exit_fill, fees_pct=req.fees_pct, slippage_bps=req.slippage_bps, max_positions=req.max_positions, @@ -267,12 +272,13 @@ def _cleanup_stale_jobs(): def _make_job_key( strategy_id: str, symbols: str | None, start: str | None, end: str | None, - matching: str, fees_pct: float, slippage_bps: float, + matching: str, entry_fill: str | None, exit_fill: str | None, + fees_pct: float, slippage_bps: float, max_positions: int, max_exposure_pct: float, initial_capital: float, position_sizing: str, params: str | None, overrides: str | None, mode: str = "position", holding_days: int = 5, ) -> str: - raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}" + raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}" return hashlib.md5(raw.encode()).hexdigest()[:12] @@ -284,6 +290,8 @@ async def strategy_stream( start: str | None = None, end: str | None = None, matching: str = "open_t+1", + entry_fill: str | None = None, + exit_fill: str | None = None, fees_pct: float = 0.0002, slippage_bps: float = 5.0, max_positions: int = 10, @@ -329,7 +337,8 @@ async def strategy_stream( job_key = _make_job_key( strategy_id, symbols, start, end, - matching, fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing, + matching, entry_fill, exit_fill, + fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing, params, overrides, mode, holding_days, ) @@ -362,6 +371,8 @@ async def strategy_stream( params=json.loads(params) if params else None, overrides=json.loads(overrides) if overrides else None, matching=matching, + entry_fill=entry_fill, + exit_fill=exit_fill, fees_pct=fees_pct, slippage_bps=slippage_bps, max_positions=int(max_positions), @@ -442,6 +453,8 @@ async def strategy_cancel(request: Request): _get("start") or None, _get("end") or None, _get("matching", "open_t+1"), + _get("entry_fill") or None, + _get("exit_fill") or None, float(_get("fees_pct", "0.0002")), float(_get("slippage_bps", "5")), int(_get("max_positions", "10")), diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index ad11712..8d43975 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -29,7 +29,11 @@ logger = logging.getLogger(__name__) @dataclass class MatcherConfig: + # matching 为向后兼容入口: 仅传 matching 时, entry_fill/exit_fill 都取 matching 的值。 + # 显式传入 entry_fill/exit_fill 时以二者为准 (允许建仓/清仓口径不同)。 matching: Literal["close_t", "open_t+1"] = "close_t" + entry_fill: Literal["close_t", "open_t+1"] | None = None + exit_fill: Literal["close_t", "open_t+1"] | None = None fees_pct: float = 0.0002 slippage_bps: float = 5.0 stop_loss_pct: float | None = None @@ -44,6 +48,13 @@ class MatcherConfig: initial_capital: float = 1_000_000.0 position_sizing: Literal["equal", "score_weight"] = "equal" + def __post_init__(self) -> None: + # 解析最终口径: 优先 entry_fill/exit_fill, 否则回退到 matching (向后兼容)。 + if self.entry_fill is None: + self.entry_fill = self.matching + if self.exit_fill is None: + self.exit_fill = self.matching + @dataclass class TradeRecord: @@ -55,6 +66,7 @@ class TradeRecord: pnl_pct: float duration: int exit_reason: str # "signal" | "stop_loss" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end" + # 退出优先级 (高→低): pending_exit(历史挂单) > 风控(止损/移动止损/移动止盈) > signal(卖点) > max_hold(到期) > end name: str = "" shares: float = 0.0 lots: float = 0.0 @@ -255,21 +267,27 @@ class BacktestEngine: if not ent.any(): return self._empty_result() - # T+1: 信号右移 1 天 + 使用开盘价撮合 - if config.matching == "open_t+1": - price_col = "open" - ent_s = np.zeros(n, dtype=bool) - ext_s = np.zeros(n, dtype=bool) - ent_s[1:] = ent[:-1] - ext_s[1:] = ext[:-1] - ent = ent_s - ext = ext_s - else: - price_col = "close" - - prices = panel[price_col].to_numpy() + # 成交口径: entry/exit 可分别配置 close_t (信号当日收盘) 或 open_t+1 (次日开盘)。 + # open_t+1 时信号右移 1 天 (用前一根的信号 + 当根的 open 成交)。 + open_prices = panel["open"].to_numpy() close_prices = panel["close"].to_numpy() + # 同一 symbol 内相邻行掩码, 跨 symbol 边界不允许 shift (避免错配)。 + same_prev_symbol = np.zeros(n, dtype=bool) + same_prev_symbol[1:] = panel_symbols[1:] == panel_symbols[:-1] + + entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices + exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices + + if config.entry_fill == "open_t+1": + ent_s = np.zeros(n, dtype=bool) + ent_s[1:] = ent[:-1] & same_prev_symbol + ent = ent_s + if config.exit_fill == "open_t+1": + ext_s = np.zeros(n, dtype=bool) + ext_s[1:] = ext[:-1] & same_prev_symbol + ext = ext_s + # 逐 symbol 撮合 trades: list[TradeRecord] = [] unique_symbols = np.unique(panel_symbols) @@ -278,7 +296,8 @@ class BacktestEngine: mask = panel_symbols == sym sym_ent = ent[mask] sym_ext = ext[mask] - sym_prices = prices[mask] + sym_entry_prices = entry_prices[mask] + sym_exit_prices = exit_prices[mask] sym_close = close_prices[mask] sym_dates = panel_dates[mask] @@ -292,33 +311,33 @@ class BacktestEngine: if sym_ent[i]: holding = True entry_idx = i - entry_price = float(sym_prices[i]) + entry_price = float(sym_entry_prices[i]) hold_days = 0 else: hold_days += 1 exit_triggered = False exit_reason = "" - # 止损 — 用当日 close 检测 + # 止损 — 用当日 close 检测 (优先级最高) if config.stop_loss_pct is not None: pnl = (float(sym_close[i]) - entry_price) / entry_price if pnl <= -abs(config.stop_loss_pct): exit_triggered = True exit_reason = "stop_loss" - # 最大持仓天数 + # 信号退出 (优先于 max_hold: 卖点信号是策略主动离场) + if not exit_triggered and sym_ext[i]: + exit_triggered = True + exit_reason = "signal" + + # 最大持仓天数 (兜底: 无信号/未止损时强制平仓) if not exit_triggered and config.max_hold_days is not None: if hold_days >= config.max_hold_days: exit_triggered = True exit_reason = "max_hold" - # 信号退出 - if not exit_triggered and sym_ext[i]: - exit_triggered = True - exit_reason = "signal" - if exit_triggered: - exit_price = float(sym_prices[i]) + exit_price = float(sym_exit_prices[i]) pnl_pct = (exit_price - entry_price) / entry_price if entry_price > 0 else 0.0 fee_cost = config.fees_pct * 2 + config.slippage_bps / 10000.0 * 2 pnl_pct -= fee_cost @@ -386,37 +405,44 @@ class BacktestEngine: entry_signal_dates = np.array([None] * n, dtype=object) exit_signal_dates = np.array([None] * n, dtype=object) - if config.matching == "open_t+1": - price_col = "open" - ent = np.zeros(n, dtype=bool) - ext = np.zeros(n, dtype=bool) - same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] + same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] + + # 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。 + ent = np.zeros(n, dtype=bool) + if config.entry_fill == "open_t+1": ent[1:] = ent_raw[:-1] & same_prev_symbol - ext[1:] = ext_raw[:-1] & same_prev_symbol for idx in np.flatnonzero(ent): entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) + else: + ent = ent_raw + for idx in np.flatnonzero(ent): + entry_signal_dates[idx] = self._date_str(panel_dates[idx]) + + # 清仓口径: 独立于建仓, close_t 用信号日收盘, open_t+1 右移到次日 open。 + ext = np.zeros(n, dtype=bool) + if config.exit_fill == "open_t+1": + ext[1:] = ext_raw[:-1] & same_prev_symbol for idx in np.flatnonzero(ext): exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) else: - price_col = "close" - ent = ent_raw ext = ext_raw - for idx in np.flatnonzero(ent): - entry_signal_dates[idx] = self._date_str(panel_dates[idx]) for idx in np.flatnonzero(ext): exit_signal_dates[idx] = self._date_str(panel_dates[idx]) - prices = panel[price_col].to_numpy() open_prices = panel["open"].to_numpy() high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices low_prices = panel["low"].to_numpy() close_prices = panel["close"].to_numpy() + # 撮合价: 建仓/清仓各自独立选列。 + entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices + exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices has_volume = "volume" in panel.columns volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float) names = panel["name"].fill_null("").to_numpy() if "name" in panel.columns else np.array([""] * n) scores = panel["score"].fill_null(0).to_numpy() if "score" in panel.columns else np.zeros(n, dtype=float) trade_scores = scores.copy() - if config.matching == "open_t+1": + # 评分跟随建仓口径 shift (评分在买入日生效)。 + if config.entry_fill == "open_t+1": trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:]) limit_up_flags = ( panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool) @@ -439,7 +465,6 @@ class BacktestEngine: sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 score_min = getattr(config, "score_min", None) score_max = getattr(config, "score_max", None) - close_mode = config.matching == "close_t" trades: list[TradeRecord] = [] execution_stats: dict[str, int] = { "buy_invalid_price": 0, @@ -495,7 +520,7 @@ class BacktestEngine: def _can_buy(idx: int) -> tuple[bool, str]: if _is_suspended(idx): return False, "buy_suspended" - if not _valid_price(prices[idx]): + if not _valid_price(entry_prices[idx]): return False, "buy_invalid_price" if _is_one_price_limit(idx, "up"): return False, "buy_limit_up" @@ -504,7 +529,7 @@ class BacktestEngine: def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]: if _is_suspended(idx): return False, "sell_suspended" - exit_price = exit_price_override if exit_price_override is not None else prices[idx] + exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx] if not _valid_price(exit_price): return False, "sell_invalid_price" if _is_one_price_limit(idx, "down"): @@ -555,7 +580,7 @@ class BacktestEngine: _count(block_reason) return False - exit_price = float(exit_price_override) if exit_price_override is not None else float(prices[idx]) + exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx]) shares = 100.0 entry_value = shares * float(pos["entry_price"]) * (1 + buy_cost_pct) exit_value = shares * exit_price * (1 - sell_cost_pct) @@ -619,7 +644,7 @@ class BacktestEngine: _count("sell_no_future") continue - entry_price = float(prices[entry_idx]) + entry_price = float(entry_prices[entry_idx]) pos = { "symbol": sym, "name": str(names[entry_idx] or ""), @@ -648,32 +673,25 @@ class BacktestEngine: def _scheduled_reason() -> tuple[str | None, str]: if pos.get("pending_exit_reason"): return str(pos["pending_exit_reason"]), str(pos.get("pending_exit_signal_date") or d_str) - if config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: - return "max_hold", d_str + # 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。 if ext[idx]: return "signal", str(exit_signal_dates[idx] or d_str) + if config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: + return "max_hold", d_str if idx == rows[-1]: return "end", d_str return None, d_str - if close_mode: - reason, override_price = _risk_exit(pos, idx) - if reason and _try_close(pos, idx, reason, d_str, override_price): - closed = True - break - reason, signal_date = _scheduled_reason() - if reason and _try_close(pos, idx, reason, signal_date): - closed = True - break - else: - reason, signal_date = _scheduled_reason() - if reason and _try_close(pos, idx, reason, signal_date): - closed = True - break - reason, override_price = _risk_exit(pos, idx) - if reason and _try_close(pos, idx, reason, d_str, override_price): - closed = True - break + # 统一退出顺序: 风控(止损/移动止损/止盈)先于计划出场 (signal/max_hold/end)。 + # 无论 entry/exit 口径如何, 风控都是保护性离场, 必须最高优先级。 + reason, override_price = _risk_exit(pos, idx) + if reason and _try_close(pos, idx, reason, d_str, override_price): + closed = True + break + reason, signal_date = _scheduled_reason() + if reason and _try_close(pos, idx, reason, signal_date): + closed = True + break hi = float(high_prices[idx]) if _valid_price(hi): @@ -715,31 +733,37 @@ class BacktestEngine: entry_signal_dates = np.array([None] * n, dtype=object) exit_signal_dates = np.array([None] * n, dtype=object) - if config.matching == "open_t+1": - price_col = "open" - ent = np.zeros(n, dtype=bool) - ext = np.zeros(n, dtype=bool) - same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] + same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] + + # 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。 + ent = np.zeros(n, dtype=bool) + if config.entry_fill == "open_t+1": ent[1:] = ent_raw[:-1] & same_prev_symbol - ext[1:] = ext_raw[:-1] & same_prev_symbol for idx in np.flatnonzero(ent): entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) + else: + ent = ent_raw + for idx in np.flatnonzero(ent): + entry_signal_dates[idx] = self._date_str(panel_dates[idx]) + + # 清仓口径: 独立于建仓。 + ext = np.zeros(n, dtype=bool) + if config.exit_fill == "open_t+1": + ext[1:] = ext_raw[:-1] & same_prev_symbol for idx in np.flatnonzero(ext): exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) else: - price_col = "close" - ent = ent_raw ext = ext_raw - for idx in np.flatnonzero(ent): - entry_signal_dates[idx] = self._date_str(panel_dates[idx]) for idx in np.flatnonzero(ext): exit_signal_dates[idx] = self._date_str(panel_dates[idx]) - prices = panel[price_col].to_numpy() open_prices = panel["open"].to_numpy() high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices low_prices = panel["low"].to_numpy() close_prices = panel["close"].to_numpy() + # 撮合价: 建仓/清仓各自独立选列。 + entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices + exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices has_volume = "volume" in panel.columns volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float) names = ( @@ -751,7 +775,8 @@ class BacktestEngine: if "score" in panel.columns else np.zeros(n, dtype=float) ) trade_scores = scores.copy() - if config.matching == "open_t+1": + # 评分跟随建仓口径 shift (评分在买入日生效)。 + if config.entry_fill == "open_t+1": trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:]) limit_up_flags = ( panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool) @@ -847,7 +872,7 @@ class BacktestEngine: def _can_buy(idx: int) -> tuple[bool, str]: if _is_suspended(idx): return False, "buy_suspended" - if not _valid_price(prices[idx]): + if not _valid_price(entry_prices[idx]): return False, "buy_invalid_price" if _is_one_price_limit(idx, "up"): return False, "buy_limit_up" @@ -856,7 +881,7 @@ class BacktestEngine: def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]: if _is_suspended(idx): return False, "sell_suspended" - exit_price = exit_price_override if exit_price_override is not None else prices[idx] + exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx] if not _valid_price(exit_price): return False, "sell_invalid_price" if _is_one_price_limit(idx, "down"): @@ -881,7 +906,7 @@ class BacktestEngine: ) -> None: nonlocal cash pos = positions.pop(sym) - exit_price = float(exit_price_override) if exit_price_override is not None else float(prices[idx]) + exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx]) exit_value = pos["shares"] * exit_price * (1 - sell_cost_pct) cash += exit_value pnl_amount = exit_value - pos["entry_value"] @@ -945,11 +970,12 @@ class BacktestEngine: if pos.get("pending_exit_reason"): reason = str(pos["pending_exit_reason"]) signal_date = str(pos.get("pending_exit_signal_date") or d_str) - elif config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: - reason = "max_hold" + # 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。 elif idx is not None and ext[idx]: reason = "signal" signal_date = str(exit_signal_dates[idx] or d_str) + elif config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: + reason = "max_hold" elif d_idx == len(all_dates) - 1: reason = "end" if reason: @@ -1067,7 +1093,7 @@ class BacktestEngine: if allocation <= 0: _count("buy_exposure") continue - entry_price = float(prices[idx]) + entry_price = float(entry_prices[idx]) shares = np.floor(allocation / (entry_price * (1 + buy_cost_pct)) / 100) * 100 entry_value = shares * entry_price * (1 + buy_cost_pct) if shares <= 0: @@ -1098,7 +1124,6 @@ class BacktestEngine: "blocked_exit_days": 0, } - close_mode = config.matching == "close_t" for d_idx, d_str in enumerate(all_dates): if d_idx % 20 == 0: if cancel_event is not None and cancel_event.is_set(): @@ -1122,16 +1147,13 @@ class BacktestEngine: for pos in positions.values(): pos["hold_days"] += 1 - if close_mode: - _process_risk_exits(d_str, row_by_symbol, sold_today) - _process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today) - if d_idx < len(all_dates) - 1: - _process_entries(d_str, idxs, sold_today) - else: - _process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today) - if d_idx < len(all_dates) - 1: - _process_entries(d_str, idxs, sold_today) - _process_risk_exits(d_str, row_by_symbol, sold_today) + # 统一执行顺序 (不分口径): 风控(止损/移动止损/止盈) → 计划出场(signal/max_hold/end) → 建仓。 + # 风控是保护性离场, 必须最先; 计划出场次之; 建仓最后 (卖出释放的现金/仓位先用于满足新买)。 + # 当天新建仓不会被风控误杀 (_process_risk_exits 跳过 entry_date == d_str 的仓位)。 + _process_risk_exits(d_str, row_by_symbol, sold_today) + _process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today) + if d_idx < len(all_dates) - 1: + _process_entries(d_str, idxs, sold_today) for sym, pos in positions.items(): idx = row_by_symbol.get(sym) diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index e6f597f..125169e 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -30,7 +30,10 @@ class StrategyBacktestConfig: end: date params: dict | None = None overrides: dict | None = None + # matching 为向后兼容入口; 显式传 entry_fill/exit_fill 时以二者为准。 matching: Literal["close_t", "open_t+1"] = "open_t+1" + entry_fill: Literal["close_t", "open_t+1"] | None = None + exit_fill: Literal["close_t", "open_t+1"] | None = None fees_pct: float = 0.0002 slippage_bps: float = 5.0 max_positions: int = 10 @@ -40,6 +43,12 @@ class StrategyBacktestConfig: mode: Literal["position", "full"] = "position" holding_days: int = 5 + def __post_init__(self) -> None: + if self.entry_fill is None: + self.entry_fill = self.matching + if self.exit_fill is None: + self.exit_fill = self.matching + @dataclass class StrategyBacktestResult: @@ -181,6 +190,8 @@ class StrategyBacktestService: t_sim = time.perf_counter() matcher_config = MatcherConfig( matching=config.matching, + entry_fill=config.entry_fill, + exit_fill=config.exit_fill, fees_pct=config.fees_pct, slippage_bps=config.slippage_bps, stop_loss_pct=stop_loss, @@ -632,6 +643,8 @@ class StrategyBacktestService: "score_min": score_min, "score_max": score_max, "matching": c.matching, + "entry_fill": c.entry_fill, + "exit_fill": c.exit_fill, "fees_pct": c.fees_pct, "slippage_bps": c.slippage_bps, "max_positions": c.max_positions, diff --git a/backend/tests/backtest/test_engine_portfolio.py b/backend/tests/backtest/test_engine_portfolio.py index 8a36cb6..2c0ede5 100644 --- a/backend/tests/backtest/test_engine_portfolio.py +++ b/backend/tests/backtest/test_engine_portfolio.py @@ -310,3 +310,113 @@ def test_independent_candidates_apply_stop_loss(): assert len(result.trades) == 1 assert result.trades[0].exit_reason == "stop_loss" assert result.trades[0].exit_price == 9.0 + + +def test_signal_exit_takes_priority_over_max_hold(): + """同一日既有卖点信号又到期 → 应按 signal 平仓 (卖点优先于 max_hold 兜底)。""" + panel = _panel( + ["A"], + days=4, + overrides={ + # day1 次日开盘买入 (open_t+1), 价 10 + ("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10}, + # day2 持有 (hold_days 计到 1) + ("A", 2): {"open": 11, "high": 11, "low": 11, "close": 11}, + # day3: 既到期 (hold_days=2 >= max_hold_days=2) 又有卖点信号 → signal 优先 + ("A", 3): {"open": 12, "high": 12, "low": 12, "close": 12}, + }, + ) + entries = _mask(panel, {("A", 0)}) # day0 收盘确认 → day1 开盘买 + exits = _mask(panel, {("A", 2)}) # day2 收盘确认卖点 → day3 开盘卖 + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=1, + max_hold_days=2, + initial_capital=100_000, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.exit_reason == "signal" + assert trade.exit_price == 12.0 # 卖点用 day3 开盘 (exit_fill 跟随 matching=open_t+1) + + +def test_stop_loss_triggers_even_when_expired_in_open_mode(): + """open_t+1 模式下仓位到期且当日破止损 → 应按 stop_loss 平仓 (风控优先于 max_hold)。""" + panel = _panel( + ["A"], + days=4, + overrides={ + ("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10}, + # day3 开盘跳空跌破止损 (-10%): open=8.9 < 9.0 止损线, low=8.5 + ("A", 3): {"open": 8.9, "high": 8.9, "low": 8.5, "close": 8.7}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + fees_pct=0, + slippage_bps=0, + max_positions=1, + max_hold_days=2, + stop_loss_pct=0.1, + initial_capital=100_000, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.exit_reason == "stop_loss" + # 风控盘中触发: 开盘价 8.9 <= 止损线 9.0 → 按开盘价 8.9 成交 + assert trade.exit_price == 8.9 + + +def test_default_fill_is_buy_open_sell_close(): + """拆分口径: 建仓=次日开盘, 清仓=收盘。entry_price 用次日 open, exit_price 用收盘价。""" + panel = _panel( + ["A"], + days=4, + overrides={ + # day1: 次日开盘买入, 开盘 10 + ("A", 1): {"open": 10, "high": 10.5, "low": 9.5, "close": 10.2}, + # day2: 到期 (max_hold_days=1), 收盘卖 + ("A", 2): {"open": 11, "high": 11, "low": 10, "close": 10.8}, + }, + ) + entries = _mask(panel, {("A", 0)}) # day0 收盘确认 + exits = _mask(panel, set()) + + result = _engine().simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + entry_fill="open_t+1", + exit_fill="close_t", + fees_pct=0, + slippage_bps=0, + max_positions=1, + max_hold_days=1, + initial_capital=100_000, + ), + ) + + assert len(result.trades) == 1 + trade = result.trades[0] + assert trade.entry_price == 10.0 # 次日开盘 + assert trade.exit_price == 10.8 # 到期日收盘 + assert trade.exit_reason == "max_hold" diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1e9d6d2..ca0abcc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -962,6 +962,8 @@ export const api = { params?: Record | null overrides?: Record | null matching?: 'close_t' | 'open_t+1' + entry_fill?: 'close_t' | 'open_t+1' | null + exit_fill?: 'close_t' | 'open_t+1' | null fees_pct?: number slippage_bps?: number max_positions?: number diff --git a/frontend/src/lib/backtestTask.ts b/frontend/src/lib/backtestTask.ts index 0d3ed9e..2c2eb6d 100644 --- a/frontend/src/lib/backtestTask.ts +++ b/frontend/src/lib/backtestTask.ts @@ -124,6 +124,8 @@ export function startBacktest(params: { start?: string | null end?: string | null matching?: string + entry_fill?: string + exit_fill?: string fees_pct?: number slippage_bps?: number max_positions?: number @@ -151,6 +153,8 @@ export function startBacktest(params: { start: params.start ?? undefined, end: params.end ?? undefined, matching: params.matching, + entry_fill: params.entry_fill, + exit_fill: params.exit_fill, fees_pct: params.fees_pct, slippage_bps: params.slippage_bps, max_positions: params.max_positions, diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index cbbe5f9..d44468d 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -88,6 +88,8 @@ export const storage = { start: string end: string matching: 'close_t' | 'open_t+1' + entryFill: 'close_t' | 'open_t+1' + exitFill: 'close_t' | 'open_t+1' fees: string maxPositions: string maxExposure: string diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 0bf1a41..1443eab 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -600,7 +600,10 @@ export function StrategyBacktest() { const [symbols, setSymbols] = useState(saved?.symbols ?? '') const [start, setStart] = useState(saved?.start ?? THREE_MONTHS_AGO) const [end, setEnd] = useState(saved?.end ?? TODAY) - const [matching, setMatching] = useState<'close_t' | 'open_t+1'>(saved?.matching ?? 'open_t+1') + // 成交口径: 建仓/清仓可独立配置。向后兼容老 matching (派生为 entry=exit=matching)。 + const [matching] = useState<'close_t' | 'open_t+1'>(saved?.matching ?? 'open_t+1') + const [entryFill, setEntryFill] = useState<'close_t' | 'open_t+1'>(saved?.entryFill ?? saved?.matching ?? 'open_t+1') + const [exitFill, setExitFill] = useState<'close_t' | 'open_t+1'>(saved?.exitFill ?? saved?.matching ?? 'close_t') const [fees, setFees] = useState(saved?.fees ?? '2') const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10') const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100') @@ -704,6 +707,8 @@ export function StrategyBacktest() { start, end, matching, + entryFill, + exitFill, fees, maxPositions, maxExposure, @@ -726,6 +731,8 @@ export function StrategyBacktest() { start: start || null, end: end || undefined, matching, + entry_fill: entryFill, + exit_fill: exitFill, fees_pct: Number(fees) / 10000, max_positions: Number(maxPositions), max_exposure_pct: Number(maxExposure) / 100, @@ -1182,14 +1189,23 @@ export function StrategyBacktest() { )} -
- - -
买卖点由策略触发器决定;这里只决定日线信号确认后按哪个价格成交。
+
+
+ + +
+
+ + +
+
建仓默认次日开盘(避免未来函数),清仓默认当日收盘(持仓中可盘中/收盘卖);买卖点由策略触发器决定,这里只决定成交价。
{simMode === 'position' && (
@@ -1841,7 +1857,8 @@ export function StrategyBacktest() {
触发 / 成交 / 仓位关系
触发器决定什么时候产生买卖信号;评分只在多个买点同时出现时排序。
-
默认按日线收盘确认,次日开盘成交;信号日收盘成交为偏理想对照口径。
+
成交口径可分别设置建仓/清仓:默认建仓次日开盘(避免未来函数)、清仓当日收盘(持仓中可盘中/收盘卖)。
+
退出优先级:止损/移动止损 > 卖点信号 > 到期平仓;到期只作兜底,不抢占卖点或风控。
最大持仓数控制同时持股数量,最大总仓位控制资金投入比例;剩余现金不等于可新增持仓名额。