Skip to content

fix(backtest): charge transaction costs on the two-way traded value (BUG-03) - #21

Merged
sijie-Z merged 4 commits into
mainfrom
fix/cost-model-two-way
Sep 13, 2026
Merged

sijie-Z merged 4 commits into
mainfrom
fix/cost-model-two-way

Conversation

@sijie-Z

@sijie-Z sijie-Z commented Sep 13, 2026

Copy link
Copy Markdown
Owner

P0. Last of the four research-integrity defects — and the only one that invalidates every backtest number this platform has produced.

What was wrong

CostModel.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:

commission_cost = abs_turnover * commission_rate
stamp_cost      = abs_turnover * stamp_tax_rate * 0.5

The * 0.5 only holds for the two-way traded value (buys + sells). Both callers passed one-sided turnover — which is the convention this codebase documents at portfolio/constraints.py:16: "Maximum one-sided turnover per rebalance".

So every component was charged at half.

Measured

30% one-sided turnover, commission 3bp / stamp 10bp / slippage 5bp:

cost
before 3.90 bp compute_costs(0.30)
after 7.80 bp compute_costs(0.60)
ratio 2.0000

Hand-check for the same inputs: commission 0.60×3bp + stamp 0.30×10bp + slippage 0.60×5bp = 7.80 bp, matching to 1e-12.

A monthly strategy at 30% one-sided turnover was undercharged by ~47 bp/year.

Fix

Both call sites now pass turnover * 2:

  • backtest/engine.py
  • backtest/capacity.py

The recorded turnover stays one-sided in both places — it is a reported metric, not the base the cost model is fed. Conflating those two roles is exactly what caused the defect, so the comments now say which is which.

Tests

  • test_cost_model.py pins the contract itself: two-way input produces the hand-computed cost, and one-sided input produces exactly half.
  • test_engine.py's rebalance test asserted 999_500 — the half-charged figure — and now asserts 999_000.

1318 passed, 1 skipped.

Impact — this is a research-result invalidation event

Affected: every CAGR, Sharpe, Sortino, net-return, drawdown and turnover-adjusted figure produced through BacktestEngine — python main.py run / compare / sweep, the walk-forward aggregate, and everything in README.md's milestone table.

Per NO_LOOKAHEAD_CONTRACT.md's own remediation procedure, the prior results should be marked affected, not deleted — the corrected numbers come from re-running, and the old ones stay traceable.

The result-validity metadata needed to do that does not exist yet (a valid / affected / invalidated / superseded field plus affected_by on the research registry). This PR does not add it; it is the natural next piece of work, and BUG-22 / BUG-23 / BUG-21 have the same requirement. Without it, deciding which results to re-run is a manual exercise in reading commit messages.

To be explicit about the alternative that was rejected: keeping the halved cost so the published numbers stay comparable is not an option. A platform whose stated principle is Truth First cannot knowingly keep a wrong number because it is already written down.

🤖 Generated with Claude Code

…BUG-03)

Every backtest cost was exactly half what the advertised A-share model
prescribes. `CostModel.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:

    commission_cost = abs_turnover * commission_rate
    stamp_cost      = abs_turnover * stamp_tax_rate * 0.5

The `* 0.5` only holds for the *two-way* traded value (buys + sells). Both
callers passed one-sided turnover, which is the convention this codebase
documents -- portfolio/constraints.py:16, "Maximum one-sided turnover per
rebalance".

Measured, 30% one-sided turnover, commission 3bp / stamp 10bp / slippage 5bp:

    before  compute_costs(0.30) = 3.90 bp
    after   compute_costs(0.60) = 7.80 bp    ratio 2.0000

Hand-check for the same inputs: commission 0.60*3bp + stamp 0.30*10bp +
slippage 0.60*5bp = 7.80 bp, matching to 1e-12.

A monthly strategy at 30% one-sided turnover was therefore undercharged by
~47 bp/year, which flatters every CAGR, Sharpe, Sortino and net-return figure
this platform has produced.

Fixed at both call sites:

  backtest/engine.py     passes turnover * 2
  backtest/capacity.py   passes turnover * 2

The recorded turnover stays one-sided in both places -- it is a reported
metric, not the base the cost model is fed. Mixing those two roles is what
caused the defect; the comments now say which is which.

Two tests:
  - test_cost_model.py pins the contract itself (two-way in, and one-sided
    input producing exactly half)
  - test_engine.py's rebalance test asserted 999_500, the half-charged figure,
    and now asserts 999_000

1318 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 8103d511-a8ef-4509-a170-42a4987d8f19


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sijie-Z
sijie-Z merged commit 43feac9 into main Sep 13, 2026
9 checks passed
@sijie-Z
sijie-Z deleted the fix/cost-model-two-way branch September 13, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant