diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 0fe0736..2f8adc1 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -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: diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 943490c..66a223a 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -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) diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 0518fb3..947f6d9 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -35,6 +35,8 @@ class StrategyBacktestConfig: 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 @@ -198,6 +200,8 @@ class StrategyBacktestService: entry_fill=config.entry_fill, exit_fill=config.exit_fill, fees_pct=config.fees_pct, + commission_pct=config.commission_pct, + stamp_tax_pct=config.stamp_tax_pct, slippage_bps=config.slippage_bps, stop_loss_pct=stop_loss, take_profit_pct=take_profit, @@ -653,6 +657,8 @@ class StrategyBacktestService: "entry_fill": c.entry_fill, "exit_fill": c.exit_fill, "fees_pct": c.fees_pct, + "commission_pct": c.commission_pct, + "stamp_tax_pct": c.stamp_tax_pct, "slippage_bps": c.slippage_bps, "max_positions": c.max_positions, "max_exposure_pct": c.max_exposure_pct, diff --git a/backend/tests/backtest/test_cost_model.py b/backend/tests/backtest/test_cost_model.py new file mode 100644 index 0000000..8484a91 --- /dev/null +++ b/backend/tests/backtest/test_cost_model.py @@ -0,0 +1,173 @@ +"""成本模型拆分测试 — 佣金(双边) + 印花税(仅卖出) + 滑点(双边)。 + +覆盖: +1. 向后兼容: 仅传 fees_pct 时, 买卖成本与旧行为完全一致 (无印花税)。 +2. 拆分模型: commission_pct / stamp_tax_pct / slippage_bps 各自参与, 印花税只在卖出侧。 +3. 优先级: 显式 commission_pct 覆盖 fees_pct。 +4. 撮合传导: 印花税只影响卖出腿, 且精度进入 TradeRecord。 +""" +from __future__ import annotations + +from datetime import date, timedelta + +import polars as pl + +from app.backtest.engine import BacktestEngine, MatcherConfig + +# --------------------------------------------------------------- +# 复用 portfolio 测试的最小面板/掩码构造 +# --------------------------------------------------------------- + +def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict | None = None) -> pl.DataFrame: + overrides = overrides or {} + start = date(2024, 1, 1) + rows = [] + for sym in symbols: + for i in range(days): + patch = overrides.get((sym, i), {}) + rows.append({ + "symbol": sym, + "name": sym, + "date": start + timedelta(days=i), + "open": patch.get("open", price), + "high": patch.get("high", price), + "low": patch.get("low", price), + "close": patch.get("close", price), + "volume": patch.get("volume", 100_000), + "score": patch.get("score", 1), + "signal_limit_up": patch.get("signal_limit_up", False), + "signal_limit_down": patch.get("signal_limit_down", False), + }) + return pl.DataFrame(rows).sort(["symbol", "date"]) + + +def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series: + base = date(2024, 1, 1) + values = [] + for row in panel.select(["symbol", "date"]).iter_rows(named=True): + day = (row["date"] - base).days + values.append((row["symbol"], day) in marks) + return pl.Series(values, dtype=pl.Boolean) + + +# --------------------------------------------------------------- +# 1. 单元测试: buy_cost_pct / sell_cost_pct +# --------------------------------------------------------------- + +def test_legacy_fees_pct_keeps_symmetric_cost_without_stamp(): + """仅传 fees_pct: 买卖成本相等, 均为 fees + slippage, 不含印花税 (旧行为)。""" + cfg = MatcherConfig(fees_pct=0.0002, slippage_bps=5.0) + assert cfg.buy_cost_pct() == 0.0002 + 0.0005 + assert cfg.sell_cost_pct() == 0.0002 + 0.0005 # 无印花税, 与买入对称 + + +def test_decomposed_cost_applies_stamp_only_on_sell(): + """拆分模型: 佣金双边, 印花税仅卖出, 滑点双边。""" + cfg = MatcherConfig(commission_pct=0.0003, stamp_tax_pct=0.001, slippage_bps=5.0) + assert cfg.buy_cost_pct() == 0.0003 + 0.0005 + assert cfg.sell_cost_pct() == 0.0003 + 0.001 + 0.0005 + + +def test_commission_pct_overrides_fees_pct(): + """同时给 fees_pct 与 commission_pct 时, 以 commission_pct 为准。""" + cfg = MatcherConfig(fees_pct=0.0002, commission_pct=0.0009, slippage_bps=0) + assert cfg.buy_cost_pct() == 0.0009 + assert cfg.sell_cost_pct() == 0.0009 # stamp 未设 → 0 + + +def test_commission_pct_zero_is_not_treated_as_unset(): + """commission_pct=0.0 是有效值, 不应因 falsy 而回退到 fees_pct。""" + cfg = MatcherConfig(fees_pct=0.0002, commission_pct=0.0, slippage_bps=0) + assert cfg.buy_cost_pct() == 0.0 + assert cfg.sell_cost_pct() == 0.0 + + +# --------------------------------------------------------------- +# 2. 撮合传导: 印花税只影响卖出腿 +# --------------------------------------------------------------- + +def _round_trip_trade(cfg_kwargs: dict): + """价格恒定的一次买卖来回, 返回唯一成交的 TradeRecord。""" + panel = _panel( + ["A"], + days=3, + overrides={ + ("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10}, + ("A", 2): {"open": 10, "high": 10, "low": 10, "close": 10}, + }, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + result = BacktestEngine(repo=None).simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + matching="open_t+1", + max_positions=1, + max_hold_days=1, + initial_capital=100_000, + **cfg_kwargs, + ), + ) + assert len(result.trades) == 1 + return result.trades[0] + + +def test_stamp_tax_only_deducts_on_sell_leg(): + """价格不变时, 加印花税只影响卖出腿, 不改变买入腿的持仓股数。 + + 本 PR 最需守护的不变量: 卖出成本不得反向污染买入腿的 sizing。 + 因此断言 (a) 两次运行 shares 相等, (b) 亏损差额恰为 卖出市值 乘以 印花税率, + 卖出市值用反推的 shares 计算, 不硬编码 (避免 sizing 逻辑变动导致误报/漏报)。 + """ + stamp = 0.001 + t_no_stamp = _round_trip_trade(dict(commission_pct=0.0003, stamp_tax_pct=0.0, slippage_bps=0)) + t_with_stamp = _round_trip_trade(dict(commission_pct=0.0003, stamp_tax_pct=stamp, slippage_bps=0)) + + # (a) 核心不变量: 买入腿由 buy_cost_pct 决定, 与印花税无关 → 股数必须一致。 + assert t_no_stamp.shares == t_with_stamp.shares + shares = t_no_stamp.shares + + # (b) 差额恰等于 卖出市值(shares * 卖出价 10) 乘以 印花税率。 + delta = t_no_stamp.pnl_amount - t_with_stamp.pnl_amount + assert delta > 0 + assert abs(delta - shares * 10 * stamp) < 1e-6 + + +def test_independent_candidate_pnl_pct_includes_decomposed_costs(): + """独立候选模式 (close_t): 价格不变时 pnl_pct == -(buy_cost + sell_cost)。""" + panel = _panel( + ["A"], + days=3, + overrides={("A", 0): {"close": 10}, ("A", 1): {"close": 10}}, + ) + entries = _mask(panel, {("A", 0)}) + exits = _mask(panel, set()) + result = BacktestEngine(repo=None).simulate_independent_candidates( + panel, + entries, + exits, + MatcherConfig(matching="close_t", commission_pct=0.0003, stamp_tax_pct=0.001, slippage_bps=0, max_hold_days=1), + ) + assert len(result.trades) == 1 + # buy_cost=0.0003, sell_cost=0.0003+0.001=0.0013 → 合计 -0.0016 + assert abs(result.trades[0].pnl_pct - (-(0.0003 + 0.0013))) < 1e-9 + + +# --------------------------------------------------------------- +# 3. SSE 任务缓存键: 成本参数必须参与, 否则不同成本命中同一缓存 / cancel 失配 +# --------------------------------------------------------------- + +def test_job_key_distinguishes_commission_and_stamp(): + """成本参数不同的两次回测必须得到不同 job_key (避免缓存碰撞与 cancel 失配)。""" + from app.api.backtest import _make_job_key + + base_args = ("s", None, None, None, "open_t+1", None, None, 0.0002, 5.0, 10, 1.0, 1e6, "equal", None, None, "position", 5) + k_none = _make_job_key(*base_args) + k_comm = _make_job_key(*base_args, commission_pct=0.0009) + k_stamp = _make_job_key(*base_args, stamp_tax_pct=0.001) + + assert k_none != k_comm + assert k_none != k_stamp + assert k_comm != k_stamp diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 93c6b62..fccdbff 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1174,6 +1174,8 @@ export const api = { entry_fill?: 'close_t' | 'open_t+1' | null exit_fill?: 'close_t' | 'open_t+1' | null fees_pct?: number + commission_pct?: number + stamp_tax_pct?: number slippage_bps?: number max_positions?: number initial_capital?: number diff --git a/frontend/src/lib/backtestTask.ts b/frontend/src/lib/backtestTask.ts index 2c2eb6d..3e72520 100644 --- a/frontend/src/lib/backtestTask.ts +++ b/frontend/src/lib/backtestTask.ts @@ -127,6 +127,8 @@ export function startBacktest(params: { entry_fill?: string exit_fill?: string fees_pct?: number + commission_pct?: number + stamp_tax_pct?: number slippage_bps?: number max_positions?: number max_exposure_pct?: number @@ -156,6 +158,8 @@ export function startBacktest(params: { entry_fill: params.entry_fill, exit_fill: params.exit_fill, fees_pct: params.fees_pct, + commission_pct: params.commission_pct, + stamp_tax_pct: params.stamp_tax_pct, slippage_bps: params.slippage_bps, max_positions: params.max_positions, max_exposure_pct: params.max_exposure_pct, diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 1b3db9b..b9e7295 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -91,6 +91,7 @@ export const storage = { entryFill: 'close_t' | 'open_t+1' exitFill: 'close_t' | 'open_t+1' fees: string + stampTax?: string slippage: string maxPositions: string maxExposure: string diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 30f89b1..633b7f6 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -662,6 +662,7 @@ export function StrategyBacktest() { 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 [stampTax, setStampTax] = useState(saved?.stampTax ?? '1') const [slippage, setSlippage] = useState(saved?.slippage ?? '5') const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10') const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100') @@ -768,6 +769,7 @@ export function StrategyBacktest() { entryFill, exitFill, fees, + stampTax, slippage, maxPositions, maxExposure, @@ -792,7 +794,8 @@ export function StrategyBacktest() { matching, entry_fill: entryFill, exit_fill: exitFill, - fees_pct: Number(fees) / 10000, + commission_pct: Number(fees) / 10000, + stamp_tax_pct: Number(stampTax) / 1000, slippage_bps: Number(slippage), max_positions: Number(maxPositions), max_exposure_pct: Number(maxExposure) / 100, @@ -1298,7 +1301,11 @@ export function StrategyBacktest() {