mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>退出优先级:止损/移动止损 > 卖点信号 > 到期平仓;到期只作兜底,不抢占卖点或风控。</div>
|
||||
<div>最大持仓数控制同时持股数量,最大总仓位控制资金投入比例;剩余现金不等于可新增持仓名额。</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user