rulelint
July 11, 2026 · View on GitHub
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:
| state | meaning | what to do |
|---|---|---|
ok | evaluable, fired at least once | nothing |
never | evaluable, never true across N bars | almost certainly a bug |
unknown | not evaluable — the series was missing | say 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/:
| transform | lower tail | upper tail | gap |
|---|---|---|---|
| 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