backtest-audit-kit 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backtest_audit_kit-0.2.0/PKG-INFO +219 -0
- backtest_audit_kit-0.2.0/README.md +198 -0
- backtest_audit_kit-0.2.0/auditkit/checks/bust_revival.py +148 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/auditkit/checks/lookahead.py +41 -5
- backtest_audit_kit-0.2.0/auditkit/cli.py +421 -0
- backtest_audit_kit-0.2.0/backtest_audit_kit.egg-info/PKG-INFO +219 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/backtest_audit_kit.egg-info/SOURCES.txt +5 -0
- backtest_audit_kit-0.2.0/backtest_audit_kit.egg-info/entry_points.txt +2 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/pyproject.toml +4 -1
- backtest_audit_kit-0.2.0/tests/test_bust_revival.py +40 -0
- backtest_audit_kit-0.2.0/tests/test_cli.py +83 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/tests/test_lookahead.py +17 -0
- backtest_audit_kit-0.1.0/PKG-INFO +0 -167
- backtest_audit_kit-0.1.0/README.md +0 -146
- backtest_audit_kit-0.1.0/backtest_audit_kit.egg-info/PKG-INFO +0 -167
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/LICENSE +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/auditkit/__init__.py +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/auditkit/checks/__init__.py +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/auditkit/checks/fees.py +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/auditkit/checks/invariants.py +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/auditkit/checks/mutants.py +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/backtest_audit_kit.egg-info/dependency_links.txt +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/backtest_audit_kit.egg-info/requires.txt +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/backtest_audit_kit.egg-info/top_level.txt +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/setup.cfg +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/tests/test_fees.py +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/tests/test_invariants.py +0 -0
- {backtest_audit_kit-0.1.0 → backtest_audit_kit-0.2.0}/tests/test_mutants.py +0 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backtest-audit-kit
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Audit a backtest: look-ahead lint, ledger invariants, mutation coverage, cost checks. Pure standard library.
|
|
5
|
+
Author: rytjy
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rytjy/backtest-audit-kit
|
|
8
|
+
Project-URL: Repository, https://github.com/rytjy/backtest-audit-kit
|
|
9
|
+
Keywords: backtesting,lookahead-bias,audit,trading,quant,mutation-testing,invariants,backtest-overfitting
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# backtest-audit-kit
|
|
23
|
+
|
|
24
|
+
**"I verify whether your strategy is real — or whether the backtest lied to you."**
|
|
25
|
+
|
|
26
|
+
A tiny, dependency-free toolkit that audits a backtest the way an accountant
|
|
27
|
+
audits a ledger and a QA engineer audits a test suite. It does not sell you
|
|
28
|
+
returns; it finds the four ways a backtest most often lies.
|
|
29
|
+
|
|
30
|
+
> **回测审计 / 策略代码复核服务** — 一页说明与联系方式 → **[PORTFOLIO.md](PORTFOLIO.md)**
|
|
31
|
+
> **Backtest audits · strategy-code review** — one-page overview → **[PORTFOLIO.md](PORTFOLIO.md)**
|
|
32
|
+
|
|
33
|
+
## 一条命令(3 分钟)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python3 -m auditkit.cli your.csv --trials 50 # 不用装,纯标准库
|
|
37
|
+
pip install -e . && auditkit your.csv # 或者装上,用 auditkit 命令
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
给它一份交易/净值 CSV(`date` + `equity`,或 `date` + `pnl` / `return`),它只回三件事:
|
|
41
|
+
|
|
42
|
+
| 检查 | 问的问题 | 什么时候红 |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| look-ahead | 时间戳和成交价物理上可能吗 | 时间戳倒退、entry==exit 零持有、入场价落在该 bar 的 high/low 之外 = FAIL |
|
|
45
|
+
| multiple testing | 这个 Sharpe 能和「试了 N 次里最好的那次」分开吗 | 校正后 p ≥ 0.05 = FAIL(`--trials` 必须填你实际搜过的参数组数;在多个时间框架上各调过参数,再加 `--timeframes N`,N_eff = trials × timeframes;不填则标 UNVERIFIED,不冒充通过) |
|
|
46
|
+
| out-of-sample decay | 后半段还剩多少边 | 后半 SR ≤ 0 = FAIL,衰减 ≥ 50% = WARN |
|
|
47
|
+
|
|
48
|
+
多尺度搜索会单独标一行:同一策略在 5 个时间框架上各调 100 组 = **N_eff 500**,门槛按 500 算;若按 100 会判 PASS、按 500 判 FAIL,会额外出一条 WARN 说明膨胀。
|
|
49
|
+
|
|
50
|
+
退出码:`0` 全过 · `1` 有 FAIL · `2` 有缺输入判不了的项。加 `--ohlc bars.csv`(date, high, low)才能判价格级前视;`--json` 给机器读;`--split 0.6` 改样本内比例。
|
|
51
|
+
|
|
52
|
+
### One command (3 minutes)
|
|
53
|
+
|
|
54
|
+
`python3 -m auditkit.cli your.csv --trials 50` prints three verdicts — look-ahead,
|
|
55
|
+
multiple-testing (deflated Sharpe), out-of-sample decay — from a plain CSV
|
|
56
|
+
(`date` + `equity`, or `pnl` / `return`). Exit code 0 = pass, 1 = FAIL, 2 = needs input.
|
|
57
|
+
Without `--trials` the multiple-testing line says UNVERIFIED instead of inventing a pass.
|
|
58
|
+
Tuned the same grid on several time frames? Add `--timeframes N` — effective trials become
|
|
59
|
+
`trials × N`, and the tool prints the inflated N and, when it flips the verdict, a WARN line.
|
|
60
|
+
`--ohlc bars.csv` unlocks the price-level look-ahead check.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## English
|
|
65
|
+
|
|
66
|
+
**What it is.** A pure standard-library (Python 3.9+) kit of four independent
|
|
67
|
+
checkers for a trading backtest:
|
|
68
|
+
|
|
69
|
+
| Checker | Question it answers |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `checks/lookahead.py` | Does any decision on bar `i` see bars `i+1...`? (AST-based future-function lint) |
|
|
72
|
+
| `checks/invariants.py` | Does the ledger obey accounting invariants? (equity identity, `wd_cum` carry, monotonic counters, non-negative balances) |
|
|
73
|
+
| `checks/mutants.py` | Which bugs would your test suite *not* catch? (mutation testing; survivors = coverage blind spots) |
|
|
74
|
+
| `checks/fees.py` | Are fees two-sided, funding charged per bar, and slippage counted once per fill? |
|
|
75
|
+
| `checks/bust_revival.py` | After a ruin check, does the account get reset to 1% and keep trading? (see `cases/case10_bust_revival.md`) |
|
|
76
|
+
|
|
77
|
+
**Who it is for.** Quant developers, prop-desk researchers, and anyone who has
|
|
78
|
+
ever shipped a strategy that looked great in a notebook and died in
|
|
79
|
+
production. If you are reviewing someone else's backtest — or your own — this
|
|
80
|
+
gives you a checklist, reproduction scripts, and before/after numbers instead
|
|
81
|
+
of opinions.
|
|
82
|
+
|
|
83
|
+
**What I deliver.** ① A ranked findings list plus a runnable reproduction
|
|
84
|
+
script for each problem. ② A minimal fix patch. ③ A before/after reconciliation
|
|
85
|
+
of the headline numbers (win rate, net PnL, multiple, MDD, t-stat).
|
|
86
|
+
|
|
87
|
+
**Three sample cases** — this page summarizes three of the eleven write-ups in `cases/`:
|
|
88
|
+
|
|
89
|
+
1. **Look-ahead** — the exit scan ran on the entry bar, so every signal knew
|
|
90
|
+
its outcome. Fixed: 91 trades / 90.1% / **+$214.37** → 26 trades / 65.4% /
|
|
91
|
+
**-$0.12**; a tick-level re-run gives 27 trades / 66.7% / **+$0.37**. The
|
|
92
|
+
leak was worth **≈ $214** of imaginary PnL — the whole profit.
|
|
93
|
+
2. **Withdrawal accounting** — the bust path dropped `wd_cum` (10 return
|
|
94
|
+
sites). It only fires when a run busts: at lev 1.2 the published config never
|
|
95
|
+
busts, so 7.82x → **7.82x**; at lev 5 it busts once and 0.00x → **5.97x**.
|
|
96
|
+
The ~27x gap between 215.27x (no withdrawal) and 7.82x (double-keep-half) is
|
|
97
|
+
**withdrawal policy, not the bug** — an earlier version of this page paired
|
|
98
|
+
those two and mis-attributed it.
|
|
99
|
+
3. **Drawdown metric** — the circuit-breaker reset the running peak, so the
|
|
100
|
+
"reported MDD" was only the local drawdown. Same run, same config:
|
|
101
|
+
investor-view raw drawdown **98.7%** vs reported **53.1%** — a **45.6 pp**
|
|
102
|
+
gap. Conclusion: MDD must be written twice (breaker-local *and* raw).
|
|
103
|
+
(*Bonus case 4:* 5-minute bar clustering inflated `n` ~11x and turned
|
|
104
|
+
t = 15.60 into t = 1.75 after a 24h cooldown.)
|
|
105
|
+
|
|
106
|
+
**Two public cases, both answered and followed by a change** — threads outside this repo; one-page PDF: [audits/2026-11-01-two-public-cases.pdf](audits/2026-11-01-two-public-cases.pdf).
|
|
107
|
+
|
|
108
|
+
| Thread | The number I questioned | What happened |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| [mr-scrooge-v6 #9](https://github.com/BrockStar3540/mr-scrooge-v6/issues/9) | -$1,308.74 over 1,041 trades (t ~ -3.0) | accepted; ratchet re-tightened, config bug fixed (B-142 to B-145) |
|
|
111
|
+
| [pineforge-engine #664](https://github.com/pineforge-4pass/pineforge-engine/issues/664) | 341 of 7,989 probes graded at a 100x wider PnL tolerance | accepted; regrade of all 7,989 due 2026-11-04 |
|
|
112
|
+
|
|
113
|
+
Full write-up: [audits/2026-11-01-two-public-cases.md](audits/2026-11-01-two-public-cases.md). Not paid work, and not a track record: 23 public threads opened, 7 answered (30%), 0 paid.
|
|
114
|
+
|
|
115
|
+
**What a deliverable looks like** - [audits/2026-11-01-sample-verification-report.pdf](audits/2026-11-01-sample-verification-report.pdf). One page, on a public live record recomputed from its own daily file: six numbers side by side, two convention gaps explained, one command to re-run. That result was *no error found* - which is also a result.
|
|
116
|
+
|
|
117
|
+
**Install.**
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install backtest-audit-kit
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Use it in code** (four checkers, pure standard library):
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from auditkit.checks import lookahead
|
|
127
|
+
findings = lookahead.detect_file("strategy.py") # future-data lint
|
|
128
|
+
print(lookahead.format_findings(findings))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**How to run.**
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
cd portfolio/backtest-audit-kit
|
|
135
|
+
python3 -m unittest discover tests # stdlib only, no install
|
|
136
|
+
# or, if you have pytest:
|
|
137
|
+
python3 -m pytest tests/ -q
|
|
138
|
+
python3 examples/run_demo.py # see all four checkers in action
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Honesty note.** The look-ahead checker is a heuristic linter, not a proof
|
|
142
|
+
engine: it flags the *shapes* that leak, and a stand-alone forward-simulation
|
|
143
|
+
pass is reported `MEDIUM` for human review rather than being declared safe.
|
|
144
|
+
Mutation testing reports blind spots; it never claims your strategy is
|
|
145
|
+
profitable. No checker here predicts returns.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## 中文
|
|
150
|
+
|
|
151
|
+
**是什么。** 一个纯 Python 标准库(3.9+)实现的回测审计工具包,四个互相独立
|
|
152
|
+
的检查器:
|
|
153
|
+
|
|
154
|
+
| 检查器 | 回答的问题 |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `checks/lookahead.py` | 在 bar `i` 上的决策,是否读到了 `i+1...` 的数据?(基于 AST 的前视/未来函数检测) |
|
|
157
|
+
| `checks/invariants.py` | 账本是否满足会计不变量?(权益恒等式、`wd_cum` 结转、计数器单调、余额非负) |
|
|
158
|
+
| `checks/mutants.py` | 你的测试套件**抓不到**哪些 bug?(变异测试;存活的变异体 = 覆盖盲区) |
|
|
159
|
+
| `checks/fees.py` | 手续费是否双边计、资金费是否逐 bar 计、滑点是否每笔只算一次? |
|
|
160
|
+
| `checks/bust_revival.py` | 爆仓检查之后,账户是否被重置成 1% 继续交易?(见 `cases/case10_bust_revival.md`) |
|
|
161
|
+
|
|
162
|
+
**给谁用。** 量化开发者、自营研究岗、以及任何"回测很美、实盘就死"过的
|
|
163
|
+
人。带看别人的回测(或自己的)时,它给你的是**问题清单 + 复现脚本 + 修复前后
|
|
164
|
+
的数字**,而不是一句"我觉得不太行"。
|
|
165
|
+
|
|
166
|
+
**交付物承诺。** ① 按严重度排序的问题清单,每个问题配一个可运行的复现脚本。
|
|
167
|
+
② 最小修复补丁。③ 修复前后的对账数字(胜率 / 净利 / 倍数 / 回撤 / t 值)。
|
|
168
|
+
|
|
169
|
+
**三个案例摘要**(本页只摘三份;`cases/` 里另有完整 11 份):
|
|
170
|
+
|
|
171
|
+
1. **前视 bug** —— 出场扫描写在开仓 bar 的循环内,每个信号都提前知道结果。
|
|
172
|
+
修复后:91 笔 / 90.1% / **+$214.37** → 26 笔 / 65.4% / **-$0.12**;tick 级
|
|
173
|
+
复跑得 27 笔 / 66.7% / **+$0.37**。这个泄漏值 **约 $214 的虚假收益**,
|
|
174
|
+
即全部利润。
|
|
175
|
+
2. **提现会计 bug** —— 爆仓返程丢了 `wd_cum`(引擎里有 10 处这样的 return)。
|
|
176
|
+
它**只在爆仓时触发**:lev 1.2 的公开配置全程 0 爆仓 ⇒ **7.82x → 7.82x**,
|
|
177
|
+
一分不变;lev 5 爆仓一次 ⇒ **0.00x → 5.97x**。215.27x 与 7.82x 之间那 27 倍
|
|
178
|
+
是**提现规则本身的代价**(不提现 vs 翻 2 留 1),不是 bug —— 早先这一页把
|
|
179
|
+
两个配置配成了一对,归因是错的,已改。
|
|
180
|
+
3. **回撤口径 bug** —— 熔断触发时把 peak 重置为 cap,"报告 MDD"只是每段熔断
|
|
181
|
+
后的局部回撤。**同一次 run、同一配置**:投资者口径 **98.7%** vs 报告
|
|
182
|
+
**53.1%**,**差 45.6 个百分点**。结论:MDD 必须双写(熔断口径 + 原始口径)。
|
|
183
|
+
(**附赠案例 4**:一段暴跌连打十几根 5min bar 让样本量虚增约 11 倍,t 值从
|
|
184
|
+
15.60 在 24h 冷却去重后掉到 1.75。)
|
|
185
|
+
|
|
186
|
+
**两个公开案例(都在本仓库之外)** —— 对方认账并改了代码;一页 PDF:[audits/2026-11-01-two-public-cases.pdf](audits/2026-11-01-two-public-cases.pdf)。
|
|
187
|
+
|
|
188
|
+
| 帖子 | 我质疑的数字 | 结果 |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| [mr-scrooge-v6 #9](https://github.com/BrockStar3540/mr-scrooge-v6/issues/9) | 1,041 笔合计 -$1,308.74(t ≈ -3.0) | 认账;收紧 ratchet,修掉配置加载 bug(B-142 至 B-145) |
|
|
191
|
+
| [pineforge-engine #664](https://github.com/pineforge-4pass/pineforge-engine/issues/664) | 7,989 个 probe 里 341 个按宽 100 倍的 PnL 容差评分 | 认账;承诺 2026-11-04 前重跑全部 7,989 |
|
|
192
|
+
|
|
193
|
+
完整文字:[audits/2026-11-01-two-public-cases.md](audits/2026-11-01-two-public-cases.md)。不是付费工作,也不是战绩:公开 23 条,7 条有回复(30%),付费 0。
|
|
194
|
+
|
|
195
|
+
**第三份,性质不同:读源码。** [APEX V2 [JOAT]](https://www.tradingview.com/script/b4EWZCPJ-APEX-V2-JOAT/)(开源 Pine,作者 JOAT)——不跑、不看净值,只把描述里的每个数字对回代码,查出六处对不上:面板上的 `8.0σ` 其实是 12 个标准差(分母带 `* 1.5`);confluence 分母 9 但输入上限 7;九个票里有俩测的是同一个 VWAP 偏离;`Enable Trailing Stop` 默认开着却永远触发不了(`limit` 和 `trail_price` 同价);`Risk Per Trade %` 算完没用(仓位走 `default_qty_value=100`);`strategy()` 无手续费无滑点。每条都带行号,附 `sed` 复现命令:[`audits/2026-11-01-apex-v2-code-audit.md`](audits/2026-11-01-apex-v2-code-audit.md)。**没有对方回复** —— TradingView 脚本页没有评论区(页面 706KB 里 `comment` 只出现两次,全是数据字段;相关 API 全 404),所以这份是"结论"而不是"回合"。
|
|
196
|
+
|
|
197
|
+
**交付物长什么样** —— [audits/2026-11-01-sample-verification-report.pdf](audits/2026-11-01-sample-verification-report.pdf)。一页纸,靶子是一份公开的实盘记录,用他本人发布的逐日文件复算:六个数字逐行对照,两处口径差异讲清楚,附一条可重跑的命令。那次结论是「未发现错误」——这也是结果。
|
|
198
|
+
|
|
199
|
+
**怎么跑。**
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
cd portfolio/backtest-audit-kit
|
|
203
|
+
python3 -m unittest discover tests # 只用标准库,无需安装
|
|
204
|
+
# 若装了 pytest:
|
|
205
|
+
python3 -m pytest tests/ -q
|
|
206
|
+
python3 examples/run_demo.py # 四个检查器全跑一遍
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**诚实声明。** 前视检查器是启发式 linter,不是证明器:它抓的是会泄漏的**代码
|
|
210
|
+
形态**,独立的"向前模拟"循环会被标为 `MEDIUM` 交人工复核,而不是被判安全。
|
|
211
|
+
变异测试只报告覆盖盲区,从不声称你的策略赚钱。本工具包不预测任何收益。
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Also in this account · 另见
|
|
216
|
+
|
|
217
|
+
- [`outpost/`](./outpost/) — 一个"只放能跑、能查、能被否证的东西"的据点:
|
|
218
|
+
单文件表格整理工具(零依赖、离线自检 14 项)+ 一份**被自己推翻**的回测(做空山寨无 edge)
|
|
219
|
+
+ 一次"两条腿都断了"的数据源修复。不写客户名、不写"我能做什么"。
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# backtest-audit-kit
|
|
2
|
+
|
|
3
|
+
**"I verify whether your strategy is real — or whether the backtest lied to you."**
|
|
4
|
+
|
|
5
|
+
A tiny, dependency-free toolkit that audits a backtest the way an accountant
|
|
6
|
+
audits a ledger and a QA engineer audits a test suite. It does not sell you
|
|
7
|
+
returns; it finds the four ways a backtest most often lies.
|
|
8
|
+
|
|
9
|
+
> **回测审计 / 策略代码复核服务** — 一页说明与联系方式 → **[PORTFOLIO.md](PORTFOLIO.md)**
|
|
10
|
+
> **Backtest audits · strategy-code review** — one-page overview → **[PORTFOLIO.md](PORTFOLIO.md)**
|
|
11
|
+
|
|
12
|
+
## 一条命令(3 分钟)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python3 -m auditkit.cli your.csv --trials 50 # 不用装,纯标准库
|
|
16
|
+
pip install -e . && auditkit your.csv # 或者装上,用 auditkit 命令
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
给它一份交易/净值 CSV(`date` + `equity`,或 `date` + `pnl` / `return`),它只回三件事:
|
|
20
|
+
|
|
21
|
+
| 检查 | 问的问题 | 什么时候红 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| look-ahead | 时间戳和成交价物理上可能吗 | 时间戳倒退、entry==exit 零持有、入场价落在该 bar 的 high/low 之外 = FAIL |
|
|
24
|
+
| multiple testing | 这个 Sharpe 能和「试了 N 次里最好的那次」分开吗 | 校正后 p ≥ 0.05 = FAIL(`--trials` 必须填你实际搜过的参数组数;在多个时间框架上各调过参数,再加 `--timeframes N`,N_eff = trials × timeframes;不填则标 UNVERIFIED,不冒充通过) |
|
|
25
|
+
| out-of-sample decay | 后半段还剩多少边 | 后半 SR ≤ 0 = FAIL,衰减 ≥ 50% = WARN |
|
|
26
|
+
|
|
27
|
+
多尺度搜索会单独标一行:同一策略在 5 个时间框架上各调 100 组 = **N_eff 500**,门槛按 500 算;若按 100 会判 PASS、按 500 判 FAIL,会额外出一条 WARN 说明膨胀。
|
|
28
|
+
|
|
29
|
+
退出码:`0` 全过 · `1` 有 FAIL · `2` 有缺输入判不了的项。加 `--ohlc bars.csv`(date, high, low)才能判价格级前视;`--json` 给机器读;`--split 0.6` 改样本内比例。
|
|
30
|
+
|
|
31
|
+
### One command (3 minutes)
|
|
32
|
+
|
|
33
|
+
`python3 -m auditkit.cli your.csv --trials 50` prints three verdicts — look-ahead,
|
|
34
|
+
multiple-testing (deflated Sharpe), out-of-sample decay — from a plain CSV
|
|
35
|
+
(`date` + `equity`, or `pnl` / `return`). Exit code 0 = pass, 1 = FAIL, 2 = needs input.
|
|
36
|
+
Without `--trials` the multiple-testing line says UNVERIFIED instead of inventing a pass.
|
|
37
|
+
Tuned the same grid on several time frames? Add `--timeframes N` — effective trials become
|
|
38
|
+
`trials × N`, and the tool prints the inflated N and, when it flips the verdict, a WARN line.
|
|
39
|
+
`--ohlc bars.csv` unlocks the price-level look-ahead check.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## English
|
|
44
|
+
|
|
45
|
+
**What it is.** A pure standard-library (Python 3.9+) kit of four independent
|
|
46
|
+
checkers for a trading backtest:
|
|
47
|
+
|
|
48
|
+
| Checker | Question it answers |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `checks/lookahead.py` | Does any decision on bar `i` see bars `i+1...`? (AST-based future-function lint) |
|
|
51
|
+
| `checks/invariants.py` | Does the ledger obey accounting invariants? (equity identity, `wd_cum` carry, monotonic counters, non-negative balances) |
|
|
52
|
+
| `checks/mutants.py` | Which bugs would your test suite *not* catch? (mutation testing; survivors = coverage blind spots) |
|
|
53
|
+
| `checks/fees.py` | Are fees two-sided, funding charged per bar, and slippage counted once per fill? |
|
|
54
|
+
| `checks/bust_revival.py` | After a ruin check, does the account get reset to 1% and keep trading? (see `cases/case10_bust_revival.md`) |
|
|
55
|
+
|
|
56
|
+
**Who it is for.** Quant developers, prop-desk researchers, and anyone who has
|
|
57
|
+
ever shipped a strategy that looked great in a notebook and died in
|
|
58
|
+
production. If you are reviewing someone else's backtest — or your own — this
|
|
59
|
+
gives you a checklist, reproduction scripts, and before/after numbers instead
|
|
60
|
+
of opinions.
|
|
61
|
+
|
|
62
|
+
**What I deliver.** ① A ranked findings list plus a runnable reproduction
|
|
63
|
+
script for each problem. ② A minimal fix patch. ③ A before/after reconciliation
|
|
64
|
+
of the headline numbers (win rate, net PnL, multiple, MDD, t-stat).
|
|
65
|
+
|
|
66
|
+
**Three sample cases** — this page summarizes three of the eleven write-ups in `cases/`:
|
|
67
|
+
|
|
68
|
+
1. **Look-ahead** — the exit scan ran on the entry bar, so every signal knew
|
|
69
|
+
its outcome. Fixed: 91 trades / 90.1% / **+$214.37** → 26 trades / 65.4% /
|
|
70
|
+
**-$0.12**; a tick-level re-run gives 27 trades / 66.7% / **+$0.37**. The
|
|
71
|
+
leak was worth **≈ $214** of imaginary PnL — the whole profit.
|
|
72
|
+
2. **Withdrawal accounting** — the bust path dropped `wd_cum` (10 return
|
|
73
|
+
sites). It only fires when a run busts: at lev 1.2 the published config never
|
|
74
|
+
busts, so 7.82x → **7.82x**; at lev 5 it busts once and 0.00x → **5.97x**.
|
|
75
|
+
The ~27x gap between 215.27x (no withdrawal) and 7.82x (double-keep-half) is
|
|
76
|
+
**withdrawal policy, not the bug** — an earlier version of this page paired
|
|
77
|
+
those two and mis-attributed it.
|
|
78
|
+
3. **Drawdown metric** — the circuit-breaker reset the running peak, so the
|
|
79
|
+
"reported MDD" was only the local drawdown. Same run, same config:
|
|
80
|
+
investor-view raw drawdown **98.7%** vs reported **53.1%** — a **45.6 pp**
|
|
81
|
+
gap. Conclusion: MDD must be written twice (breaker-local *and* raw).
|
|
82
|
+
(*Bonus case 4:* 5-minute bar clustering inflated `n` ~11x and turned
|
|
83
|
+
t = 15.60 into t = 1.75 after a 24h cooldown.)
|
|
84
|
+
|
|
85
|
+
**Two public cases, both answered and followed by a change** — threads outside this repo; one-page PDF: [audits/2026-11-01-two-public-cases.pdf](audits/2026-11-01-two-public-cases.pdf).
|
|
86
|
+
|
|
87
|
+
| Thread | The number I questioned | What happened |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| [mr-scrooge-v6 #9](https://github.com/BrockStar3540/mr-scrooge-v6/issues/9) | -$1,308.74 over 1,041 trades (t ~ -3.0) | accepted; ratchet re-tightened, config bug fixed (B-142 to B-145) |
|
|
90
|
+
| [pineforge-engine #664](https://github.com/pineforge-4pass/pineforge-engine/issues/664) | 341 of 7,989 probes graded at a 100x wider PnL tolerance | accepted; regrade of all 7,989 due 2026-11-04 |
|
|
91
|
+
|
|
92
|
+
Full write-up: [audits/2026-11-01-two-public-cases.md](audits/2026-11-01-two-public-cases.md). Not paid work, and not a track record: 23 public threads opened, 7 answered (30%), 0 paid.
|
|
93
|
+
|
|
94
|
+
**What a deliverable looks like** - [audits/2026-11-01-sample-verification-report.pdf](audits/2026-11-01-sample-verification-report.pdf). One page, on a public live record recomputed from its own daily file: six numbers side by side, two convention gaps explained, one command to re-run. That result was *no error found* - which is also a result.
|
|
95
|
+
|
|
96
|
+
**Install.**
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install backtest-audit-kit
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Use it in code** (four checkers, pure standard library):
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from auditkit.checks import lookahead
|
|
106
|
+
findings = lookahead.detect_file("strategy.py") # future-data lint
|
|
107
|
+
print(lookahead.format_findings(findings))
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**How to run.**
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd portfolio/backtest-audit-kit
|
|
114
|
+
python3 -m unittest discover tests # stdlib only, no install
|
|
115
|
+
# or, if you have pytest:
|
|
116
|
+
python3 -m pytest tests/ -q
|
|
117
|
+
python3 examples/run_demo.py # see all four checkers in action
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Honesty note.** The look-ahead checker is a heuristic linter, not a proof
|
|
121
|
+
engine: it flags the *shapes* that leak, and a stand-alone forward-simulation
|
|
122
|
+
pass is reported `MEDIUM` for human review rather than being declared safe.
|
|
123
|
+
Mutation testing reports blind spots; it never claims your strategy is
|
|
124
|
+
profitable. No checker here predicts returns.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 中文
|
|
129
|
+
|
|
130
|
+
**是什么。** 一个纯 Python 标准库(3.9+)实现的回测审计工具包,四个互相独立
|
|
131
|
+
的检查器:
|
|
132
|
+
|
|
133
|
+
| 检查器 | 回答的问题 |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `checks/lookahead.py` | 在 bar `i` 上的决策,是否读到了 `i+1...` 的数据?(基于 AST 的前视/未来函数检测) |
|
|
136
|
+
| `checks/invariants.py` | 账本是否满足会计不变量?(权益恒等式、`wd_cum` 结转、计数器单调、余额非负) |
|
|
137
|
+
| `checks/mutants.py` | 你的测试套件**抓不到**哪些 bug?(变异测试;存活的变异体 = 覆盖盲区) |
|
|
138
|
+
| `checks/fees.py` | 手续费是否双边计、资金费是否逐 bar 计、滑点是否每笔只算一次? |
|
|
139
|
+
| `checks/bust_revival.py` | 爆仓检查之后,账户是否被重置成 1% 继续交易?(见 `cases/case10_bust_revival.md`) |
|
|
140
|
+
|
|
141
|
+
**给谁用。** 量化开发者、自营研究岗、以及任何"回测很美、实盘就死"过的
|
|
142
|
+
人。带看别人的回测(或自己的)时,它给你的是**问题清单 + 复现脚本 + 修复前后
|
|
143
|
+
的数字**,而不是一句"我觉得不太行"。
|
|
144
|
+
|
|
145
|
+
**交付物承诺。** ① 按严重度排序的问题清单,每个问题配一个可运行的复现脚本。
|
|
146
|
+
② 最小修复补丁。③ 修复前后的对账数字(胜率 / 净利 / 倍数 / 回撤 / t 值)。
|
|
147
|
+
|
|
148
|
+
**三个案例摘要**(本页只摘三份;`cases/` 里另有完整 11 份):
|
|
149
|
+
|
|
150
|
+
1. **前视 bug** —— 出场扫描写在开仓 bar 的循环内,每个信号都提前知道结果。
|
|
151
|
+
修复后:91 笔 / 90.1% / **+$214.37** → 26 笔 / 65.4% / **-$0.12**;tick 级
|
|
152
|
+
复跑得 27 笔 / 66.7% / **+$0.37**。这个泄漏值 **约 $214 的虚假收益**,
|
|
153
|
+
即全部利润。
|
|
154
|
+
2. **提现会计 bug** —— 爆仓返程丢了 `wd_cum`(引擎里有 10 处这样的 return)。
|
|
155
|
+
它**只在爆仓时触发**:lev 1.2 的公开配置全程 0 爆仓 ⇒ **7.82x → 7.82x**,
|
|
156
|
+
一分不变;lev 5 爆仓一次 ⇒ **0.00x → 5.97x**。215.27x 与 7.82x 之间那 27 倍
|
|
157
|
+
是**提现规则本身的代价**(不提现 vs 翻 2 留 1),不是 bug —— 早先这一页把
|
|
158
|
+
两个配置配成了一对,归因是错的,已改。
|
|
159
|
+
3. **回撤口径 bug** —— 熔断触发时把 peak 重置为 cap,"报告 MDD"只是每段熔断
|
|
160
|
+
后的局部回撤。**同一次 run、同一配置**:投资者口径 **98.7%** vs 报告
|
|
161
|
+
**53.1%**,**差 45.6 个百分点**。结论:MDD 必须双写(熔断口径 + 原始口径)。
|
|
162
|
+
(**附赠案例 4**:一段暴跌连打十几根 5min bar 让样本量虚增约 11 倍,t 值从
|
|
163
|
+
15.60 在 24h 冷却去重后掉到 1.75。)
|
|
164
|
+
|
|
165
|
+
**两个公开案例(都在本仓库之外)** —— 对方认账并改了代码;一页 PDF:[audits/2026-11-01-two-public-cases.pdf](audits/2026-11-01-two-public-cases.pdf)。
|
|
166
|
+
|
|
167
|
+
| 帖子 | 我质疑的数字 | 结果 |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| [mr-scrooge-v6 #9](https://github.com/BrockStar3540/mr-scrooge-v6/issues/9) | 1,041 笔合计 -$1,308.74(t ≈ -3.0) | 认账;收紧 ratchet,修掉配置加载 bug(B-142 至 B-145) |
|
|
170
|
+
| [pineforge-engine #664](https://github.com/pineforge-4pass/pineforge-engine/issues/664) | 7,989 个 probe 里 341 个按宽 100 倍的 PnL 容差评分 | 认账;承诺 2026-11-04 前重跑全部 7,989 |
|
|
171
|
+
|
|
172
|
+
完整文字:[audits/2026-11-01-two-public-cases.md](audits/2026-11-01-two-public-cases.md)。不是付费工作,也不是战绩:公开 23 条,7 条有回复(30%),付费 0。
|
|
173
|
+
|
|
174
|
+
**第三份,性质不同:读源码。** [APEX V2 [JOAT]](https://www.tradingview.com/script/b4EWZCPJ-APEX-V2-JOAT/)(开源 Pine,作者 JOAT)——不跑、不看净值,只把描述里的每个数字对回代码,查出六处对不上:面板上的 `8.0σ` 其实是 12 个标准差(分母带 `* 1.5`);confluence 分母 9 但输入上限 7;九个票里有俩测的是同一个 VWAP 偏离;`Enable Trailing Stop` 默认开着却永远触发不了(`limit` 和 `trail_price` 同价);`Risk Per Trade %` 算完没用(仓位走 `default_qty_value=100`);`strategy()` 无手续费无滑点。每条都带行号,附 `sed` 复现命令:[`audits/2026-11-01-apex-v2-code-audit.md`](audits/2026-11-01-apex-v2-code-audit.md)。**没有对方回复** —— TradingView 脚本页没有评论区(页面 706KB 里 `comment` 只出现两次,全是数据字段;相关 API 全 404),所以这份是"结论"而不是"回合"。
|
|
175
|
+
|
|
176
|
+
**交付物长什么样** —— [audits/2026-11-01-sample-verification-report.pdf](audits/2026-11-01-sample-verification-report.pdf)。一页纸,靶子是一份公开的实盘记录,用他本人发布的逐日文件复算:六个数字逐行对照,两处口径差异讲清楚,附一条可重跑的命令。那次结论是「未发现错误」——这也是结果。
|
|
177
|
+
|
|
178
|
+
**怎么跑。**
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
cd portfolio/backtest-audit-kit
|
|
182
|
+
python3 -m unittest discover tests # 只用标准库,无需安装
|
|
183
|
+
# 若装了 pytest:
|
|
184
|
+
python3 -m pytest tests/ -q
|
|
185
|
+
python3 examples/run_demo.py # 四个检查器全跑一遍
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**诚实声明。** 前视检查器是启发式 linter,不是证明器:它抓的是会泄漏的**代码
|
|
189
|
+
形态**,独立的"向前模拟"循环会被标为 `MEDIUM` 交人工复核,而不是被判安全。
|
|
190
|
+
变异测试只报告覆盖盲区,从不声称你的策略赚钱。本工具包不预测任何收益。
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Also in this account · 另见
|
|
195
|
+
|
|
196
|
+
- [`outpost/`](./outpost/) — 一个"只放能跑、能查、能被否证的东西"的据点:
|
|
197
|
+
单文件表格整理工具(零依赖、离线自检 14 项)+ 一份**被自己推翻**的回测(做空山寨无 edge)
|
|
198
|
+
+ 一次"两条腿都断了"的数据源修复。不写客户名、不写"我能做什么"。
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Bust-revival detection.
|
|
2
|
+
|
|
3
|
+
A *bust revival* is code that, after declaring an account ruined
|
|
4
|
+
("if cap <= 0"), resets the account to a tiny positive constant
|
|
5
|
+
("cap = 0.01") and keeps compounding. The backtest then reports a
|
|
6
|
+
survivor that never existed: in a leveraged path the losing trade is
|
|
7
|
+
supposed to end the account, not hand it 1% of the starting capital.
|
|
8
|
+
|
|
9
|
+
Two shapes are flagged:
|
|
10
|
+
|
|
11
|
+
REVIVAL_ASSIGN (HIGH)
|
|
12
|
+
if cap <= 0: cap = 0.01 -- an explicit reset inside a ruin check.
|
|
13
|
+
HIGH when the variable is compounded (multiplied) in the same
|
|
14
|
+
function; MEDIUM when it is not.
|
|
15
|
+
|
|
16
|
+
REVIVAL_CLAMP (MEDIUM)
|
|
17
|
+
cap = max(cap * (1 + r), 0.01) -- a floor applied on every step.
|
|
18
|
+
A floor of 1e-9 is a division guard and is not flagged; the
|
|
19
|
+
threshold is 0.1 so only percent-scale revivals match.
|
|
20
|
+
|
|
21
|
+
This is a heuristic linter. It cannot tell a deliberate unit floor from a
|
|
22
|
+
ruin reset; it points a human at the line.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import List, Optional, Sequence
|
|
31
|
+
|
|
32
|
+
__all__ = ["Finding", "detect", "detect_file", "format_findings", "SEVERITY_ORDER"]
|
|
33
|
+
|
|
34
|
+
SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3}
|
|
35
|
+
REVIVAL_MAX = 0.1 # constants above this are units, not 1% revivals
|
|
36
|
+
RUIN_OPS = (ast.LtE, ast.Lt, ast.Eq)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Finding:
|
|
41
|
+
rule: str
|
|
42
|
+
severity: str
|
|
43
|
+
lineno: int
|
|
44
|
+
message: str
|
|
45
|
+
snippet: str
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return f"{self.severity:6s} L{self.lineno:<5d} {self.rule:14s} {self.message} | {self.snippet}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _const(node: ast.AST) -> Optional[float]:
|
|
52
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
|
53
|
+
return float(node.value)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ruin_name(test: ast.AST) -> Optional[str]:
|
|
58
|
+
"""Return the variable compared against ~zero in a ruin check."""
|
|
59
|
+
if not isinstance(test, ast.Compare):
|
|
60
|
+
return None
|
|
61
|
+
if not any(isinstance(op, RUIN_OPS) for op in test.ops):
|
|
62
|
+
return None
|
|
63
|
+
names = [n.id for n in [test.left, *test.comparators] if isinstance(n, ast.Name)]
|
|
64
|
+
consts = [_const(c) for c in test.comparators]
|
|
65
|
+
if not names:
|
|
66
|
+
return None
|
|
67
|
+
if any(c is not None and 0 <= c <= 0.1 for c in consts):
|
|
68
|
+
return names[0]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Visitor(ast.NodeVisitor):
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self.findings: List[Finding] = []
|
|
75
|
+
self._loop_depth = 0
|
|
76
|
+
self._func_stack: List[set] = []
|
|
77
|
+
|
|
78
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
79
|
+
compounded = set()
|
|
80
|
+
for sub in ast.walk(node):
|
|
81
|
+
if isinstance(sub, ast.BinOp) and isinstance(sub.op, ast.Mult):
|
|
82
|
+
for side in (sub.left, sub.right):
|
|
83
|
+
if isinstance(side, ast.Name):
|
|
84
|
+
compounded.add(side.id)
|
|
85
|
+
self._func_stack.append(compounded)
|
|
86
|
+
self.generic_visit(node)
|
|
87
|
+
self._func_stack.pop()
|
|
88
|
+
|
|
89
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
90
|
+
|
|
91
|
+
def visit_For(self, node: ast.For) -> None:
|
|
92
|
+
self._loop_depth += 1
|
|
93
|
+
self.generic_visit(node)
|
|
94
|
+
self._loop_depth -= 1
|
|
95
|
+
|
|
96
|
+
visit_While = visit_For
|
|
97
|
+
|
|
98
|
+
def _compounded(self, name: str) -> bool:
|
|
99
|
+
return bool(self._func_stack) and name in self._func_stack[-1]
|
|
100
|
+
|
|
101
|
+
def visit_If(self, node: ast.If) -> None:
|
|
102
|
+
name = _ruin_name(node.test)
|
|
103
|
+
if name:
|
|
104
|
+
for stmt in node.body:
|
|
105
|
+
c = None
|
|
106
|
+
if isinstance(stmt, ast.Assign) and any(isinstance(t, ast.Name) and t.id == name for t in stmt.targets):
|
|
107
|
+
c = _const(stmt.value)
|
|
108
|
+
elif isinstance(stmt, ast.AugAssign) and isinstance(stmt.target, ast.Name) and stmt.target.id == name:
|
|
109
|
+
c = _const(stmt.value)
|
|
110
|
+
if c is not None and 0 < c <= REVIVAL_MAX:
|
|
111
|
+
sev = "HIGH" if (self._loop_depth > 0 and self._compounded(name)) else "MEDIUM"
|
|
112
|
+
self.findings.append(Finding(
|
|
113
|
+
rule="REVIVAL_ASSIGN", severity=sev, lineno=stmt.lineno,
|
|
114
|
+
message=f"ruin check resets {name!r} to {c:g} and keeps trading",
|
|
115
|
+
snippet=ast.unparse(stmt)))
|
|
116
|
+
self.generic_visit(node)
|
|
117
|
+
|
|
118
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
119
|
+
if isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name) and node.value.func.id == "max":
|
|
120
|
+
args = node.value.args
|
|
121
|
+
if len(args) >= 2:
|
|
122
|
+
c = _const(args[-1])
|
|
123
|
+
target = node.targets[0]
|
|
124
|
+
if c is not None and 0 < c <= REVIVAL_MAX and isinstance(target, ast.Name):
|
|
125
|
+
sev = "HIGH" if (self._loop_depth > 0 and self._compounded(target.id)) else "MEDIUM"
|
|
126
|
+
self.findings.append(Finding(
|
|
127
|
+
rule="REVIVAL_CLAMP", severity=sev, lineno=node.lineno,
|
|
128
|
+
message=f"{target.id!r} is floored at {c:g} on every step",
|
|
129
|
+
snippet=ast.unparse(node)))
|
|
130
|
+
self.generic_visit(node)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def detect(source: str, filename: str = "<string>") -> List[Finding]:
|
|
134
|
+
tree = ast.parse(source, filename=filename)
|
|
135
|
+
v = _Visitor()
|
|
136
|
+
v.visit(tree)
|
|
137
|
+
return sorted(v.findings, key=lambda f: (f.lineno, f.rule))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def detect_file(path: str | Path) -> List[Finding]:
|
|
141
|
+
p = Path(path)
|
|
142
|
+
return detect(p.read_text(encoding="utf-8"), filename=str(p))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def format_findings(findings: Sequence[Finding]) -> str:
|
|
146
|
+
if not findings:
|
|
147
|
+
return "clean: no bust-revival patterns detected"
|
|
148
|
+
return "\n".join(str(f) for f in findings)
|
|
@@ -102,14 +102,50 @@ def _is_negative_int(node: ast.AST) -> bool:
|
|
|
102
102
|
return False
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
_ANCHOR_NODES = (ast.Name, ast.Attribute, ast.Subscript)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_anchorish(node: ast.AST) -> bool:
|
|
109
|
+
"""True for expressions that plausibly name a bar index (not a constant)."""
|
|
110
|
+
return isinstance(node, _ANCHOR_NODES)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _anchor_offset(node: ast.AST) -> Optional[int]:
|
|
114
|
+
"""Net integer offset of an ``<anchor> +/- k`` chain; None if not provable.
|
|
115
|
+
|
|
116
|
+
``i + 1`` -> 1, ``i - 24`` -> -24, ``(i - n) + 1`` -> None: an unknown
|
|
117
|
+
amount is subtracted, so the window is not provably past the anchor. A bare
|
|
118
|
+
constant returns None too, so ``range(1, n)`` stays clean.
|
|
119
|
+
"""
|
|
120
|
+
if _is_anchorish(node):
|
|
121
|
+
return 0
|
|
122
|
+
if _is_int_const(node):
|
|
123
|
+
return None
|
|
124
|
+
if isinstance(node, ast.BinOp):
|
|
125
|
+
if isinstance(node.op, ast.Add):
|
|
126
|
+
left = _anchor_offset(node.left)
|
|
127
|
+
if left is not None and _is_int_const(node.right):
|
|
128
|
+
return left + node.right.value # type: ignore[attr-defined]
|
|
129
|
+
right = _anchor_offset(node.right)
|
|
130
|
+
if right is not None and _is_int_const(node.left):
|
|
131
|
+
return right + node.left.value # type: ignore[attr-defined]
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(node.op, ast.Sub):
|
|
134
|
+
left = _anchor_offset(node.left)
|
|
135
|
+
if left is not None and _is_int_const(node.right):
|
|
136
|
+
return left - node.right.value # type: ignore[attr-defined]
|
|
137
|
+
return None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
105
141
|
def _adds_positive_int(node: ast.AST) -> bool:
|
|
106
|
-
"""True iff ``node``
|
|
142
|
+
"""True iff ``node`` is provably *past* an anchor: ``<anchor> + k``, k > 0.
|
|
107
143
|
|
|
108
|
-
|
|
144
|
+
``(i - n) + 1`` is **not** a match: that window still ends at or before the
|
|
145
|
+
anchor. Reporting it was the false positive written up as case 9.
|
|
109
146
|
"""
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return False
|
|
147
|
+
offset = _anchor_offset(node)
|
|
148
|
+
return offset is not None and offset > 0
|
|
113
149
|
|
|
114
150
|
|
|
115
151
|
def _is_range_call(node: ast.AST) -> bool:
|