feat(backtest): 拆分建仓/清仓成交口径并修复退出优先级

成交口径: 单个 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 个测试经兼容映射后行为不变, 全部通过。
This commit is contained in:
shy3130
2026-06-23 14:00:09 +08:00
parent 349ff3062e
commit 3e79f94da8
8 changed files with 284 additions and 101 deletions
+16 -3
View File
@@ -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")),
+111 -89
View File
@@ -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)
+13
View File
@@ -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,
@@ -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"
+2
View File
@@ -962,6 +962,8 @@ export const api = {
params?: Record<string, any> | null
overrides?: Record<string, any> | 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
+4
View File
@@ -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,
+2
View File
@@ -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
@@ -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() {
)}
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={matching} onChange={e => setMatching(e.target.value as any)} className={INPUT_CLS}>
<option value="open_t+1"> </option>
<option value="close_t"> </option>
</select>
<div className="mt-1 text-[10px] leading-4 text-muted">线</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={entryFill} onChange={e => setEntryFill(e.target.value as any)} className={INPUT_CLS}>
<option value="open_t+1"></option>
<option value="close_t"></option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={exitFill} onChange={e => setExitFill(e.target.value as any)} className={INPUT_CLS}>
<option value="close_t">/</option>
<option value="open_t+1"></option>
</select>
</div>
</div>
<div className="mt-1 text-[10px] leading-4 text-muted">/</div>
{simMode === 'position' && (
<div className="grid grid-cols-2 gap-2">
@@ -1841,7 +1857,8 @@ export function StrategyBacktest() {
<div className="mb-4 rounded-btn border border-accent/25 bg-accent/5 px-3 py-2.5 text-[11px] leading-5 text-secondary">
<div className="font-medium text-foreground"> / / </div>
<div className="mt-1"></div>
<div>线</div>
<div>//</div>
<div>退/ &gt; &gt; </div>
<div></div>
</div>