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
+17 -1
View File
@@ -191,6 +191,8 @@ class StrategyBacktestRequest(BaseModel):
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
commission_pct: float | None = None
stamp_tax_pct: float | None = None
slippage_bps: float = 5.0
max_positions: int = 10
max_exposure_pct: float = 1.0
@@ -224,6 +226,8 @@ def strategy_run(req: StrategyBacktestRequest, request: Request):
entry_fill=req.entry_fill,
exit_fill=req.exit_fill,
fees_pct=req.fees_pct,
commission_pct=req.commission_pct,
stamp_tax_pct=req.stamp_tax_pct,
slippage_bps=req.slippage_bps,
max_positions=req.max_positions,
max_exposure_pct=req.max_exposure_pct,
@@ -277,8 +281,9 @@ def _make_job_key(
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,
commission_pct: float | None = None, stamp_tax_pct: float | None = None,
) -> str:
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}"
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}|{commission_pct}|{stamp_tax_pct}"
return hashlib.md5(raw.encode()).hexdigest()[:12]
@@ -293,6 +298,8 @@ async def strategy_stream(
entry_fill: str | None = None,
exit_fill: str | None = None,
fees_pct: float = 0.0002,
commission_pct: float | None = None,
stamp_tax_pct: float | None = None,
slippage_bps: float = 5.0,
max_positions: int = 10,
max_exposure_pct: float = 1.0,
@@ -341,6 +348,7 @@ async def strategy_stream(
fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing,
params, overrides,
mode, holding_days,
commission_pct, stamp_tax_pct,
)
_cleanup_stale_jobs()
@@ -374,6 +382,8 @@ async def strategy_stream(
entry_fill=entry_fill,
exit_fill=exit_fill,
fees_pct=fees_pct,
commission_pct=commission_pct,
stamp_tax_pct=stamp_tax_pct,
slippage_bps=slippage_bps,
max_positions=int(max_positions),
max_exposure_pct=float(max_exposure_pct),
@@ -447,6 +457,10 @@ async def strategy_cancel(request: Request):
p = parse_qs(qs)
def _get(key: str, default: str = "") -> str:
return p.get(key, [default])[0]
def _get_opt_float(key: str) -> float | None:
# 可选成本参数: 缺省或空串 → None (与 stream 侧 float | None 口径一致, 保证 job_key 对齐)。
v = _get(key)
return float(v) if v else None
job_key = _make_job_key(
_get("strategy_id"),
_get("symbols") or None,
@@ -465,6 +479,8 @@ async def strategy_cancel(request: Request):
_get("overrides") or None,
_get("mode", "position"),
int(_get("holding_days", "5")),
commission_pct=_get_opt_float("commission_pct"),
stamp_tax_pct=_get_opt_float("stamp_tax_pct"),
)
job = _running_jobs.get(job_key)
if job and not job.done: