Repository navigation
fix(backtest): charge transaction costs on the two-way traded value (BUG-03) - #21
Merged
Merged
Conversation
…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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_costscharges commission and slippage on the value it is given, and halves stamp tax on the assumption that half of that value is sells:The
* 0.5only holds for the two-way traded value (buys + sells). Both callers passed one-sided turnover — which is the convention this codebase documents atportfolio/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:
compute_costs(0.30)compute_costs(0.60)Hand-check for the same inputs: commission
0.60×3bp+ stamp0.30×10bp+ slippage0.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.pybacktest/capacity.pyThe 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.pypins the contract itself: two-way input produces the hand-computed cost, and one-sided input produces exactly half.test_engine.py's rebalance test asserted999_500— the half-charged figure — and now asserts999_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 inREADME.md's milestone table.Per
NO_LOOKAHEAD_CONTRACT.md's own remediation procedure, the prior results should be markedaffected, 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 / supersededfield plusaffected_byon 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