Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backtest/capacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion backtest/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
31 changes: 31 additions & 0 deletions tests/test_backtest/test_cost_model.py
Original file line number Diff line number Diff line change
@@ -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"])
Expand Down
8 changes: 7 additions & 1 deletion tests/test_backtest/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading