backtest-auditor 0.6.0__py3-none-any.whl

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.
@@ -0,0 +1,873 @@
1
+ Metadata-Version: 2.4
2
+ Name: backtest-auditor
3
+ Version: 0.6.0
4
+ Summary: Backtest Auditor: a red-team lint for trading strategies (static lookahead/bias lint, Deflated Sharpe, cost and stability audit), as a Python library and an MCP server.
5
+ License-Expression: Apache-2.0
6
+ Keywords: backtesting,lookahead-bias,mcp,quant,static-analysis,deflated-sharpe-ratio
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Classifier: Topic :: Office/Business :: Financial :: Investment
9
+ Classifier: Topic :: Software Development :: Quality Assurance
10
+ Classifier: Intended Audience :: Developers
11
+ Requires-Python: <3.13,>=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: mcp==2.3.0
15
+ Requires-Dist: numpy==2.5.4
16
+ Requires-Dist: pandas==3.0.6
17
+ Requires-Dist: scipy==1.18.1
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest==9.1.1; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # Backtest Auditor (`nba_auditor`), v0.3
23
+
24
+ A red-team "lint" for trading strategies, delivered as an MCP server and a Python library. It runs the checks we
25
+ ran on our own models (lookahead, survivorship and ticker reuse, cost realism, overfitting and multiple
26
+ testing, sub-period stability, concentration) on *your* code, return series or trade log.
27
+
28
+ You bring your own data. Run locally (CLI or MCP), the auditor makes no network calls, calls no data vendor and
29
+ needs no API key, so we never receive your code. Your AI assistant is a different matter: anything you paste into it,
30
+ and anything the tools return to it (findings quote file names and code lines, and suggested fixes include the
31
+ original and replacement text), is sent to that assistant's provider as usual.
32
+
33
+ > Every report ends with: *This is an automated statistical and code review of a hypothetical backtest. It is
34
+ > general information, not investment advice, not tailored to anyone, and does not predict future results. The
35
+ > checks can miss problems and can flag correct code. Nothing But Alpha is not a registered investment adviser.*
36
+
37
+ ## Tools
38
+
39
+ | Tool | Input | What you get |
40
+ |---|---|---|
41
+ | `check_path(path, include_glob?, max_files?)` | A `.py` file, notebook or folder on disk | Per-file findings with rule id, severity, title, exact line range and a one-line "why it matters"; suggested fixes where a minimal edit is unambiguous (see below). Read-only |
42
+ | `audit_backtest(code_path?, returns_csv?, trades_csv?, benchmark_csv?, n_trials?, turnover?, ...)` | Paths to the code and the results | Every audit that applies, and **one combined verdict** (the worst of the parts) with reasons per part |
43
+ | `check_code(source)` | Python/pandas strategy code | Findings with rule id, severity, line, why the problem inflates results, and a fix. Score 0-100. Also `suggested_fixes` |
44
+ | `audit_returns(returns, freq, costs_bps?, turnover?, positions?, benchmark?, n_trials?, ...)` | A return series (CSV/JSON) | Cost grid and break-even cost; Sharpe with Newey-West and Lo t-stats; **Deflated Sharpe Ratio**; minimum track record length; thirds and years; top-day concentration; drawdown; IR/beta. Verdict **No red flags found / Some red flags / Major red flags** with reasons, plus a score |
45
+ | `audit_trade_log(trades, prices?, initial_capital?, ...)` | date, ticker, side, qty, price[, fee] | Daily PnL and turnover from the log, then `audit_returns`, plus ticker concentration and survivorship hints |
46
+ | `explain(check_id)` | e.g. `LA07`, `RA03`, or empty | What the check does, why it matters, how to fix it, references |
47
+
48
+ Every tool returns `{"markdown": ..., "report": {...JSON...}}`. The server also offers two MCP prompts, which
49
+ clients show as one-click commands: **Find look-ahead bias in this repo** (`find_lookahead_bias`) and **Audit this
50
+ backtest before I rely on it** (`audit_backtest_before_trusting`).
51
+
52
+ ### For agents: `check_path`, suggested fixes and `audit_backtest`
53
+
54
+ *Draft: compliance-checker pass 2026-10-16 (CLEAR WITH EDITS, applied). Still held until counsel answers LEGAL_QUESTIONS 1b.*
55
+
56
+ Coding agents (Claude Code, Cursor, Claude Desktop) call the server while they write backtests and act on what it
57
+ returns, so these tools favor precision: few findings get an edit, and an edit is only offered when the change is
58
+ unambiguous.
59
+
60
+ - **`check_path` is read-only.** Files are parsed with `ast` and never imported or run. Only `.py`, `.pyw` and
61
+ `.ipynb` files are read, whatever `include_glob` says. Virtual environments (any folder with `pyvenv.cfg`),
62
+ `site-packages`, `node_modules`, hidden folders (`.git`, `.venv`, ...), build and cache folders are skipped. Folder
63
+ symlinks and junctions are not followed, so link loops end; a file symlink is read only if it points inside the
64
+ folder. Limits: 200 files by default (`max_files`, at most 2,000), 1 MB per file, 20 MB in total. Binary,
65
+ unreadable and undecodable files are listed under `skipped` with the reason. A UTF-8 byte-order mark is stripped
66
+ before parsing; other encodings are used only when the file declares them (PEP 263). Notebook findings carry
67
+ the cell and the line in the cell.
68
+ - **Suggested fixes** are offered only for high or medium findings in these unambiguous cases:
69
+
70
+ | Rule | Pattern | Suggested edit |
71
+ |---|---|---|
72
+ | LA01 | `y = x.shift(-k)` alone, with `x` a plain column or variable, never combined with `x` later | `x.shift(k)`; or just `x` when the result is already lagged later (e.g. `position.shift(1)`), so the fix never lags twice |
73
+ | LA02 | `rolling(..., center=True)`, not feeding a label | `center=False` (trailing window) |
74
+ | LA03 | `.bfill()`, `.backfill()`, `fillna(method="bfill")` on rows visibly sorted ascending (a resample, `sort_index()`, or `sort_values` ending on a date); `reindex(..., method="bfill")` on the row index | The forward-fill version |
75
+ | LA05 | A scaler or model fitted on all rows, with a visible `X[:k]`/`X.iloc[:k]` split later | Fit on the training slice |
76
+ | LA06 | `train_test_split` without `shuffle=False`; `KFold(shuffle=True)`; only when the rows split are visibly ordered by date alone and the file shows no sign of panel data (an asset column such as `ticker`, any `groupby`, a sort with another key before the date) | `shuffle=False`; `TimeSeriesSplit` (plus its import) |
77
+
78
+ Each fix is a structured edit (`file`, `start_line`, `end_line`, `original`, `replacement`) with a one-line
79
+ rationale. Before a fix is returned, the server applies it in memory and lints again: the finding must be gone,
80
+ the code must parse, and no new high or medium finding may appear. Otherwise the fix is dropped. No fix is offered
81
+ where the mechanical edit would change what the code means: `x.shift(-1) > x` or `x.shift(-1) / x - 1` (a lead
82
+ combined with `x`), `pct_change(-k)` and `diff(-k)` (the mirror has the opposite sign), a lead lagged again later,
83
+ a column later used as a model's label (y of `fit`, a split, a metric, or a label-named column), fills across
84
+ columns (`axis=1`), fills on data whose ascending order isn't visible (bfill on newest-first data reads the past,
85
+ so ffill would be the leak). Also none for `stratify=`, `interpolate()`, more than one candidate, or notebooks.
86
+ Fix checks are budgeted (12 per file, 60 and 10 seconds per call); fixes past the budget are skipped and counted
87
+ in `fixes_not_checked`. Most
88
+ findings get no fix; an agent should explain those rather than guess an edit. A fix removes a pattern; it doesn't
89
+ make a backtest trustworthy.
90
+ - **`audit_backtest`** runs `check_path` on the code, `audit_returns` on the returns and `audit_trade_log` on the
91
+ trades, each against the benchmark if given, and returns the worst verdict of the parts, in the same language,
92
+ always followed by the caveat line. A high code finding is "Major red flags"; a medium one is "Some red flags". The
93
+ caps stay: without a benchmark, or with code only, the best verdict is "Some red flags".
94
+ - **Eval (in-sample; not an accuracy measure).** [`eval/agent_fix`](eval/agent_fix/README.md) applies the
95
+ suggested fixes to small leaky and clean repos and counts leaks removed, new findings and clean lines changed. We
96
+ wrote both the repos and the fix rules, so the result only shows that the fixes behave as designed on patterns we
97
+ already knew; it says nothing about how often they help, or what they change, in your code. On those 27 files the
98
+ fixes removed 11 of 23 planted leaks, added no new findings, changed no clean lines and changed none of the lines
99
+ where a mechanical fix would change the meaning (the `meaning_preservation` repo, from the red-team review). The
100
+ 12 not removed get no suggested fix by design.
101
+
102
+ ### Code rules (`check_code`)
103
+
104
+ | id | Rule | Typical severity |
105
+ |---|---|---|
106
+ | LA01 | Negative `shift`/`pct_change`/`diff` in a feature, signal or condition (labels are fine). v0.6: a lag helper called with a negative lag (`lag(r, -2)` with `def lag(s, k): return s.shift(k)`); a helper that returns a negative shift is also shown at its `return` line | high |
107
+ | LA02 | `rolling(..., center=True)`; rolling/`pct_change`/`shift` on data sorted newest-first (`sort_values('date', ascending=False)`, `sort_index(ascending=False)`). v0.6: two-sided transforms (`filtfilt`, `sosfiltfilt`, `hpfilter`, `savgol_filter`, `gaussian_filter1d`, `uniform_filter1d`, `np.gradient`, `argrelextrema`/`argrelmin`/`argrelmax`, `find_peaks`, `convolve(..., mode="same")`, a Kalman `.smooth(...)`, `seasonal_decompose`, `STL`, `lowess`, `bkfilter`, `cffilter`) and `rolling(center=<setting>)` when every visible definition of the setting is `True`: high when the result reaches a signal, position, PnL or model input (followed through assignments and helper return values), possible otherwise; not reported for a label, or when re-run on data up to each date inside a loop | high / possible |
108
+ | LA03 | `bfill`, `fillna(method="bfill")`, `reindex(method="bfill")`, `interpolate()` | medium / possible |
109
+ | LA04 | `merge_asof(direction="forward"/"nearest")` | high / medium |
110
+ | LA05 | Scaler/PCA/model fitted on all rows before the time split, or fitted and evaluated in-sample; a scaler fitted on all rows while the model trains on a subset of them (medium). A fit on rows bounded above (`.loc[:cut]`, `.iloc[:k]`, `[:k]`, `.loc[a:b]`, `df.date < cut`, `between`) is a training subset and is not flagged; a start filter (`.loc['2000':]`, `df.index >= '2000'`) or a test-window mask is not a training subset. v0.6: a full-sample estimate (`sm.OLS`/`WLS`/`RLM`, `np.polyfit`, `linregress`, `coint`, `KMeans`, `GaussianMixture`, `GaussianHMM`, `arch_model`, `ARIMA`, `PCA`, ...) outside loops and helpers, in a file with no split, training-window or walk-forward names, whose output reaches a position or signal that is booked against returns (medium; not for forecasts compared with outcomes, `fit(last_obs=...)`, or positions computed for live use with no backtest in the file) | high / medium |
111
+ | LA06 | `train_test_split` without `shuffle=False`; shuffled or default k-fold CV on time series. v0.6: `TimeSeriesSplit` without `gap` when a label in the file looks 2 or more bars ahead (medium, unless the folds are trimmed or the file mentions purging or an embargo); `fit_resample` (SMOTE and similar) on all rows before a split or cross-validation (high); `.sample(frac=1)` before a time-series split, and torch `random_split` on time series (medium); `KFold(shuffle=True)` also when the only time-series hint is a forward-return name (medium) | high / medium / possible |
112
+ | LA07 | Signal from bar t's close times bar t's return, including numpy/list slices (`c[1:]` vs `c[:-1]`, `zip`, `np.diff`), inline conditions and helper functions; a close-based signal times a return that starts at bar t's open; bar loops that decide on bar i's close (also through a variable set from it earlier in the iteration, v0.4) and fill at bar i's open (high) or close (medium) or book bar i's return; an entry or exit priced at bar t's open and selected by a signal from bar t's close, high, low or volume: `open.where(sig)`, `open.mask(~sig)`, `open[sig]`, `df.loc[sig, "open"]`, `np.where(sig, open, nan)`, `df.loc[sig, "entry_px"] = df["open"]` (v0.4; high when the result has a fill name such as `entry_px`, `buy_price`, `exit_px`, else possible). v0.5: a positive shift at the end of a function passed to `groupby(...).transform`/`apply`/`pipe` (`lambda s: s.pct_change(7).shift(1)`, or a helper returning `param....shift(k)`) lags the result, and a frame used only to group (`panel.groupby("date")["mom"]`) doesn't count against a lag; `cheat_on_close`/`set_coc`/`trade_on_close`; vectorbt `from_signals(price=close)` (medium) or default close fills (possible) with unshifted entries. v0.6: a fill or entry priced at the same bar's low or high (`entry_px = bar["low"]` in a bar loop, high; `fill_px = np.where(sig, low, nan)`, `from_signals(price=low)`, medium); a limit set from bar t's close checked against bar t's own low or high (high); a bar's VWAP is treated like its open for same-bar timing (a close signal entering at that bar's VWAP) | high / medium / possible |
113
+ | LA08 | Label is the same-bar return (not shifted forward) | possible |
114
+ | LA09 | Global z-score / min-max / `x / x.abs().max()` / `x / x.std()` over all rows, also inside functions (issue #3) and helpers; a full-sample mean/median/quantile used as a signal threshold; full-column `rank`, `zscore`, `qcut` feeding a signal; per-asset `groupby().transform` of a full-history statistic; a lambda, a named helper or `zscore` passed to `apply`/`transform`/`pipe` that scales each column by its own whole-column statistics (v0.4; not flagged with `axis=1`, `groupby`, rolling/expanding windows, a transposed frame, a training slice or one date's cross-section). Medium when the result feeds a signal and the column is a time series; possible otherwise. v0.6: `groupby(<asset>)[col].rank(pct=True)` over each asset's whole history (medium when it reaches a signal); winsorizing at quantiles of every row (`quantile(0.02)` then `clip`) or `fillna(frame.mean()/median())` before the frame is split into train and test rows (medium) | medium / possible |
115
+ | LA10 | Feature or signal built from the label; `X` that drops `target` but keeps `fwd_ret` (drop lists, constants, `df.columns` comprehensions and `str.startswith` masks are resolved per column); forward columns passed to `fit` or a training dataset, also inside a frame built with `pd.DataFrame({...})`/`pd.concat`; a neutral-named forward value that reaches a signal or position; an outcome (a forward average, a forward PnL statistic, a target encoding) assigned to a signal, position or weight name, or transformed (rolling, sign, rank, clip, ratio) and multiplied with a return (`*`, `.mul`, `np.dot`, `np.where(c, r, -r)`, `np.copysign(r, s)` with `s` from future data, v0.4); a forward column reshaped with `pivot`/`pivot_table`/`unstack` keeps its forward flag and lead (v0.4); literal `query`/`eval` strings are read as the code they stand for (v0.4); a strategy-named series kept only on rows chosen by an inline comparison on future data (`strat = bench[mkt_fwd > 0]`, medium, v0.4); buckets formed from the forward return and then used to average it (medium). Not flagged: forward returns used only as the outcome (averaged per date or bucket, an IC, booked against a position decided earlier, next-open fills, a bucket `pivot_table` of the forward return, `fw[mask].mean()` with a mask from past data); a lead undone by a lag of the same size (`shift(-H)` then `shift(H)`); a fill model, where a resting limit order placed without future data fills if the next bar's low (high) reaches the limit and that mask only selects trade returns (v0.4). A neutral name that the lint can't tie to a model input or signal is "possible" | high / medium / possible |
116
+ | LA11 | Parameter grid (loops, `itertools.product`, dict/list comprehensions) scored on the full sample, best kept (reports the grid size to use as `n_trials`). v0.6: also shown at the line that keeps the best (`best = max(scores, key=scores.get)`), as a repeat that is scored once | medium |
117
+ | LA12 | Intraday `resample` stamped at the bar start (default or `label='left'`): medium when the bars are joined, reindexed or assigned back onto another time index, possible otherwise. A resampled column assigned into a frame of the same bars (`bars["volume"] = ticks["size"].resample("1min").sum()`) is not "another time index" (v0.4). v0.6: names computed only from bars of the same rule (`feat = pd.DataFrame({... a_1s.rolling(5).mean()})`, `nxt = mid_1s.pct_change().shift(-1)`) keep those bars' stamps, so joining them is not a realignment; `groupby(<period of the index>)[col].transform("last"/"max"/"sum"/"mean"/"std"/...)` or `pd.Grouper(freq=...)` writes a period's final value onto the earlier bars of that period (medium when it reaches a signal, possible otherwise; `first`, `count`, running statistics and a following `.shift` are fine) | medium / possible |
118
+ | LA13 | `x[i+1]`, `x.iloc[i+1]` or a slice starting after `i` inside an `if` in a `for` or `while` bar loop, or a value read from a later bar earlier in the loop. v0.4: offsets from module constants and parameter defaults are resolved (`c[i + K]` with `K = -2` is the past); string character loops and loops whose outputs only ever become a label or target are not reported; a later bar read only for a missing-data check (`np.isnan`, `pd.isna`, `is None`) is possible. v0.5: a resting order simulated in a loop is not reported: an order decided at bar `i` from past data (an enclosing condition, an `and` term, or an `elif` of another fill check), filled if bar `i+1`'s low or high reaches its level (a level known before that bar's high and low: past data or bar `i+1`'s open), with the fill or exit priced from that level and every later-bar read in the branch a price assigned to a fill, exit or PnL name. Possible when the fill isn't priced from the level. v0.6: in a backtrader `Strategy`/`Indicator`, a positive line index (`self.data.close[1]`) is the next bar (high); a trade list read only for its size (`len(trades) >= 50`, `if trades`, `not trades`) is not a decision on fill values | high / possible |
119
+ | LA14 | Early stopping or best-model selection on the test set (`eval_set`/`valid_sets`/`validation_data` with early stopping); a loop that picks the model, parameter or feature with the best test score. v0.6: `max`/`min` with a `key` that scores test data (`max(grid, key=lambda t: f1_score(y_test, p > t))`); a dict filled in a loop with one test-set score per candidate (test-named data or rows `~train_mask`) and picked with `max(d, key=d.get)` (high; a repeat scored once when the loop is already reported); an Optuna-style objective passed to `optimize` that returns a score on test data (high) | high / medium |
120
+ | LA15 | Fundamentals merged onto prices by fiscal period end (`period_end`, `fiscalDateEnding`, `datadate`, `calendardate`, ...) instead of the filing date, with no visible reporting lag: `merge_asof` high, period-end index joined medium, exact merge medium if forward-filled, else possible | high / medium / possible |
121
+ | SB01 | Today's constituents: `read_html(<Wikipedia S&P 500>)`, `tickers_sp500()`, snapshot CSVs, hard-coded ticker lists; filtering a panel to names whose last date is the panel's last date (medium). v0.6 (medium): `dropna(axis=1)` (or `thresh=len(...)`) on a price- or return-named wide frame that is then run through returns or rolling windows; columns kept by `iloc[-1].notna()`; a universe taken from the last row (`iloc[-1].nlargest(n)`, not of a score or signal) or from whole-period returns (`px.iloc[-1] / px.iloc[0]`) that then selects the backtest's columns; members whose `end`/`removed` date is empty (not combined with a point-in-time test); `groupby(id).filter(lambda g: g[date].max() >= <fixed end date>)` | high / medium / possible |
122
+ | DQ01 | yfinance data caveats | info |
123
+ | DQ02 | Raw `Close` returns with `auto_adjust=False`; `Adj Close` mixed with raw OHLC | medium / possible |
124
+ | CO01 | No commissions, fees, spread or slippage anywhere (vectorized or loop PnL); engines called without fees. A realism caveat, not a lookahead error, so never above "possible" (since v0.3); the message says when a Sharpe, CAGR or total return is computed from the cost-free returns | possible |
125
+
126
+ The lint uses `ast` plus a light data-flow pass over assignments: it tracks whether each column is
127
+ forward-looking, lagged, price-derived, a return or a signal, and uses name heuristics (`target`/`fwd_*`/`y`
128
+ mean labels, `signal`/`position`/`pred` mean signals). When unsure it says "possible". Code that doesn't
129
+ parse falls back to a few text patterns, all reported as "possible".
130
+
131
+ ### Return checks (`audit_returns`)
132
+
133
+ - **RA01** costs and the break-even cost.
134
+ - **RA02** Sharpe significance: Newey-West, plus a Lo-style (2002) correction.
135
+ - The HAC bandwidth widens automatically when returns overlap.
136
+ - All significance tests use an effective sample size: T divided by the long-run variance ratio.
137
+ - **RA03** Deflated Sharpe Ratio (Bailey & López de Prado 2014).
138
+ - **RA04** MinTRL (Bailey & López de Prado 2012).
139
+ - **RA05** sub-period stability.
140
+ - **RA06** concentration in the best 5% and 1% of days, compared with a normal distribution.
141
+ - **RA07** drawdown.
142
+ - **RA08** benchmark: excess return, IR, beta, and alpha with its t-stat.
143
+ - **RA09** implausibility: a Sharpe above 3, or a daily hit rate above 65%, on daily or slower bars.
144
+
145
+ Trade logs add **TL01** survivorship hints and **TL02** concentration by ticker.
146
+
147
+ **Verdict rules** (always a statistical review of a hypothetical backtest, never a certification). Every verdict is followed by this line: *No red flags doesn't mean a strategy will make money; it means these checks didn't find the common errors.*
148
+ - *Major red flags*: any of the following.
149
+ - DSR < 0.50, meaning the result does not beat what the best of `n_trials` zero-skill strategies would show.
150
+ - The mean return is not positive.
151
+ - Costs erase the edge at *your* declared cost (10 bps if you gave turnover but no cost).
152
+ - The Sharpe is above 5 on daily or slower bars.
153
+ - *No red flags found*: all of the following.
154
+ - DSR >= 0.95 and a Newey-West t of at least 2, both with the autocorrelation adjustment.
155
+ - All three thirds are positive.
156
+ - `n_trials` is declared and costs are assessed; a user-supplied `trials_sr_variance` is not below the
157
+ zero-skill sampling variance.
158
+ - The series has dates (or an explicit `freq`), regularly spaced, with finite values.
159
+ - A benchmark is supplied and the alpha over it is significant (t >= 2). Without a benchmark the best verdict
160
+ is "Some red flags", with the reason "No benchmark supplied: market exposure can't be separated from skill."
161
+ - The result is not implausible.
162
+ - The series is not intraday, and the units look right.
163
+ - For trade logs: daily prices were given, fills match those prices, and no anachronistic ticker was found.
164
+ - *Some red flags*: everything else. Concentration (RA06) is a warning and a score component, not a blocker,
165
+ because positively skewed strategies (trend, long volatility) are concentrated by nature.
166
+
167
+ **Score**: *A score of 100 means none of our listed patterns matched, not that the code is free of leaks. Static checks miss many leaks.* (Every report prints this next to the score.) Weighted points for DSR (40), costs (20), stability (20) and concentration (20). A check that can't
168
+ be assessed is dropped and the rest rescaled. The verdict also caps the score: at most 40 for *Major
169
+ red flags*, 79 for *Some red flags*, and 20 if the result is implausible. The JSON has the full breakdown. MinTRL is
170
+ reported but not scored separately, because "track record >= MinTRL vs the hurdle" is the same test as DSR >= 0.95.
171
+
172
+ ### Calibration on synthetic data (how often the verdict is wrong)
173
+
174
+ These are synthetic daily series of 5 years each (1260 days; the leveraged-index rows use 10 years), with
175
+ turnover 0.05 per day. Every row gets an independent market series as its benchmark, except where it says
176
+ "no benchmark". Without a benchmark the best verdict is "Some red flags" by design. Reproduce the table with
177
+ `python scripts/calibration.py` (fixed seeds; last run after the second red-team pass and the benchmark rule).
178
+
179
+ | Series | No red flags found | Some red flags | Major red flags |
180
+ |---|---|---|---|
181
+ | Pure noise, one strategy, `n_trials=1` (300 runs) | **1%** | 45% | 54% |
182
+ | Best of 100 noise strategies, honest `n_trials=100` (200 runs) | **0%** | 28% | 72% |
183
+ | Best of 100 noise strategies, **`n_trials=1` (understated)** (200 runs) | **67%** | 33% | 0% |
184
+ | Noise as 20-day overlapping returns, `n_trials=10` (200 runs) | **0%** | 6% | 94% |
185
+ | Noise with AR(1) autocorrelation 0.3, `n_trials=10` (200 runs) | **0%** | 4% | 96% |
186
+ | 2x leveraged index (Sharpe ~0.75), **no benchmark** (200 runs) | **0%** | 94% | 6% |
187
+ | 2x leveraged index, benchmark = the index (200 runs) | **0%** | 94% | 6% |
188
+ | Planted Sharpe 0.5, `n_trials=10` (200 runs) | 1% | 22% | 76% |
189
+ | Planted Sharpe 1.0, `n_trials=10` (200 runs) | 14% | 54% | 32% |
190
+ | Planted Sharpe 1.5, `n_trials=10` (200 runs) | 48% | 44% | 8% |
191
+ | Planted Sharpe 0.7, positive skew, `n_trials=1` (200 runs) | 0% | 100% | 0% |
192
+ | Planted Sharpe 0.7, negative skew, `n_trials=1` (200 runs) | 0% | 100% | 0% |
193
+
194
+ **Code lint, measured on held-out code** (snippets the builder had not seen, written by two red-team passes):
195
+ recall at high or medium severity was **47% (15/32)** on the first set and **67% (8/12)** on the second, measured
196
+ before any fixes. That is the honest expectation for new code: roughly **half to two thirds of leaks get
197
+ caught**, so a clean `check_code` result is weak evidence. Both sets are now regression tests, so the current
198
+ scores on them are in-sample (see Known limitations).
199
+
200
+ **v0.2 lint, measured on sealed sets written outside the build** (2026-10-16). The builder never saw these
201
+ snippets; only the leak category names of the first set were shared.
202
+ - **Sealed set v2** (50 leaky, 30 clean): v0.1 recall 54%. The first v0.2 build reached 76% but raised one high
203
+ false alarm on correct code (`pos[1:] = np.where(sig[:-1], 1, -1)`, a lag through the assignment target). After
204
+ that fix: **39/50 (78%) flagged at high or medium, 0/30 false alarms**.
205
+ - **Sealed set v3** (60 leaky, 40 clean, with near-miss clean files written to trip the lint): v0.1 recall **24/60
206
+ (40%)** with **5/40 high false alarms**; v0.2 at commit ea153fd **36/60 (60%)** with **3/40 high false alarms**, all
207
+ LA10 (target leakage) and all present in v0.1 too. Re-run at 5f3ea0a: unchanged, 36/60 with 3/40 high false alarms,
208
+ all LA10. A red-team review of that build found more high false alarms
209
+ (pandas `.shift(1)` before `.to_numpy()`, lagged period ends, `sort_index(axis=1)`, a holdout used for early
210
+ stopping); they are fixed and are regression tests in `tests/corpus_rt3`. Re-run at the merged code (18e6055):
211
+ unchanged, **36/60 (60%)** with **3/40 high false alarms**, all LA10.
212
+ - **The free web check stays off until v3 shows 0 high false alarms.** A high finding on correct code is worse
213
+ than a miss.
214
+
215
+ **v0.3 lint: precision fixes, measured in-sample only** (2026-10-16). v0.3 targets the remaining high false
216
+ alarms: LA10 on forward columns that are dropped before fitting, used only as the outcome, or used as next-open
217
+ fill prices; LA05 on fits whose training rows are a slice or a date mask; CO01 capped at "possible". The builder
218
+ wrote the development twins below from descriptions of those classes and never read the sealed sets. **Every
219
+ number here is in-sample** (the rules were changed until these files passed). They show the fixes work on the
220
+ patterns as described, not how v0.3 does on new code.
221
+
222
+ **v0.3 on sealed set v5** (written outside the build, never read by the builder; run by the coordinator on the final
223
+ code at 2717e1e): **0 of 70 correct files flagged at high or medium; 21 of 50 leaks (42%) flagged at high or medium**
224
+ (easy 9/12, medium 9/20, hard 3/18; 24/50 = 48% counting "possible"). For comparison, v0.2 on the same set: 20/50
225
+ (40%) with 14/70 correct files flagged (13 of them CO01 medium). Coordinator's note: v5 is written to imitate everyday
226
+ research code and covers more leak types than v2/v3, several outside these rules. Read it this way:
227
+ - **Recall on v5 is about 40%**, far below the 60-78% quoted for the v2/v3 sets above. Expect roughly 40% of leaks
228
+ in new code to be caught at high or medium. A clean `check_code` result is weak evidence.
229
+ - **The v5 recall number is contaminated.** Two leak classes from v5 were fed back to the builder during the
230
+ red-team review (the descriptions in rounds 2 and 5 below), so recall on those classes is partly in-sample.
231
+ - **The v5 precision number (false alarms on correct code) is not contaminated.** No clean v5 file or description
232
+ was fed back.
233
+ - LA10 twins (`tests/corpus_v03`, 27 clean, 20 leaky; acceptance criteria committed before the first run in
234
+ `tests/corpus_v03/PREREGISTRATION.md`). Clean files with a high or medium finding: v0.2 **13/27** (8 with LA10
235
+ high), v0.3 **0/27**. Leaks flagged as expected: v0.2 18/20, v0.3 20/20.
236
+ - LA05 and CO01 twins (`tests/corpus_v03b`, 15 clean, 8 leaky). Clean files with a high or medium finding: v0.2
237
+ **10/15** (3 LA05 high, 5 LA05 medium, 2 CO01 medium), v0.3 **0/15**. Leaks flagged: v0.2 7/8, v0.3 8/8. Three clean
238
+ and one leaky twin (`d10`-`d12`) were added after the first measurement, to reproduce the LA05 *high* case
239
+ (a split-named variable assigned after the fit); one clean/leaky pair (`b10`) was added after the repository rescan.
240
+ - Round 2 (PR #9 review, `tests/corpus_v03c`, 7 clean, 7 leaky, also in-sample). It covers three cases: a negative
241
+ shift at the end of a chain (`rolling(21).std().shift(-21)`) flowing through features into a signal lagged by fewer
242
+ bars than the lead; a negative `np.roll` used as a "previous" value; a position times a return between next-bar
243
+ fill prices. Before the fix, v0.3 caught 3/7 leaks at high and had 1/7 clean files with a high finding (v0.2: the
244
+ same). After: 7/7 and 0/7.
245
+ - The lint now tracks how many bars a value looks ahead, and a lag undoes only that many bars.
246
+ - A neutral-named forward value that reaches a signal or position (also through `pos[1:] = ...` or `pos[mask] = ...`)
247
+ is high, for both LA01 and LA10.
248
+ - The label passed to `lgb.Dataset`/`xgb.DMatrix`/`Pool` is no longer an input, so a model trained on it, and its
249
+ predictions on new rows, are not reported as target leakage. The data passed to these constructors is checked
250
+ like `fit`.
251
+ - After round 2, the rescan against v0.2 shows: repo, 0 new high or medium outside the test corpora; earlier
252
+ internal codebase, 0 added and 60 removed. 21 of those are v0.2 LA10 *highs* on predictions from a model trained
253
+ on a label in one function and scored on fresh rows at the rebalance date in another; on review, false alarms.
254
+ Static analysis can't tell whether the training window ends a full label horizon before the scoring date (an
255
+ embargo question), and v0.3 does not try.
256
+ - Round 5 (red-team block of PR #9, `tests/corpus_rt5`, the red team's repro blocks). At bef5683, 19 required cases
257
+ failed that v0.2 passed: 14 leaks were missed or only "possible", and 5 correct files got a high false alarm on a
258
+ symbolic horizon (`shift(-H)` realigned by `shift(H)`). Now all 19 pass. Changes:
259
+ - A frame built from columns (`pd.DataFrame({...})`, `pd.concat([...])`) that stores a forward column is tracked as
260
+ a container. It is high when a forward column survives into `fit`/`predict`, a training dataset, a feature
261
+ matrix or a signal. An IC frame that is only correlated stays clean.
262
+ - Outcomes that decide a position are high in two cases:
263
+ - The outcome is assigned to a signal, position or weight name (`position`, `weight`, `score`; names that only
264
+ say long, short or trade are not enough).
265
+ - The outcome is a transform of forward data (rolling, sign, rank, clip, ratio) used as a factor in a product
266
+ with a return, whatever its name. Products are `*`, `@`, `.mul`/`.multiply`, `np.multiply`, `np.dot`, and
267
+ `np.where(c, r, -r)` / `r.where(c, ...)`, where `c` is the position.
268
+
269
+ Examples: a per-date mean forward return turned into a tilt, a rolling Sharpe of forward PnL used as a weight, a
270
+ target encoding used as a score.
271
+ - Symbolic horizons cancel: `shift(-H)` then `shift(H)` (also `periods=-H`, function parameters, `cfg.horizon`,
272
+ `H + 1`, `-1 * 5`).
273
+ - LA05 date masks and slices count as a training subset only if bounded above.
274
+ - On the red team's files, 10 leaks that are not in the required set are also flagged now. 9 are still missed
275
+ (listed under Known limitations).
276
+ - Round 6 (red-team review at 0ba08c2, APPROVE WITH FIXES; `tests/corpus_rt5/r6_*.txt`):
277
+ - Products written as `.mul`, `np.dot`, `np.where(c, r, -r)` and `r.where(...)` count, so a long/short-named
278
+ outcome can't escape through them (A01, A03).
279
+ - Containers follow `rename(columns=...)` and `data.columns = [...]` (C07), and positional column selections (K01:
280
+ `X, y = data.iloc[:, :-1], data.iloc[:, -1]` is clean).
281
+ - A lead and lag with the same expression no longer cancel when the name is rebound, a loop variable or bound in
282
+ several scopes (B01-B04).
283
+ - A forward comparison used only for a hit rate is "possible" (K09).
284
+ - `len(data)` and `data.shape` are no longer treated as reading the data.
285
+ - Still missed: `pivot(values="fwd_ret")` (A04-A06; A04/A05 caught in v0.4) and a helper defined above the forward
286
+ column it reads (B05).
287
+ - Round 7 (red-team review at c8bfe78; `tests/corpus_rt5/r7*.txt`):
288
+ - Container names now default to unknown after any frame-level operation other than known name-keeping ones
289
+ (F01-F06, H02). This covers in-place renames, `add_prefix`, `set_axis`, mapper renames and
290
+ `.columns.str.replace`.
291
+ - Inline transforms inside a product factor are judged as the expression (E03, H05).
292
+ - Masked sign flips of a return series on rows chosen from forward data are flagged (E02, E07), as is the keyword
293
+ form `where(cond=..., other=...)` (E05).
294
+ - A name whose only signal words are long, short or trade is judged by its use, not its name. This removed a new
295
+ high false alarm on the IC frame in `research/nba_research/phase0b.py`.
296
+ - Documented misses: `np.copysign` (E01) and `frame.eval("t * b")` (E04). Both caught in v0.4.
297
+ - Round 8 (red-team review at 9e6fc0a; `tests/corpus_rt5/r8.txt`):
298
+ - Container column order is no longer kept stale across reorders (S01-S03): `sort_index(axis=1)`, a literal
299
+ reorder list, `iloc[:, ::-1]`.
300
+ - A return selected by a position-named mask built from future data (`bench[long]`, `bench.loc[short]`,
301
+ `bench[~long]`) is high (T01). An inline comparison (`bench[mkt > 0]`, as in up/down capture) is not counted.
302
+ - A container lagged by its full lead (`data.shift(5)` with a 5-bar lead) is clean (U01). Partial lags stay high
303
+ (S04, S06).
304
+ - Round 9 (red-team approval at 6dbc2b4, conditional on documentation; `tests/corpus_rt5/r9.txt`):
305
+ - A column subscript that isn't a literal list (`data[sorted(data.columns)]`, `data[data.columns[::-1]]`) and
306
+ any transpose (`.T`) make the column order unknown (V01, V03, V06).
307
+ - Masks are followed through `.values`, `.to_numpy()` and `x[x].index` (W03, W04).
308
+ - A mask of future data with a neutral name selecting returns (`ok = mkt > 0; bench[ok]`) is **medium** (W01):
309
+ it can be an up/down-capture filter rather than a trading rule. A position name (`long`, `short`, `regime`)
310
+ is high. An inline comparison (`bench[mkt > 0]`) is not counted (X01), and a lagged regime mask is clean (X02).
311
+ - Documented miss: `frame.query("m > 0")` (W06). Caught (medium) in v0.4.
312
+ - Older corpora: no leak lost its high or medium finding except the 7 cost-only files (`v21`-`v23`, `v50`,
313
+ `hl21`-`hl23`), which now show CO01 as "possible" by design.
314
+ - Rescan against v0.2. **This repository** (699 Python files, test corpora included): no new high or medium finding
315
+ outside the test corpora. Removed: 10 LA10 mediums in `research/` (a label builder, equal-weight benchmark averages
316
+ of forward returns, next-date rebalance scheduling, a benchmark slice), one LA10 high in `research/` (a
317
+ long-format frame for an IC), and CO01 mediums by policy. All were false alarms on review. One high false alarm
318
+ remains in `research/`: LA13 on an event-study diagnostic that reads bars after the event on purpose
319
+ (`phase0b.volume_peak_offsets`; gone in v0.4). **831 files from an earlier internal codebase** (first round; round 2 above has the final count): 0 added, 36 removed (32 LA10
320
+ mediums where the label flows into `lgb.Dataset(X, label=y)` and from there into the model and its outputs, or test
321
+ labels averaged by prediction bucket; 4 CO01 by policy). On review the 32 were false alarms: training on a label is
322
+ not target leakage, and whether the training rows overlap the test rows is LA05/LA14's job.
323
+
324
+ **v0.6 on sealed set v9 (2026-10-17; pre-registered one-shot run under the revised gate, reproduced by the red team;
325
+ docs/company/AUDITOR_EVAL_V9_PREREG.md).** v9: 60 leaky, 105 plain correct and 55 near-miss correct files, written
326
+ after v0.6 was frozen and never read by the builders. v0.6 (package 0.6.0): **30/60 leaks flagged (50%)** at
327
+ high/medium (easy 12/15, medium 12/25, hard 6/20), so it **misses half the planted leaks (30/60)**; false alarms on
328
+ **0/105 plain** and **4/55 near-miss** correct files (4/160 combined). With 105 plain files, a result of 0 is still
329
+ consistent with a true false-alarm rate of up to about 3%. One of the 30 catches counts only because of a "same issue
330
+ as line N" repeat of a finding on another line; without it, 29/60. The merged v0.5 on the same set: 24/60 flagged, with
331
+ the same false alarms (0/105 plain, 4/55 near-miss). The hosted web check stays off: it still needs legal review. We
332
+ changed this test's pass rule after two earlier runs. The old rule (no false alarms on any correct file) was not met: 3
333
+ of 80 correct files were flagged in one run and 4 of 90 in the next, all but one of them look-alike files. The new rule
334
+ was fixed before the next test set was written. All runs are published (v6, v7, v8, v9), including v8 below, where the
335
+ new rule was not met. One test set, written for us: a clean `check_code` result is weak evidence.
336
+
337
+ **v0.5 on sealed set v8 (pre-registered one-shot run under the revised gate, reproduced by the red team;
338
+ docs/company/AUDITOR_EVAL_V8_PREREG.md).** v8: 60 leaky, 105 plain correct and 55 near-miss correct files. v0.5 and v0.4
339
+ gave byte-identical results: **19/60 leaks flagged (32%)** at high/medium; false alarms on **1/105 plain** and **3/55
340
+ near-miss** correct files (4/160 combined). The gate (0 on plain files) is **not met**, so the hosted web check stays off.
341
+ We changed this test's pass rule after two earlier runs; all runs are published (v6, v7, v8). A clean `check_code` result is
342
+ weak evidence.
343
+
344
+ **v0.6 lint (package 0.6.0): a recall sprint, measured in-sample only** (2026-10-17).
345
+ v2-v8 are development sets now: the builder scored them and read v4-v8's missed leaky files (and v8's false alarm
346
+ clean_16) to group the misses into classes (`docs/RECALL_ANALYSIS_v06.md`: 196 missed files, 17 classes). The rules
347
+ were then written from the classes, with the builder's own twins (`tests/corpus_v06`, 47 leaky, 33 clean) and
348
+ acceptance criteria committed before the lint changed (`tests/corpus_v06/PREREGISTRATION.md`). **Every number here is
349
+ in-sample, and the dev-set gains are the most in-sample of all: the rules were designed after reading those misses.**
350
+ The out-of-sample test is sealed set v9 (above); these dev-set numbers are not.
351
+
352
+ | Dev set (scored with its own rules) | Leaks flagged, v0.5.0 → v0.6 | Correct files flagged, v0.5.0 → v0.6 |
353
+ |---|---|---|
354
+ | v2 | 37/50 → 37/50 | 0/30 → 0/30 |
355
+ | v3 | 34/60 → 41/60 | 0/40 → 0/40 |
356
+ | v4 | 14/60 → 30/60 | 0/50 → 0/50 |
357
+ | v5 | 21/50 → 33/50 | 0/70 → 0/70 |
358
+ | v6 | 22/60 → 36/60 | 0/80 → 0/80 |
359
+ | v7 | 18/60 → 35/60 | 0/90 → 0/90 |
360
+ | v8 | 19/60 → 35/60 (easy 9 → 11/15, medium 5 → 11/25, hard 5 → 13/20) | 4/160 → 3/160 (plain 1/105 → 0/105; near-miss 3/55 → 3/55) |
361
+
362
+ - No leak flagged by v0.5.0 was lost on any dev set. The 82 new catches by rule: LA02 23 (two-sided transforms), SB01
363
+ 12, LA05 12, LA14 7, LA09 6, LA07 5, LA06 5, LA12 4, LA11 3, LA04 2, LA13 2, LA01 1. Six of them (v4 leaky_36; v7 leaky_08,
364
+ leaky_25; v8 leaky_06, leaky_42, leaky_47) count only because of a second-line (`same_as`) report of a leak the lint
365
+ already flagged on another line, not because a new leak was detected.
366
+ - Dev twins: leaks flagged **5/47 → 47/47**; correct twins with a high or medium finding **2/33 → 0/33** (the two are
367
+ the fixed false alarms below). 14 post-hoc look-alikes written after the fixes (`tests/corpus_v06/posthoc_clean.txt`)
368
+ have no high or medium finding.
369
+ - False alarms fixed: v8 clean_16 (two series resampled with the same rule, joined through derived names; LA12) and
370
+ the count-only trade cap (red-team Y04: `if len(trades) >= 50: break`; LA13). The v8 near-miss false alarms clean_17,
371
+ clean_27 and clean_141 remain. In the first build (b090f0e) clean_17 also gained a new LA07 high (a next-bar low held
372
+ in a variable, read as this bar's low); fix F2 below removed it, so clean_17 is back to its one pre-existing LA10.
373
+ - A finding is now also shown where the leak is written when that differs from where it was found (the negative shift
374
+ inside a helper, the `direction=` keyword of a multi-line `merge_asof`, the `max(...)` after a parameter sweep, the
375
+ pick from a dict of test scores). These repeats carry `same_as` (the line of the first finding), are labelled "same
376
+ issue as line N", and are not counted in the score, the counts or the score breakdown.
377
+ - One red-team "correct" file was wrong: `tests/corpus_rt3/clean/c05_pad_lag.py` used `np.convolve(..., "same")`, a
378
+ centred moving average that reads 4 bars ahead. It now uses a trailing average like the other corpus files; the
379
+ centred version is twin `n07`.
380
+ - Every other corpus item (`tests/`, `eval/`, `examples/`, every red-team block): no high or medium finding lost
381
+ against v0.5.0; gains only on leaky items. Rescans against v0.5.0: this repository, high/medium added only in leaky
382
+ test corpora and the leaky example (a repeat at the `max(...)` line, scored once); the earlier internal codebase (831
383
+ files), nothing added or removed.
384
+ - Speed: 200 KB in about 1.4-1.6 s; the red-team stress shapes 2.3-2.6 s (budgets: 3 s, 4 s, 8 s ceiling).
385
+ - Red-team round 14 (`docs/company/REDTEAM_LINT_V06.md`, APPROVE WITH FIXES). The red team wrote 42 fresh files
386
+ (`tests/corpus_rt14_v06`). **Original probe result on b090f0e, before any fix (not in-sample): leaky 1/20 (v0.5.0)
387
+ → 19/20; correct files flagged 2/22 (v0.5.0) → 8/22.** Fixes:
388
+ - F1: backtrader `[k > 0]` is reported only on line objects (`self.data...`, `self.dataN...`, `self.datas[i]...`,
389
+ attributes assigned from `bt.ind`/`bt.indicators`/`bt.talib` or data lines); never through `p`/`params` or on
390
+ attributes assigned from literals.
391
+ - F2: a low or high held in a variable that was shifted (`next_low = low.shift(-1)`) is not this bar's low.
392
+ - F3: for transforms with a bounded look-ahead (np.gradient 1 bar, argrelextrema `order`, find_peaks `distance`,
393
+ savgol half-window) lags are added up along the flow: a signal is causal once lagged by the look-ahead, and a
394
+ position booked against the same bar's return needs one bar more (else high; possible when fully lagged). Calls
395
+ inside a function passed to `rolling(...).apply`/`expanding(...).apply` are not reported.
396
+ - F4: a period aggregate, or a transform, that reaches only PnL-named keys (a printed daily PnL total) is a report;
397
+ PnL counts as a decision only when a reached value is multiplied with a return as a position.
398
+ - F5: helper call sites, statements by the names they read, products and assignments by key are indexed once. The
399
+ two red-team shapes (12.5 s and 7.0 s on b090f0e) and a third found while fixing (limit compares, 5.4 s) now take
400
+ 1.3-1.4 s at 200 KB and are in the speed tests.
401
+ - **After the fixes, in-sample (the rules were changed with these files in view): rt14 leaky 19/20, correct files
402
+ flagged 2/22** (the two pre-existing v0.5.0 findings, q10 LA10 and q18 LA05, out of scope). Still missed: k09
403
+ (a universe from `last_valid_index`). Dev sets after the fixes: same numbers as the table above, no lost catch.
404
+ - Precision note for F3: v3 leaky_12 (`np.sign(np.gradient(ma)).shift(1) * ret`) shows why one lag isn't enough:
405
+ the slope at t-1 still uses bar t, the bar whose return is booked.
406
+ - Red-team round 15 (`tests/corpus_rt15_v06`, 12 leaky, 9 correct; original result on f270645, not in-sample: leaky 8/12,
407
+ correct flagged 3/9). Fixes: B1, F4 had made PnL-named results a report even when a price or signal aggregate fed PnL,
408
+ fills or exits; now only an aggregate or smoothing *of* PnL/equity is a report, and fill/exit-priced (`exec`) and PnL
409
+ results count as decisions otherwise; a feature-named intermediate counts only when it reaches `fit`/`predict`. B2,
410
+ backtrader indicators are resolved from the imports (`import backtrader.indicators as btind`, `from backtrader.indicators
411
+ import RSI`), a local alias of a data line (`o = self.data.open; o[1]`) is a line, and an attribute built from a list
412
+ of feeds (`sorted(self.datas)`) is not. **After the fixes, in-sample: rt15 leaky 12/12, correct flagged 1/9 (n04, an
413
+ LA10 already on main); rt14 leaky 19/20, correct 2/22.** Dev sets unchanged, no lost catch.
414
+ - Not attempted (they need information the code does not carry, or would cost precision): point-in-time joins with
415
+ config-driven directions or relabelled periods, same-bar products hidden in wrappers, target encoding, calendar and
416
+ time-zone timing, corporate actions, interpolation before returns (still "possible"), z-scores inside classes or
417
+ tensors, reversed expanding windows. Next: the sealed v9 run and a red-team pass on these rules.
418
+
419
+ **v0.4 on sealed set v7 (pre-registered one-shot run, reproduced by the red team; docs/company/AUDITOR_EVAL_V7_PREREG.md).**
420
+ v7: 60 leaky and 90 correct files (55 plain, 35 near-miss), written outside the build and never read by the builders.
421
+ - v0.4 (7d22613): **17/60 leaks (28%) flagged at high or medium** (easy 9/15, medium 6/25, hard 2/20); **4/90 correct
422
+ files flagged** at high/medium: **0/55 plain, 4/35 near-miss** (three LA13 loops, one LA07).
423
+ - v0.3 on the same set: 16/60 (27%) and 5/90 (0/55 plain, 5/35 near-miss).
424
+ - The precision gate for the hosted web check (0 high/medium false alarms) is **not met**. v0.4 merged because it beats
425
+ v0.3 on v7, as the pre-registered rule allows. Recall on v7 is low: v7 deliberately covers many leak types outside
426
+ these rules. A clean `check_code` result is weak evidence.
427
+
428
+ **v0.5 lint (unreleased; package version still 0.4.0): the four v7 false alarms, measured in-sample only** (2026-10-17).
429
+ The coordinator described three classes from the v7 review; the builder did not read v7, any `heldout_*` folder or v8
430
+ (written later, sealed). Acceptance criteria and dev twins (`tests/corpus_v05`, 8 leaky, 10 clean) were committed
431
+ before the lint changed (`tests/corpus_v05/PREREGISTRATION.md`). **Every number here is in-sample.**
432
+ - Loop order simulation (LA13): a resting limit, stop or take-profit order placed at bar i from past data and checked
433
+ against bar i+1's low or high is exempt when the fill or exit is priced from the order's level; "possible" when it
434
+ isn't. A next-bar high or low that decides the order, a next-bar close in the fill condition, or sizing from the
435
+ fill bar's range stays high.
436
+ - numpy label loops (LA13): the label-only exemption accepts numpy summaries and files (`np.unique`, `np.bincount`,
437
+ `np.histogram`, `np.save`/`savez`/`savetxt`, `.tofile`, nan-aware statistics, `np.count_nonzero(labels == 1)`) and
438
+ missing-value masks (`~np.isnan(labels)`). A product, a signal or a feature still breaks it.
439
+ - Lags inside groupby transforms (LA07): `transform(lambda s: ... .shift(1))`, `.apply`, `.pipe` and lag helpers count
440
+ as a lag (a helper whose every return is `<expression of a parameter>.shift(k)`, k > 0, with no lead inside); a frame
441
+ used only for `groupby(...)[col]` no longer cancels a lag.
442
+ - Dev twins, v0.4 (main at a8d57fc) then v0.5: leaks flagged **12/12 → 12/12**; correct files with a high or medium
443
+ finding **10/12 → 0/12**. The last six twins (addendum 1) came from the coordinator's scoring of the first build
444
+ (d0dbece) on the dev sets: no lost catches, 0 false alarms on v2-v6, v7 false alarms 4 → 2, both loop order
445
+ simulations in idioms the first build missed (a simulation inside a function over precomputed signal indices with
446
+ a dataclass config, and the next bar read through a row alias `nxt = bars.iloc[t + 1]`). v7 is a dev set from that
447
+ scoring on, so v7 numbers are in-sample too.
448
+ - Every other corpus item (`tests/`, `eval/`, `examples/`, every red-team block): no high or medium finding lost or
449
+ added against v0.4. Rescans against v0.4: this repository and the earlier internal codebase, no high or medium added
450
+ or removed outside `tests/corpus_v05`.
451
+ - Speed: 200 KB in 1.2 s; the red team's worst-case shapes (rt10) about 2.3-2.7 s, the 262 KB stress file about 2.5 s.
452
+ Two older scans were made cheaper on the way (the test-set selection loop check now runs its cheapest filter
453
+ first; parameter defaults use a one-pass index).
454
+ - Red-team round 12 (BLOCKED at e07293a; `tests/corpus_v05/rt12_*.txt`, 24 leaky and 9 correct blocks, added
455
+ before the fixes): the loop-order exemption did not enforce what this README claimed. Fixed: R01/R02 (signal indices
456
+ from future data), R03/R04 (fill or exit values feeding decisions), R06 (a row-alias next-bar low deciding a new
457
+ entry), R11 (fills priced at the next bar's low), S02 (labels saved and loaded back as a signal), and the
458
+ pre-existing false alarms K01 (buy-stop entries), K02 (trailing stop in a `while` loop), K07 (a lag helper returning
459
+ `(a - b).shift(1)`). In-sample: 23/24 leaky blocks flagged (R10 is a deliberate exemption, above), 0/9 correct blocks
460
+ flagged. The exact enforced conditions are under Known limitations.
461
+ - Red-team round 13 (BLOCK at bc95ea6, narrow; `tests/corpus_v05/rt13_leaky.txt`, 8 leaky blocks, added before the
462
+ fixes): fixed X05/X07/X08 (lists holding exempt fill or exit values read in a decision), X02 (signal indices through
463
+ `self.<attr>`), and the agreed R10 conditions (far side of the open, booked at the level). In-sample: 8/8 flagged.
464
+ - Perf: the cyclic garbage collector is paused during a lint run (the run makes no reference cycles; in a long-lived
465
+ process GC passes over a large heap could double the time). The red-team stress shapes (232-268 KB) have a 4 s local
466
+ budget with the median of three runs when close; the 200 KB budget stays 3 s; every run must stay under the 8 s
467
+ web check timeout. On this laptop v0.4 and v0.5 time the same on these shapes; both vary by about 1 s with load.
468
+ - Not attempted: the documented misses H2 and G13. Not done here: the sealed v8 run and the next red-team pass.
469
+
470
+ **v0.4 lint: documented misses turned into rules, measured in-sample during development** (2026-10-16). Nine items: six from the v0.3 backlog (same-bar open fills, normalization inside
471
+ `apply`/`transform`, pivot keeping the forward flag, `query`/`eval` strings, `np.copysign`, LA13 false alarms on our own
472
+ code) and three false-alarm classes the coordinator described from a fresh sealed set (fill-condition models,
473
+ label-only loops, bar construction with default resample labels). The builder never read that sealed set or any red-team
474
+ probe folder. The acceptance criteria and the dev twins (`tests/corpus_v04`) were committed before the lint changed
475
+ (`tests/corpus_v04/PREREGISTRATION.md`; the three sealed-set classes are its addendum 1, committed before the fix).
476
+ **Every number below is in-sample**: the rules were changed until these files passed, so they show the rules work on
477
+ the patterns as described, not how v0.4 does on new code.
478
+ - Dev twins, v0.3 (main at b5e8d1c) then v0.4: leaks flagged as expected **13/41 → 41/41**; correct files with a high or
479
+ medium finding **13/48 → 0/48**. The six backlog items: 5/31 → 31/31 and 5/36 → 0/36. The three sealed-set classes:
480
+ 8/10 → 10/10 and 8/12 → 0/12 (v0.3 flagged all three fill models, the triple-barrier labelers and the minute-bar
481
+ builder). The last six twins (addenda 2 and 3) came from the coordinator's scores of this PR on the held-out sets:
482
+ - Addendum 2: a labeler whose output reaches only summaries (`y.value_counts(normalize=True)`, `describe`, `plot`,
483
+ `to_csv`) was still LA13 high. The exemption now accepts summary and file sinks as well as labels, as long as there
484
+ is label evidence (a label-named output, call-site target or function name) and no feature, signal, position,
485
+ product or model use.
486
+ - Addendum 3: the caller also aligned the features to the labels' dates (`X = feats.loc[y.index].dropna()`).
487
+ Index-only reads (`y.index`, `reindex(y.index)`, `len(y)`, `y.shape`) are now neutral; value reads (`X["lab"] = y`,
488
+ `concat([X, y])`, `y.values`) still break the exemption.
489
+ - Red-team round 10 (blocked 6659762; `tests/corpus_rt10`, 40 leaky and 22 correct blocks, added before the
490
+ fixes): four regressions against v0.3 fixed (L1, L3, G2, G7), plus H1, L2, L5 and F3 (a missing-data check on a
491
+ later bar no longer stops the scan of the later conditions in the same loop). 0/22 correct blocks flagged. Four
492
+ documented misses (H2, H5, J5, Q1; see Known limitations). These blocks are now in-sample too.
493
+ - Red-team round 11 (APPROVE at 1fb35fc on one condition; `tests/corpus_rt10/r11.txt`, 12 leaky blocks): G9, an exit
494
+ priced at the next bar's high through a shifted multi-column frame, was only "possible" (v0.3: high) and is high
495
+ again. 11/12 flagged; G13 (a numpy fill model with offset slices) is a documented miss, also missed by v0.3.
496
+ - Held-out sets, scored by the coordinator (not seen by the builder) on earlier builds of this PR: no lost catches
497
+ against main; 0 false alarms on four sets; on the set that produced the three classes, 3 → 1, the label-only case
498
+ that addenda 2 and 3 target. The final code has not been re-scored.
499
+ - Two of the v0.3 false alarms on the clean twins were in existing rules the twins hit by accident, and are fixed
500
+ rather than the twins edited: a forward column joined onto a feature frame that is fitted only through an explicit
501
+ column list that doesn't name it (LA10 medium → possible), and a decile spread of a wide forward frame selected with
502
+ a past-only mask (`fw[q > 0.9].mean(axis=1)`, LA10 high → possible).
503
+ - Red-team repros caught since v0.4 (they were documented misses): `pivot(values="fwd_ret")` (rt6 A04, A05),
504
+ `np.copysign` (rt7 E01), `frame.eval("t * b")` (rt7 E04), `frame.query("m > 0")["b"]` stored as `strat` (rt9 W06,
505
+ medium).
506
+ - Every other corpus (all leaky and clean files and red-team blocks in `tests/`, `eval/` and `examples/`, 735 items):
507
+ no high or medium finding was lost and none was added, compared with main.
508
+ - Rescan against main. **This repository** (841 Python files): removed the two LA13 highs on our own code
509
+ (`research/nba_research/phase0b.py` `volume_peak_offsets`, now clean because `s + lo` with the default `lo=-2` is the
510
+ past; `auditor/nba_auditor/path_audit.py:84`, a character loop over a string). Added: none outside `tests/corpus_v04`.
511
+ **831 files from an earlier internal codebase**: 0 added; 2 LA13 highs became "possible" (two sweep scripts skip a
512
+ rebalance when its realized return is NaN: a missing-data check on the booked outcome, not a signal input; the
513
+ "possible" keeps the delisting caveat). Still high on our own code, documented rather than exempted (no principled rule
514
+ found): `research/nba_research/data/resolver.py:409` (interval bookkeeping that reads the next plan entry) and
515
+ `research/nba_research/recorder/house.py:152` (the bad-print check, which looks at the next session to see whether a
516
+ spike reverses). `auditor/tests/test_trade_log.py:100` is a deliberate leak in a test and stays high.
517
+ - Speed: 200 KB of code in 1.2-1.3 s (v0.3: 1.1 s) on the company laptop; the red team's worst-case shapes are
518
+ under Known limitations (performance guards).
519
+ - Not done here (merge gate, RETROS #23): the result on a sealed set this build has never seen, and a red-team pass
520
+ that writes new cases aimed at each new exemption (the fill model, label-only loops, same-bar resample columns,
521
+ string loops, missing-data checks, parameter defaults, the literal-column-list downgrade, bucket pivot tables).
522
+
523
+ **Our own corpora** (in-sample: they were used during the build). Numbers below are as of v0.2; the CO01 cap moves
524
+ the cost-only files from medium to "possible", as listed above.
525
+ - A separate agent that never saw the lint code wrote 35 leaky and 25 clean look-alike snippets from the category
526
+ list (`tests/corpus_v02_holdout`). First measurement, before any fix it prompted: 31/35 leaks flagged at high or
527
+ medium (v0.1: 11/35); 4/25 clean files had a high or medium finding, all existing v0.1 false alarms (v0.1: 5/25).
528
+ Now 35/35 and 0/25.
529
+ - Development set (`tests/corpus_v02`, 54 leaky, 60 clean): 54/54 and 0/60.
530
+ - Red-team review of PR #8 (`tests/corpus_rt3`, 35 leaky, 87 clean): 16/35 and 0/87 (v0.1: 5/35, 15/87).
531
+ - The 173 Python files in this repository: the only new high or medium findings are the two bugs in
532
+ `examples/leaky_to_clean` that v0.1 missed. 837 files from an earlier internal codebase: v0.2 adds 8 (early
533
+ stopping on the test year in two training scripts, EPS merged by fiscal period end, four return calculations with
534
+ no costs next to performance figures) and drops 11 v0.1 findings that were false alarms on review.
535
+
536
+ What this means:
537
+ - The auditor rarely returns "No red flags found" on noise, **as long as `n_trials` is honest**. Understate it and
538
+ the audit is fooled the same way you are. `check_code` can count a visible parameter grid for you (LA11).
539
+ - Market exposure isn't skill. A leveraged index never gets "No red flags found": without a benchmark, because of
540
+ the benchmark rule; with one, because it has no alpha.
541
+ - Five years of data often can't confirm even a genuine edge. A single strategy with a true Sharpe of 1.0,
542
+ audited with `n_trials=10`, reads as consistent with selection noise about a third of the time. That is the
543
+ honest answer for that much data, not a bug. Read "Major red flags" as "this evidence is consistent with
544
+ noise, selection or costs", not as proof that the strategy has no edge.
545
+
546
+ **Provenance.** v0.1's first thresholds were not tuned to this table. Two independent red-team passes found
547
+ verdict bugs, including autocorrelated returns, the benchmark being ignored, the default frequency, intraday
548
+ series and a hard-coded cost rule. The fixes changed the verdict rules, and the table above was re-run after them.
549
+ All of it is in the git history; there was no separate pre-registration file.
550
+
551
+ ## Install
552
+
553
+ ```bash
554
+ cd auditor
555
+ python -m venv .venv # Python 3.12
556
+ .venv/Scripts/python -m pip install -r requirements.txt # (Linux/macOS: .venv/bin/python)
557
+ .venv/Scripts/python -m pip install -e . --no-deps
558
+ .venv/Scripts/python -m pytest -q
559
+ ```
560
+
561
+ On the company machine, prefix pip with `env -u SSL_CERT_FILE -u SSL_CERT_DIR`. The antivirus also
562
+ corrupts PyPI's `/simple/mcp/` page; if `pip install mcp` fails with `InvalidChunkLength`, install the wheel URL
563
+ from `https://pypi.org/pypi/mcp/json` directly.
564
+
565
+ ## Use as an MCP server
566
+
567
+ The server is `nba-auditor` and runs over stdio. Start it with the `nba-auditor-mcp` command, `nba-auditor mcp`,
568
+ or `python -m nba_auditor.mcp`. It is built on the official MCP Python SDK v2 (`mcp.server.MCPServer`, the v2 name
569
+ of v1's FastMCP). All tools are marked read-only.
570
+
571
+ **Setup for Claude Code, Claude Desktop, Cursor, VS Code and other clients: [docs/quickstart.md](docs/quickstart.md)**
572
+ (every snippet there is tested in CI). The short version for Claude Code:
573
+
574
+ ```bash
575
+ claude mcp add nba-auditor -- /absolute/path/to/.venv/bin/nba-auditor-mcp
576
+ # Windows: C:/path/to/.venv/Scripts/nba-auditor-mcp.exe
577
+ ```
578
+
579
+ **Available after release** (the PyPI package `backtest-auditor` isn't published yet; POLICY_DECISIONS #21-24):
580
+
581
+ ```bash
582
+ claude mcp add backtest-auditor -- uvx backtest-auditor
583
+ ```
584
+
585
+ and `{"command": "uvx", "args": ["backtest-auditor"]}` in Claude Desktop's or Cursor's `mcpServers`. With no
586
+ arguments the `backtest-auditor` command starts the MCP server; with arguments it is the CLI.
587
+
588
+ Then ask, for example: *"Find look-ahead bias in this repo with check_path"*, or *"Audit this backtest with
589
+ audit_backtest: strategy.py, backtest.csv, spy.csv as the benchmark, n_trials=40, turnover 0.3."*
590
+
591
+ A full worked example (a leaky strategy, its audit, the fix and the re-audit) is in
592
+ [examples/leaky_to_clean](examples/leaky_to_clean/README.md).
593
+
594
+ ## Use as a library or CLI
595
+
596
+ ```python
597
+ from nba_auditor import check_code, check_path, audit_backtest, audit_returns, audit_trade_log, explain
598
+
599
+ rep = check_code(open("strategy.py").read())
600
+ print(rep.to_markdown()) # or rep.to_dict() for JSON
601
+
602
+ res = check_path("strategies/") # dict: per-file findings, line ranges, suggested_fixes, skipped
603
+ res = audit_backtest("strategy.py", "returns.csv", benchmark_csv="spy.csv", n_trials=40, turnover=0.4)
604
+ print(res["verdict"], res["verdict_note"])
605
+
606
+ rep = audit_returns("returns.csv", freq="D", turnover=0.4, n_trials=40, benchmark="spy.csv")
607
+ print(rep.verdict, rep.reasons, rep.score)
608
+ ```
609
+
610
+ ```bash
611
+ python -m nba_auditor check strategy.py
612
+ python -m nba_auditor path strategies/ --json # check_path
613
+ python -m nba_auditor backtest --code strategy.py --returns returns.csv --benchmark spy.csv --n-trials 40
614
+ python -m nba_auditor returns returns.csv --n-trials 40 --turnover 0.4 --json
615
+ python -m nba_auditor trades trades.csv --prices closes.csv
616
+ python -m nba_auditor explain LA07
617
+ python -m nba_auditor mcp # the MCP server over stdio
618
+ ```
619
+
620
+ **Input conventions**
621
+ - Returns are simple per-period decimals (0.01 = 1%). Use `units="percent"` for percent values, or `input_kind="equity"` for an equity curve.
622
+ - `freq` defaults to `auto` (inferred from the dates). A stated frequency that contradicts the dates is an error.
623
+ - Turnover is one-way: sum of |change in weights| per period. Cost in bps is charged per unit of turnover.
624
+ - Returns are treated as gross unless `returns_are_net=True`. When turnover is given without a cost, the headline result charges 10 bps.
625
+ - `n_trials` counts every variant you tried (parameters, universes, rules). By default the DSR assumes the trials' Sharpe ratios vary the way zero-skill strategies would (variance 1/T). If you know the actual variance, pass it as `trials_sr_variance` (annualized).
626
+
627
+ ## Known limitations (v0.3, with the v0.4, v0.5 and v0.6 lint changes marked)
628
+ - **v0.5 loop order simulation: exactly what is exempt** (after red-team round 12). A condition in a bar loop over
629
+ `i` is not reported only when all of these hold; otherwise it is judged as before (high).
630
+ 1. Every term is either past-only (no read of a later bar, no helper function defined in the file, no name tainted
631
+ by a later bar's data other than bar `i+1`'s open) or a fill comparison: bar `i+1`'s low or high, exactly one bar
632
+ ahead, read directly (`lo[i + 1]`, `df["low"].iloc[i + 1]`) or through a next-bar row alias (`nxt = bars.iloc[t + 1]`,
633
+ also `.loc` and `rows[k + 1]`; not a single price copied into a variable, `nl = lo[i + 1]`), against a level known
634
+ before that bar's high and low (past data, or bar `i+1`'s open).
635
+ 2. The fill comparison points the filling way: the low at or below the level (buy limit, sell stop) or the high at
636
+ or above it (sell limit, take-profit, buy stop). The other direction (`nxt["low"] > level`) means the order did
637
+ not fill: it is allowed only as a skip (`continue`, `break`, `pass`), never to book a trade (rt12 R06).
638
+ 3. The branch prices the fill or exit from the order's level (`min(limit, o[i + 1])`, `max(stop, o[i + 1])`,
639
+ `exit_px = tp`, the limit itself). A price at a later bar's low or high itself (`c[i + 6] / lo[i + 1]`) is not
640
+ exempt (rt12 R11). Later-bar reads in the branch must be prices assigned to fill, exit or trade-log names, plus the
641
+ fill's timestamp (`bars.index[i + 1]`); a size, weight, volume or position from a later bar is not exempt.
642
+ 4. Fill and exit values (and names derived from them) reach only `append`/`extend`, a return value, or a
643
+ print/log/file call; never a condition, a comparison, or another variable used that way, in this iteration, a
644
+ later one or after the loop (rt12 R03 cooldown on the last trade's return, R04 keeping only winners). The list
645
+ they are appended to inherits this (rt13): it may reach only summaries and sinks (`mean`, `len`, `sum`, `print`,
646
+ a `DataFrame`/`Series`, a return value, a file, and names built from those). A read of it in an `if`/`while`
647
+ test, a comparison, a comprehension filter, a loop over it, or a `break`/`continue` condition
648
+ (`not trades or trades[-1] > 0`, `if sum(trades) < -0.2: break`, `[t for t in trades if t > -0.02]`) removes the
649
+ exemption, and LA13 is reported high at that line. Reads of its size are not decisions (v0.6, the count-only trade
650
+ cap): `len(trades)` anywhere, and truthiness tests (`if trades`, `not trades`, an `and`/`or` operand that is
651
+ itself only a test), because how many fills have happened so far is known in real time.
652
+ 5. Decided from past data: an enclosing past-only condition, an `and` term that reads past bar data (`not in_pos`
653
+ alone is state, not a decision), an `elif` of another fill check, or a loop over precomputed signal indices
654
+ (`for i in idx`, not `range`) where nothing forward-looking feeds the indices, followed through names, function
655
+ parameters to their call sites, object attributes (`self.idx = ...` in a class, `s.idx`; every assignment to
656
+ that attribute name is checked, and an attribute with no visible assignment gives no exemption; rt13 X02),
657
+ `np.flatnonzero`, `np.where(...)[0]`, `.nonzero()`, `.index`, comprehensions, and positional slices that start at
658
+ different bars (`c[5:] / c[:-5]`; rt12 R01, R02). If the indices are tainted there is no exemption at all. An unconditional resting order on every bar (`limit = c[i] * 0.99` each bar, or a
659
+ buy-stop at the 20-bar high, rt12 K01) counts as decided only when its fill is priced from its level.
660
+ - When conditions 1-4 hold and the order was explicitly decided but the fill isn't priced from the level, the
661
+ finding is "possible".
662
+ - Deliberate (agreed with the red team in round 13): a level set from the fill bar's open (`limit = o[i + 1] * 0.99`,
663
+ rt12 R10, directly or through names assigned earlier in the same branch) is exempt only when it sits on the far
664
+ side of that open (below it for a buy, `o * k` with k < 1 or `o - x`; above it for a sell or take-profit, k > 1 or
665
+ `o + x`) and the fill is booked at the level itself, not `min(limit, o[i + 1])`. Otherwise it is high. A stop derived from an entry at bar `i+1`'s open and checked inside an inner
666
+ loop over later bars (a trailing stop, rt12 K02) is not reported by the outer loop.
667
+ - Not exempt: event studies and order simulations that check bars beyond `i+1`.
668
+ - **v0.6 rules lean on names and visible flow.** "Reaches a signal, position, PnL or model input" is decided by
669
+ following assignments (and helper return values) to names the lint classifies as signals or PnL, or to model inputs.
670
+ A transform that feeds a decision through names it can't classify stays "possible". The survivorship patterns only
671
+ fire on price- or return-named wide frames, and the full-sample estimate rule (LA05) only when the file has no split,
672
+ training window or walk-forward names and the positions are booked against returns. Two-sided transforms re-run on
673
+ data up to each date inside a loop are treated as one-sided without checking the window.
674
+ - **Static analysis can't see everything.** Measured recall at high or medium severity on leaky snippets the
675
+ builder had not seen (second red-team pass, 2026-10-16):
676
+ - Held-out set 1 (32 leaky, 16 clean), before fixes: **15/32 (47%)**, 1 false alarm in 16 clean files.
677
+ - Held-out set 2 (12 leaky, 8 clean), written after the set-1 fixes and measured before any further change:
678
+ **8/12 (67%)**, no false alarms.
679
+ - Both sets are now regression tests (`tests/corpus_rt2`). v0.2 and v0.3 flag all 44, but that is in-sample.
680
+ v0.2's sealed-set numbers are under "Calibration" above: 60-78% recall, which still means a clean `check_code`
681
+ result is weak evidence. v0.3 on sealed set v5: 0/70 false alarms and 21/50 (42%) recall, with recall about 40% (contaminated by two leak
682
+ classes fed back during review; precision is not contaminated). See "v0.3 on sealed set v5" above.
683
+ - Still missed (from the PR #8 red-team review and our own tests):
684
+ - LA14: hyperparameter search with Optuna or `set_params` scored on the test set, and selection through a
685
+ helper that returns the test Sharpe.
686
+ - LA15: period ends renamed to `date` before the merge, `to_period` keys, period ends read from the index.
687
+ - SB01: `dropna(axis=1)` on a wide price frame, `last_valid_index`, keeping names with a full count of dates.
688
+ - LA12: `pd.Grouper(freq=...)` bars joined back.
689
+ - LA02: `iloc[::-1]` on a whole frame (often a fix for newest-first files, so it is not flagged).
690
+ - LA09: `qcut`/`rank`/`scale` on a column with no visible time-series operation in the file (we can't tell a
691
+ time series from one date's cross-section), `describe()` quantiles.
692
+ - CO01: a cost basis used for something other than trading costs can hide the finding.
693
+ - Limit fills at the day's low, resampled bars realigned in another file, and leaks hidden in other files or in
694
+ the data itself.
695
+ - **v0.2 trades some recall for precision.** These are deliberate choices:
696
+ - Full-sample statistics inside functions count only when the column is visibly a time series (built with
697
+ `rolling`, `shift`, `pct_change` and similar in the same scope). Functions run per date (`groupby('date').apply`,
698
+ loops over dates) are skipped. A cross-sectional scoring function that ranks one date's stocks is not flagged.
699
+ - Same-bar *close* fills in loops are medium and only for entries. Exits at the close on a close-based stop are
700
+ common and close to executable, so they are not flagged.
701
+ - vectorbt's default fill at the close is still "possible"; only an explicit `price=close` is medium.
702
+ - Anything we can't line up is "possible" at most: positions written through masks or variable offsets, engine
703
+ settings passed through `**kwargs` (they may carry the fees), resample labels passed through `**kwargs`, and
704
+ exact merges on a period end that are not forward-filled afterwards.
705
+ - LA15 counts a reporting lag only when it is applied to the period-end column or index itself:
706
+ `f['period_end'] + <offset>`, `f['period_end'].add(<offset>)`, `assign(period_end=... + <offset>)`,
707
+ `f.index = f.index + <offset>` (or `+=`), or `f.shift(n, freq=...)` / `tshift(n)` with n > 0. An offset is a positive
708
+ `pd.Timedelta`, `DateOffset`, `pd.offsets.*` (e.g. `QuarterEnd(1)`, `BDay(45)`) or `to_timedelta`, or a name bound
709
+ to one. Not lags: `merge_asof(..., tolerance=...)` and other keyword arguments, zero offsets that only snap a date
710
+ (`MonthEnd(0)`), negative or zero `shift(freq=...)`, and other index changes (`tz_localize`, `tz_convert`). Early stopping on a set named `holdout`/`oos` is not flagged when a separate `test` set is
711
+ scored, and loops that redefine the test window each iteration are treated as walk-forward evaluation.
712
+ - **v0.3 trades more recall for precision** (LA05, LA10, CO01). Deliberate choices, each a possible miss:
713
+ - LA01/LA10: a value computed from forward data under a neutral name (`up`, `oo`, `tmp`) is high if it reaches a
714
+ signal or position, and medium if it reaches a model input. Model inputs are: the first argument of
715
+ `fit`/`predict`, a feature-named assignment, the return value of a feature builder, a top-k pick (`nlargest`,
716
+ `idxmax`), or a PnL product as a comparison. Used some other way, it is "possible" (v0.2: medium).
717
+ - LA10: forward data used as the outcome is not an input: `groupby(...)[fwd].mean()` and other aggregations,
718
+ `df.loc[mask, fwd]` / `df[mask][fwd]` with a mask built without forward data, and `position * fwd_ret`.
719
+ Results built from these are "possible" when their name only suggests a leg or a spread (`long_leg`, `spread`,
720
+ `trades`) and are not reported otherwise. An outcome is an input again, and high, when it is:
721
+ - put back onto rows by `map`/`merge`/`reindex`/`transform`, a column assignment or a comparison;
722
+ - assigned to a signal, position or weight name (`position`, `weight`, `score`; not names that only say long,
723
+ short or trade);
724
+ - a non-return factor (rolling statistic, sign, rank, clip, ratio) in a product with a return. Products are
725
+ `*`, `@`, `.mul`/`.multiply`, `np.multiply`, `np.dot`, and `np.where(c, r, -r)` / `r.where(c, ...)`.
726
+
727
+ Also high: a transform written inside the factor (`bench * np.sign(mkt)`,
728
+ `np.sign(fwd.rolling(5).mean()) * fwd`), and a return series flipped or overwritten on rows chosen from forward
729
+ data (`strat[tilt < 0] *= -1`, `strat.loc[tilt < 0] = -bench`).
730
+
731
+ Missed:
732
+ - an outcome read back some other way (`agg.loc[d]` inside a loop);
733
+ - a product inside a helper function (`backtest(w, r)`, rt6 A06).
734
+
735
+ Caught since v0.4 (were missed in v0.3): `pivot(values="fwd_ret")`, `np.copysign(bench, mkt)`,
736
+ `frame.eval("t * b")` and `frame.query("m > 0")["b"]` under a strategy name.
737
+ - v0.4 `query`/`eval`: only literal strings are read (`df.eval(expr)` with `expr` a variable is not), one statement
738
+ per string. A frame built once from a dict literal maps its column names back to the source expressions; other
739
+ frames read `name` as `frame["name"]`. A query is a row filter like `frame[cond]`, so an inline future-data
740
+ condition is judged like `bench[mkt > 0]`: not counted unless the result is stored, unaggregated, under a strategy
741
+ name (`strat`, `strategy`, `pnl`, `equity`, `backtest`, `bt`, `wealth`; not `perf`, `port` or `net`), which is medium.
742
+ - v0.4 pivot: the `values=` column (keyword, or positional for `pivot_table`) is read. A `pivot_table` whose index or
743
+ columns name buckets (`q`, `decile`, `quintile`, `bucket`...) is an outcome table, like `groupby(q)[fwd].mean()`.
744
+ - v0.4 fill model (LA01/LA10): only the comparison inside the fill mask is exempt. The mask must be `<order decided
745
+ without future data> & (<next bar's low or high, exactly one bar ahead> <op> <a price known at t>)`, and every use
746
+ of the mask must select, zero or count trade returns (`r[filled]`, `r.where(filled)`, `np.where(filled, r, 0)`,
747
+ `filled.sum()`). Any other use (a signal, a position, a feature, a fit, `astype`, a shift) makes it an input
748
+ again, as before. Every other read of the next bar's low or high is still an input: sizing from
749
+ `limit_px - low.shift(-1)` next to the fill mask is LA10 high (rt10 G2), and an entry or exit priced at
750
+ `low.shift(-1)` / `high.shift(-1)` is LA01 high (rt10 G7), also when the price is written inline in the trade
751
+ return selected by the mask. A column read from a frame shifted as a whole (`best = df[["low", "high"]].shift(-1)`,
752
+ then `best["high"]`, `best.loc[:, "high"]` or `best.high`) is read exactly like that column shifted by -k, for the
753
+ price rule, the sizing rule and the mask exemption alike (rt11 G9). A two-bar-ahead low, a fill flag without an
754
+ order term, or a fill flag with a fill-sounding name (`will_fill`) is still flagged. Missed: a numpy fill model
755
+ written with offset slices (`lo[1:] <= lim[:-1]`, sizing from `lo[1:]`, rt11 G13).
756
+ - v0.4 LA13: a slice whose start is in the past and end in the future (`x[i - 2: i + 3]`) is not read as a
757
+ future read (unchanged from v0.3). The label-only exemption follows each loop output to its uses: returned by a
758
+ function (optionally wrapped in `pd.Series(...)`) whose every call site assigns a label-named target, one passed
759
+ as a label, or any non-signal target when the function's name says label or target; and from there only into
760
+ `fit`'s y, `label=`, a score, a label- or evaluation-named name, a summary (`value_counts`, `describe`, `mean`,
761
+ `groupby(...).mean()`, `plot`) or a file (`to_csv`, `to_parquet`), with no product or comparison on the way.
762
+ Index-only reads (`X.loc[y.index]`, `reindex(y.index)`, `len(y)`, `y.shape`) are alignment and don't count.
763
+ Since red-team round 10, a label written as a column (`px["target"] = labeler(...)`) is followed through its
764
+ column uses exactly like a name, and a bare call statement is a sink only when it is a print, plot or file write:
765
+ `positions.extend(labels)`, `.append`, `.insert`, `.update` and `df.insert(1, "lab", labels)` are writes into the
766
+ other name or column, which is then followed in turn (rt10 L1, L2, L3; L5, a `where` on the labels, is caught too).
767
+ - v0.4 performance guards: call sites, name reads, the statements that read each name or column, label uses and
768
+ the "feeds a signal" graph are each indexed once per file, and the open-fill and fill-mask rules skip files or
769
+ values that can't match. The red team's worst cases (rt10: 1,100 labeler functions at 232 KB, 1,600 label loops at
770
+ 247 KB, 1,600 fill models at 268 KB) run in about 2.5 s on the company laptop, and their 262 KB symbolic/container
771
+ stress file in 2.3 s, the same as main (main: 2.2-2.6 s for the first two; the red team measured 165 s on main
772
+ for the fill models, which hit a quadratic scan that predates v0.4). `tests/test_redteam_rt10.py` times each shape against the 3 s local budget and the 8 s ceiling.
773
+ - Still missed after round 10 (documented, `KNOWN_MISSES` in `tests/test_redteam_rt10.py`):
774
+ - H2: a loop with no condition that writes forward values (`label_max[i] = c[i + 1: i + 21].max() / c[i] - 1`)
775
+ under a label name, then used as a feature. It isn't LA13's pattern, and the loop's output isn't tracked as
776
+ forward data.
777
+ - H5: a helper loop that writes forward values, smoothed into a signal outside the helper.
778
+ - J5: forward columns merged in from a second frame (`px.merge(labels, on=[...])`), then listed as features.
779
+ - Q1: `strategy = df.query("fwd_ret > 0")` followed only by a mean, which reads like a capture statistic. Anything else (a feature, a
780
+ signal, a product with a return) keeps the high. Event studies that branch on post-event values are flagged.
781
+ - v0.4 LA09 in `apply`: the receiver must be visibly a time series (built with rolling, shift, pct_change and similar
782
+ here or where its base frame was assigned) and feed a signal, a feature or a model for medium; otherwise
783
+ possible.
784
+ - LA10 containers: a frame built from columns that stores a forward column is not reported where it is built.
785
+ It is reported (high) where a forward column survives into `fit`/`predict`, a training dataset, a feature matrix
786
+ or a signal.
787
+ - Names come from dict keys, `.rename("x")` and `keys=[...]`. They are kept only through two kinds of change:
788
+ - operations known to keep names: row filters, `dropna`, `fillna`, sorting, index changes, `astype`, `copy`,
789
+ `to_numpy`, `assign`, and a `drop` that is resolved by name;
790
+ - renames with literal names: `rename(columns={"a": "b"})` (also with `inplace=True`) and
791
+ `data.columns = ["a", "b"]` while the column order is known.
792
+ - Any other frame-level operation on a container makes its column names unknown. This includes in-place calls,
793
+ `add_prefix`, `set_axis`, `rename(mapper, axis=1)`, `rename(columns=lambda ...)` and
794
+ `.columns = .columns.str.replace(...)`.
795
+ - A forward column with an unknown name survives every later explicit column list or drop.
796
+ - Positional selections (`iloc[:, :-1]`, `arr[:, -1]`) are resolved while the column order is known. The order
797
+ is updated or dropped as columns move:
798
+ - a literal column list (`data[["c", "a", "b"]]`, `.loc[:, [...]]`, `reindex(columns=[...])`) sets the order
799
+ to that list;
800
+ - a resolvable positional selection (`iloc[:, ::-1]`) reorders it;
801
+ - any other column-axis change makes it unknown: `sort_index(axis=1)`, a transpose (`.T`), a column subscript
802
+ that isn't a literal list (`data[sorted(data.columns)]`, `data[data.columns[::-1]]`), a non-literal
803
+ reindex, or any operation the lint can't read.
804
+ With the order unknown, a later positional selection keeps the forward column.
805
+ - LA10 masks: a return selected by a mask built from future data (`bench[long]`, `bench.loc[short]`, `bench[~m]`,
806
+ also through `.values`, `.to_numpy()` and `m[m].index`) is high when the mask has a position name and medium
807
+ when it has a neutral one (`ok`, `up`). An inline comparison (`bench[mkt > 0]`) is not counted, so up/down
808
+ capture statistics stay clean; a neutral-named capture mask gets the medium.
809
+ - A row shift of the whole frame (`data.shift(5)`) lags every stored column. A forward column whose lead is
810
+ fully cancelled (a 5-bar lead, `shift(5)`) no longer counts; a partial lag (`shift(1)`) still does.
811
+ - Look-ahead cancel scope: a lead and a lag written with the same expression (`shift(-h)` then `shift(h)`) cancel
812
+ only when every name in it is bound once and is not a loop variable. A module constant shadowed by a function
813
+ parameter or local is not used. Missed: a helper defined above the line that creates the forward column it reads
814
+ (the pass runs in source order).
815
+ - LA10: a model trained on the label (`fit(X, y)`, `lgb.Dataset(X, label=y)`, `xgb.DMatrix`, `Pool`) no longer
816
+ passes "future data" on to the model and its outputs. Whether the training rows overlap the evaluated rows, or
817
+ end less than a label horizon before them, is LA05/LA14's job, and fits inside functions are not checked there.
818
+ - Look-ahead size: constants (`H = 5`, `cfg.horizon`) and simple arithmetic (`-H`, `H + 1`, `-1 * 5`) are
819
+ resolved. Any other expression cancels only the same expression (`shift(-horizon)` then `shift(horizon)`). A
820
+ different unknown lag counts as 1 bar.
821
+ - LA10 column lists: drop lists and feature lists are resolved per column (string lists, `+`, single-assignment
822
+ constants, comprehensions over `df.columns` with `startswith`/`endswith`/`in`/`==`/`re.match`, and
823
+ `df.columns.str.startswith`/`isin` masks). A list the lint can't resolve (built in a loop, read from a file,
824
+ reassigned) gives "possible" for the forward columns it may keep, never high.
825
+ - LA05: a slice or date mask bounded above (`.loc[:cut]`, `.iloc[:k]`, `[:k]`, `.loc[a:b]`, `df.date < cut`,
826
+ `between`) counts as a training subset, as does a frame assigned once from one. A start filter (`.loc['2000':]`,
827
+ `[20:]`, `df.index >= '2000'`) or a test-window mask does not. The lint does not check that the evaluation uses
828
+ the later rows. Missed as a result:
829
+ - an in-sample score on the same slice;
830
+ - fits on all rows but the last (`.iloc[:-1]`), which count as subsets;
831
+ - overlapping train and test slices (`iloc[:3000]` vs `iloc[2500:]`);
832
+ - a model fitted and scored on the same `iloc[:-1]` rows.
833
+ - Also still missed from the round 5 review: a forward-filtered ticker list used through `isin`, an
834
+ `inplace=True` drop on a different frame than the one fitted, and a forward column re-joined under another name
835
+ after a drop.
836
+ - CO01: never above "possible" (3 points off the score). Missing costs are a realism caveat, not a lookahead
837
+ error, and the returns may already be net.
838
+ - **Suggested fixes are narrow and change behavior on purpose.** `shift(-1)` becomes `shift(1)`, which uses older
839
+ data than the original author may want; `TimeSeriesSplit` gives different folds than `KFold`. The self-check only
840
+ shows that the lint no longer sees the pattern and sees nothing new; it doesn't run the code. Fixes aren't offered
841
+ for notebooks yet. The agent-fix eval is in-sample.
842
+ - **`check_path` resolves relative paths against the server's working directory**, which depends on the client;
843
+ agents should pass absolute paths. It has **no scope limit**: it reads any local `.py`/`.ipynb` file or folder the
844
+ user running the server can read (it never writes). Network (UNC) and device paths are refused, for `check_path`
845
+ and for every data-file argument.
846
+ - **Results quote file content.** Paths, snippets and suggested edits come from the files and are marked
847
+ `content_is_untrusted_data`; a file can contain text written to look like instructions. Snippets have comments
848
+ stripped. Data files (`.csv`, `.json`, `.txt` only) are never quoted in error messages.
849
+ - **The name heuristics can be fooled.** A label called `signal` will be misread. Test sets are recognized by
850
+ name (`X_test`, `test`, `oos`, `holdout`), validation sets by `val`/`valid`, and fundamentals dates by name.
851
+ - **It reads Python only.** Pine Script and other languages are not supported yet.
852
+ - **The verdict depends on `n_trials` being honest** (see the calibration table).
853
+ - **Trade-log survivorship checks are heuristics, not proof.** They flag symbols traded before the current
854
+ company used them (some symbols were reused, e.g. ARM, SNOW, CEG, META) and universes where no name ever
855
+ disappears. The built-in list of first-trade dates is short.
856
+ - **Trade logs without daily prices are only marked when positions trade.** Their Sharpe is overstated, so the
857
+ verdict can never be "No red flags found".
858
+ - **The cost model is linear in turnover.** No market impact, borrow fees or financing.
859
+ - **Intraday series are reviewed but never get "No red flags found".** The implausibility thresholds assume daily or
860
+ slower bars.
861
+ - **Verdict labels were renamed** from "Likely real / Inconclusive / Likely artifact" to "No red flags found / Some
862
+ red flags / Major red flags" (CEO decision: "Likely real" implied an edge). A compliance-checker pass on
863
+ 2026-10-16 edited the disclaimer and several explain texts; public launch copy still needs its own pass.
864
+ - **Input limits.** `check_code` accepts up to 1,000,000 characters; MCP data arguments up to 20,000,000
865
+ characters each. There is no wall-clock timeout; these limits keep a run to seconds on a laptop.
866
+
867
+ ## References
868
+ - Bailey & López de Prado (2014), *The Deflated Sharpe Ratio*, JPM 40(5). Worked example reproduced in `tests/test_stats_paper.py`: DSR 0.9004; 0.9505 at N=46; 0.9505 at N=88 with Normal returns.
869
+ - Bailey & López de Prado (2012), *The Sharpe Ratio Efficient Frontier*, J. of Risk 15(2). MinTRL examples reproduced: 59.895 months; 2.73, 2.83 and 3.24 years.
870
+ - Lo (2002), *The Statistics of Sharpe Ratios*, FAJ 58(4). Newey & West (1987), Econometrica 55(3).
871
+ - See `explain(<id>)` for the references behind each check.
872
+
873
+ <!-- mcp-name: app.nothingbutalpha/backtest-auditor -->