Metadata-Version: 2.5
Name: quantlint
Version: 0.1.1
Summary: Find look-ahead bias in quantitative Python. Zero dependencies.
Project-URL: Homepage, https://tickbloom.com
Project-URL: Source, https://github.com/tickbloom/quantlint
Project-URL: Issues, https://github.com/tickbloom/quantlint/issues
Author: Tickbloom
License-Expression: MIT
License-File: LICENSE
Keywords: backtesting,bias,look-ahead,quant,static-analysis,trading
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# quantlint

**Find look-ahead bias in quantitative Python before it costs you money.**

Zero dependencies. Pure stdlib `ast`. Runs in under a second on a typical strategy repo.

```bash
pip install quantlint
python -m quantlint strategies/
```

```
[CERTAIN] TB-102  Centred rolling window includes future bars
  rolling(center=True) places the window symmetrically around the current bar, so
  roughly half of every computed value comes from bars that had not happened yet.
    strategies/signal.py:15  df["smooth"] = close.rolling(10, center=True).mean()

[LIKELY]  TB-108  Current-bar value used in a feature
  A windowed statistic is combined with the same series unshifted, so the expression
  reads the CURRENT bar's value — which is not known when the decision is made.
    strategies/signal.py:12  df["mom"] = close.rolling(20).mean() / close - 1

1 certain, 1 likely
```

---

## The problem

Your backtest showed a Sharpe of 2.8. Live it did 0.4. The strategy logic is fine — you've read it forty times.

The bug is usually one line like this:

```python
mom = close.rolling(20).mean() / close - 1
```

At time `t`, you're dividing by `close[t]` — the closing price of the bar you're currently deciding in. You don't have that number yet. If you did, you wouldn't need a momentum feature.

It never raises. It never looks wrong on a plot. And it biases results in the direction that makes you more confident, which is exactly the direction that costs money. Your linter has nothing to say about it, because as *code* it's perfectly correct.

That's the gap this fills.

---

## Rules

| ID | Confidence | Detects |
|---|---|---|
| `TB-101` | certain | Negative shift — `close.shift(-1)` |
| `TB-102` | certain | Centred rolling window — `rolling(center=True)` |
| `TB-103` | certain | Full-series aggregate in a feature — `price / price.max()` |
| `TB-104` | likely | Future-named column read as a feature input |
| `TB-105` | likely | Scaler or model `fit()` on the full series |
| `TB-106` | likely | `resample()` without explicit `label` and `closed` |
| `TB-107` | likely | Forward index offset — `df.iloc[i + 1]` |
| `TB-108` | likely | Current-bar value in a feature — `x.rolling(20).mean() / x` |

**Confidence is the design decision that matters here.** A static analyser dies from false positives, not from missed detections — one spurious warning and it gets uninstalled.

- **certain** → exit code 1. The pattern is a leak *by definition*. `close.shift(-1)` is tomorrow's close; there is no reading of it that isn't.
- **likely** → reported, exit code 0. Usually a leak, but has legitimate uses.

Only `certain` rules fail a build. A false failure costs you the user; a false flag costs them four seconds.

---

## Allowlisting

Forward-looking code is **correct** when you're building labels — a target column is *supposed* to read the future:

```python
df["target_1d"] = close.shift(-1) / close - 1   # quantlint: allow
df["target_1d"] = close.shift(-1)               # quantlint: allow TB-101
```

The comment works on the flagged line or the line above. Without this, every supervised-learning codebase reports dozens of findings on its target construction and the tool is worthless.

Writing *into* a future-named column is never flagged — only reading one as a feature input is.

---

## CI

```yaml
- run: pip install quantlint
- run: python -m quantlint strategies/
```

Exit codes: `0` clean (advisory flags may still print), `1` at least one certain leak, `2` bad usage. Add `--strict` to fail on advisories too.

Pre-commit:

```yaml
repos:
  - repo: https://github.com/tickbloom/quantlint
    rev: v0.1.0
    hooks:
      - id: quantlint
```

JSON for tooling: `python -m quantlint strategies/ --json`

---

## What it cannot do

Stated plainly, because a scanner that oversells itself is worse than none.

This is a **syntactic pass**. It reads your source; it does not run it. It cannot see through:

- a variable holding a shift amount — `n = -1; close.shift(n)`
- a leak inside a library you call
- an expression assembled at runtime
- data that was already contaminated before it reached your code

It catches the common written-down forms, which in practice is most of them. It is not a proof of correctness, and a clean run does not mean your backtest is honest.

`TB-108` does one-pass tracking of names bound to `.shift(n>0)`, so it stays quiet when you lagged correctly upstream. That analysis is shallow, which is exactly why the rule is `likely` rather than `certain`.

---

## Library use

```python
import quantlint

findings = quantlint.scan("strategies/")
if quantlint.has_certain_leak(findings):
    raise SystemExit("look-ahead leak — refusing to backtest")

for f in findings:
    print(f.id, f.severity, f.title)
    for loc in f.locations:
        print("  ", loc)
```

`Finding` is a plain dataclass with `.to_dict()`. No pandas, no config file, no network.

---

## Contributing

New rules are welcome, with one requirement: **every rule ships with a false-positive test.** A test proving the rule catches the bug is half the work; a test proving it stays silent on the legitimate version is the half that decides whether anyone keeps the tool installed.

Roughly half the existing suite asserts that clean code produces *nothing*. Please keep that ratio.

---

## Related

`quantlint` is the standalone scanner extracted from [Tickbloom](https://tickbloom.com), which adds market-data integrity scoring, a runtime guard, and audit reports you can send to a prop firm or allocator. This part is MIT and always will be — it's the piece that's most useful to the most people and the least sensible to charge for.

## Name

Published as `quantlint`. The obvious name, `lookahead`, was taken on PyPI in 2013 by an unrelated iterator utility. The `lookahead` console command is installed as an alias, and `# lookahead: allow` still works as a suppression marker alongside `# quantlint: allow`.

## License

MIT
