diff --git a/backtest/capacity.py b/backtest/capacity.py index 721d290..274395b 100644 --- a/backtest/capacity.py +++ b/backtest/capacity.py @@ -272,7 +272,10 @@ def _run_at_aum( # Standard turnover cost turnover = abs(trade_weights).sum() / 2 total_turnover += turnover - cost_rate = self.cost_model.compute_costs(turnover) + # Two-way traded value: compute_costs halves stamp tax on the + # assumption that half of its input is sells, so it needs + # buys + sells, not the one-sided turnover kept above. + cost_rate = self.cost_model.compute_costs(turnover * 2) capital -= cost_rate * capital # Extra impact cost diff --git a/backtest/engine.py b/backtest/engine.py index 64a9132..a4b0c00 100644 --- a/backtest/engine.py +++ b/backtest/engine.py @@ -224,7 +224,13 @@ def _simulate_pnl(self, returns: pd.DataFrame) -> None: turnover = (target_weights - current_weights).abs().sum() / 2 if turnover > 0: - rebalance_cost = self.cost_model.compute_costs(turnover) + # `turnover` is one-sided (the convention documented in + # portfolio/constraints.py), but CostModel.compute_costs + # charges commission and slippage on the value it is given + # and halves stamp tax on the assumption that half of it is + # sells -- which only holds for the *two-way* traded value. + # Passing one-sided turnover halved every component. + rebalance_cost = self.cost_model.compute_costs(turnover * 2) turnover_records.append((next_rdate, turnover)) current_weights = target_weights.copy() diff --git a/tests/test_backtest/test_cost_model.py b/tests/test_backtest/test_cost_model.py index c4049b9..ee21da6 100644 --- a/tests/test_backtest/test_cost_model.py +++ b/tests/test_backtest/test_cost_model.py @@ -1,9 +1,40 @@ """Tests for transaction cost model.""" import pandas as pd +import pytest from quant_platform.backtest.cost_model import CostModel +def test_compute_costs_scalar_input_is_the_two_way_traded_value(): + """The contract the backtest engine depends on (regression for BUG-03). + + `compute_costs` charges commission and slippage on the value it is given, + and halves stamp tax on the assumption that half of that value is sells. + That assumption only holds for the *two-way* traded value (buys + sells), + not for one-sided turnover. + + The engine used to pass one-sided turnover -- the convention documented in + portfolio/constraints.py -- which halved commission, stamp tax and slippage + together. On a 30%-one-sided-turnover monthly strategy that understated the + cost by ~47 bp/year. + """ + model = CostModel(commission=0.0003, stamp_tax=0.001, slippage=0.0005) + one_sided = 0.30 + two_way = one_sided * 2 + + expected = ( + two_way * 0.0003 # commission, both sides + + one_sided * 0.001 # stamp tax, sells only + + two_way * 0.0005 # slippage, both sides + ) + + assert model.compute_costs(two_way) == pytest.approx(expected) + # And feeding it one-sided is exactly the halving that was the defect. + assert model.compute_costs(two_way) == pytest.approx( + 2 * model.compute_costs(one_sided) + ) + + def test_cost_model_basic(): model = CostModel() turnover = pd.Series([100000, 50000], index=["A", "B"]) diff --git a/tests/test_backtest/test_engine.py b/tests/test_backtest/test_engine.py index d6dd2d7..0ef9d88 100644 --- a/tests/test_backtest/test_engine.py +++ b/tests/test_backtest/test_engine.py @@ -39,7 +39,13 @@ def test_initial_rebalance_charges_cost_and_records_turnover(self): engine.weights_history = {dates[1]: target} engine._simulate_pnl(returns) - assert engine.portfolio_values.iloc[-1] == pytest.approx(999_500.0) + # One-sided turnover is 0.5 (cash -> fully invested), so the two-way + # traded value is 1.0 and the cost is 1.0 * commission = 0.001. + # This used to assert 999_500 -- the half-charged figure produced by + # passing one-sided turnover to a cost model that expects two-way. + assert engine.portfolio_values.iloc[-1] == pytest.approx(999_000.0) assert engine.turnover_history is not None assert len(engine.turnover_history) == 1 + # The recorded turnover stays one-sided: it is a metric, not the base + # the cost model is fed. assert engine.turnover_history.iloc[0] == pytest.approx(0.5)