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.
- backtest_auditor-0.6.0.dist-info/METADATA +873 -0
- backtest_auditor-0.6.0.dist-info/RECORD +22 -0
- backtest_auditor-0.6.0.dist-info/WHEEL +5 -0
- backtest_auditor-0.6.0.dist-info/entry_points.txt +4 -0
- backtest_auditor-0.6.0.dist-info/licenses/LICENSE +201 -0
- backtest_auditor-0.6.0.dist-info/top_level.txt +1 -0
- nba_auditor/__init__.py +28 -0
- nba_auditor/__main__.py +3 -0
- nba_auditor/backtest_audit.py +194 -0
- nba_auditor/catalog.py +445 -0
- nba_auditor/cli.py +124 -0
- nba_auditor/fixes.py +998 -0
- nba_auditor/lint.py +8017 -0
- nba_auditor/mcp/__init__.py +288 -0
- nba_auditor/mcp/__main__.py +3 -0
- nba_auditor/parsing.py +205 -0
- nba_auditor/path_audit.py +418 -0
- nba_auditor/report.py +159 -0
- nba_auditor/returns_audit.py +552 -0
- nba_auditor/safety.py +91 -0
- nba_auditor/stats.py +226 -0
- nba_auditor/trade_log.py +304 -0
|
@@ -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 -->
|