rulelint

July 11, 2026 · View on GitHub

tests Open In Colab license: MIT

Find trading-rule conditions that never fire.

A condition can be syntactically valid, raise no exception, and evaluate to False on every evaluated bar. Nothing crashes. Your strategy simply never trades — and looks patient.

hh = high.rolling(20).max()   # "the 20-bar high"
entry = close > hh            # "price breaks out above it"

close[t] ≤ high[t] ≤ max(high[t-19..t]). The current bar's own high is inside the window. entry is false on every bar. Zero out of a thousand. It is arithmetic, not markets.

hh = high.rolling(20).max().shift(1)   # the high of the PREVIOUS 20 bars

Same data, one .shift(1): 44 bars true, 35 crossings.

I shipped that bug into two live strategies and watched them make zero trades for days without suspecting the code. This library is what I built afterwards. The full story →

Try it in your browser — no install, runs client-side on real data →


Install

pip install git+https://github.com/momoddo/rulelint

Requires Python ≥3.10, pandas, numpy. Nothing else.

Try it in one click

No install: open the demo notebook in Colab — it pulls 1,000 bars of real BTC from Binance's public API and shows the classic bug live.

Use

import pandas as pd
from rulelint import lint

bars = pd.DataFrame({"close": ..., "high": ..., "low": ..., "volume": ...})

rule = {"branches": [
    {"direction": "long", "combine": "and", "conditions": [
        {"left": {"ind": "price", "period": 1}, "op": "cross_above",
         "right": {"ind": "high", "period": 20}},
        {"left": {"ind": "vol_ratio", "period": 20}, "op": "gt", "right": 1.5},
    ]},
]}

report = lint(rule, bars)
print(report["verdict"])                       # ok | never_joint | dead | unknown
for b in report["branches"]:
    for c in b.conditions:
        print(c.status, c.hits, c.label)       # never  0  price(1) cross_above high(20)

Integrate with your backtester

Lint is a gate, not a step: refuse to backtest a rule with dead branches, because your engine will happily report "zero trades, zero drawdown" and let you read a bug as discipline.

report = lint(rule, bars)
dead = [c for b in report["branches"] for c in b.conditions if c.status == "never"]
assert not dead, f"dead conditions: {[c.label for c in dead]}"
# only now: signals -> vectorbt / backtrader / your own loop

Full working pattern (lint gate -> per-branch signals -> toy engine you can swap for vectorbt one-liner): examples/lint_before_backtest.py.

The three states

The only subtle thing in this library:

statemeaningwhat to do
okevaluable, fired at least oncenothing
neverevaluable, never true across N barsalmost certainly a bug
unknownnot evaluable — the series was missingsay nothing

Collapsing unknown into never is how you build a linter that cries wolf. Missing data must never be reported as an impossible condition.

A branch is also flagged never_joint when every condition fires individually but they never co-occur. That is a softer warning: rare confluence is a legitimate strategy; impossibility is not.

The bug family this catches

1. Rolling extremes that include the current bar. Donchian channels, prior-day high/low, N-bar breakouts, swing levels. Any level you compare against the current bar must exclude the current bar. indicator("high", 20, bars) here already does.

2. A rolling mean of one. volume / volume.rolling(1).mean() is identically 1.0, so > 1.5 is false forever. indicator("vol_ratio", 1, bars) falls back to a 20-bar window rather than returning a constant.

3. Thresholds that live outside the data. Binance's retail long/short ratio on BTC has range [1.285, 2.917] over the 500 bars in tests/data/ — it never goes below 1.0. So "retail is net short" (retail_ls < 0.7) is not a rare state. It is not a state. Three of my twelve strategies could only ever trade one direction because of thresholds invented by symmetry.

Percentiles will not save you

The obvious repair for #3 is a percentile: don't say "below 0.7", say "in its own bottom 20%". I implemented it, then measured it before shipping. On the real series in tests/data/:

transformlower tailupper tailgap
rolling percentile (200)56.3%15.5%40.8 pts
rolling z-score (200)11.8%9.8%2.0 pts

A rolling percentile rank inherits the trend of the series it ranks: the upper tail empties out and you have rebuilt the same dead condition, pointing the other way.

And z-score is not immune either. If drift dominates noise inside the window, it too pins to one side — there is a test in this repo that asserts exactly that, on purpose, so nobody mistakes it for a silver bullet.

There is no transform that removes the need to look. Print both tail frequencies on your own data before you ship.

What this is not

Not a backtester. Not a strategy library. Not investment advice. It answers exactly one question: how often was this condition actually true?

If your strategy has a condition you have never seen fire, you do not have a patient strategy. You have an untested one.

Development

pip install -e ".[dev]" && pytest -q

The tests are the point. Each one pins a bug that reached production.

License

MIT