feat(backtest): 成本模型拆分为佣金+印花税(仅卖出)+滑点 (#46)

* feat(backtest): 成本模型拆分为佣金+印花税(仅卖出)+滑点

MatcherConfig 新增 commission_pct/stamp_tax_pct 及 buy_cost_pct()/
sell_cost_pct() 方法, 撮合三处现场统一改用方法计算。

印花税单边(仅卖出)是 A 股与美股的本质差异: 旧的 fees_pct 双边模型
把印花税摊到买卖两腿, 会高估买入成本、低估卖出成本。拆分后买入腿=
佣金+滑点, 卖出腿=佣金+印花税+滑点。

commission_pct 未设时回退 fees_pct, 印花税未设时为 0, 完全向后兼容,
既有 12 个 portfolio 测试不变。strategy/api(含 SSE + 缓存键)全链透传。

新增 tests/backtest/test_cost_model.py 覆盖向后兼容/拆分/印花税仅卖出/
优先级/撮合传导 5 个用例。

* fix(backtest): 打通成本模型前端链路并修复 cancel 缓存键

子代理审查发现 PR3 半成品缺陷, 本次补全:

C1 (正确性): /strategy/cancel 的 _make_job_key 漏传 commission_pct/
stamp_tax_pct, 一旦用户设成本参数, cancel 算出的 job_key 与 stream 失配
导致取消静默失败。补 None-aware 解析对齐两侧口径。

C2 (完整性): 前端 SSE/sync 链未透传新字段, 新成本模型从 UI 够不到。
- backtestTask.ts / api.ts: 类型 + query 透传 commission_pct/stamp_tax_pct
- StrategyBacktest.tsx: 佣金映射到 commission_pct, 新增印花税(千分之)输入,
  默认 1 (A股千1), 映射 stamp_tax_pct; 状态持久化
- storage.ts: stampTax 类型声明

测试加固:
- test_stamp_tax_only_deducts_on_sell_leg: 去掉硬编码 shares=9900,
  改为断言两次运行 shares 相等 (守护买入腿 sizing 不受卖出成本污染)
  + 从结果反推卖出市值校验差额
- 新增 commission_pct=0.0 非 None 边界 (防 falsy 回退)
- 新增 job_key 区分成本参数的回归测试 (守护 C1)

后端 26 测试全绿; 前端 tsc 仅剩既有 baseUrl deprecation (非本次引入)。
This commit is contained in:
im47cn
2026-07-03 23:15:32 +08:00
committed by GitHub
parent 5a46bf915e
commit 1fd7e84785
8 changed files with 234 additions and 8 deletions
+22 -5
View File
@@ -34,7 +34,11 @@ class MatcherConfig:
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
# 成本模型: 优先使用拆分口径 (佣金双边 + 印花税仅卖出 + 滑点双边)。
# 未设 commission_pct 时回退到 fees_pct 作为双边佣金 (向后兼容, 无印花税)。
fees_pct: float = 0.0002
commission_pct: float | None = None
stamp_tax_pct: float | None = None
slippage_bps: float = 5.0
stop_loss_pct: float | None = None
take_profit_pct: float | None = None
@@ -56,6 +60,19 @@ class MatcherConfig:
if self.exit_fill is None:
self.exit_fill = self.matching
def _commission_pct(self) -> float:
# commission_pct 显式给出时优先, 否则回退 fees_pct (向后兼容双边佣金)。
return self.commission_pct if self.commission_pct is not None else self.fees_pct
def buy_cost_pct(self) -> float:
# 买入腿: 佣金 + 滑点。
return self._commission_pct() + self.slippage_bps / 10000.0
def sell_cost_pct(self) -> float:
# 卖出腿: 佣金 + 印花税 + 滑点。印花税未设时为 0 (向后兼容)。
stamp = self.stamp_tax_pct if self.stamp_tax_pct is not None else 0.0
return self._commission_pct() + stamp + self.slippage_bps / 10000.0
@dataclass
class TradeRecord:
@@ -340,7 +357,7 @@ class BacktestEngine:
if exit_triggered:
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
fee_cost = config.buy_cost_pct() + config.sell_cost_pct()
pnl_pct -= fee_cost
e_date = sym_dates[entry_idx]
@@ -462,8 +479,8 @@ class BacktestEngine:
row_pos_in_symbol[i] = len(rows)
rows.append(i)
buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
buy_cost_pct = config.buy_cost_pct()
sell_cost_pct = config.sell_cost_pct()
score_min = getattr(config, "score_min", None)
score_max = getattr(config, "score_max", None)
trades: list[TradeRecord] = []
@@ -808,8 +825,8 @@ class BacktestEngine:
if not all_dates:
return self._empty_result()
buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
buy_cost_pct = config.buy_cost_pct()
sell_cost_pct = config.sell_cost_pct()
cash = float(config.initial_capital)
peak = cash
max_positions = max(int(config.max_positions), 0)