scanlang
Screener DSL and scan compiler: signal definitions -> polars pushdown filters.
A scan definition is a plain dict — JSON from a UI, a Python literal from a
notebook — that scanlang compiles into a single validated polars predicate.
There is no string interpolation, so there is no injection surface. Filters
run on any eager DataFrame or lazy LazyFrame; window semantics
(indicators, crosses) are computed per partition, so 10 symbols or 10,000
behave the same.
Status: v0.1. The IR is frozen — see docs/IR_FREEZE.md for the exact
contract (additive changes only). Consumers: marketdata-screens (Lab UI),
REPL, jupyter/marimo.
Install
uv add scanlang # or: pip install scanlang
Requires Python >= 3.11 and polars >= 1.44. The optional talib extra
(uv add scanlang --optional talib) is reserved for a future value-parity
indicator module.
Quickstart
score_bars turns OHLCV bars into one scored row per symbol; apply
filters/orders/limits that frame with a scan definition. (Data setup elided —
full runnable version in docs/examples/01_quickstart.py.)
>>> from scanlang import apply, compile, score_bars, validate
>>> scored = score_bars(bars.lazy()).collect() # lazy in, lazy out — collect at your edge
>>> scored.select("symbol", "close", "score", "phase")
shape: (2, 4)
┌────────┬───────┬───────┬───────┐
│ symbol ┆ close ┆ score ┆ phase │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ i16 ┆ str │
╞════════╪═══════╪═══════╪═══════╡
│ AAA ┆ 69.0 ┆ 60 ┆ BASE │
│ BBB ┆ 1.0 ┆ 20 ┆ NONE │
└────────┴───────┴───────┴───────┘
A scan definition is a plain dict. validate returns [] when it's valid;
apply runs it; compile hands you the bare polars expression:
>>> scan_def = {
... "filters": [
... {"property": "score", "op": ">=", "value": 40},
... {"any": [
... {"property": "phase", "op": "in", "value": ["BREAKOUT", "TREND"]},
... {"not": {"property": "phase", "op": "==", "value": "NONE"}},
... ]},
... ],
... "order_by": [{"property": "score", "dir": "desc"}],
... "limit": 5,
... }
>>> validate(scan_def)
[]
>>> apply(scored, scan_def).select("symbol", "score", "phase")
shape: (1, 3)
┌────────┬───────┬───────┐
│ symbol ┆ score ┆ phase │
│ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ i16 ┆ str │
╞════════╪═══════╪═══════╡
│ AAA ┆ 60 ┆ BASE │
└────────┴───────┴───────┘
>>> expr = compile(scan_def) # a single polars predicate
(Transcript above is machine-generated from a live REPL —
scripts/gen_repl.py regenerates it.)
The scan definition (the IR)
Top level: {"filters": [node, ...], "order_by": [...], "limit": int} —
order_by and limit are optional; a bare filters list of leaves is
valid, so today's flat defs keep working.
Nodes nest arbitrarily:
{"all": [node, ...]}— AND (nonempty){"any": [node, ...]}— OR (nonempty){"not": {node}}— unary NOT- leaf:
{"property": <prop>, "op": <op>, "value": <operand>}
Ops: >= <= > < == != between in contains, plus cross_above /
cross_below, which compile to a > b AND shift(a,1) <= shift(b,1) over the
partition (mirrored for below).
Properties and operands — a property is a catalog column name or a
computed operand; comparison values are operands too (in/between/
contains values stay literal-only):
- bare scalar — literal:
60,"BREAKOUT",False {"col": "close"}— column ref{"fn": "sma", "args": [operand, ...]}— indicator call, args recursive (sma(rsi(close,14), 5)is legal){"+": [a, b]},"-","*","/"— arithmetic fold (n-ary;{"-": [x]}negates)
Indicators (INDICATORS, extensible by insertion): sma, ema, rsi,
atr, rmin, rmax, shift. Window ops are computed .over(partition)
(default "symbol").
Validation split: literal leaves are totally validated — malformed defs
raise ValueError from compile/apply and return error strings from
validate, never a polars ComputeError at filter time. Computed operands
are structurally validated (known fn, known col, arg types, required cols);
dtype mismatches there surface at collect time.
Nulls: comparisons and not on null yield null, and filter drops null
rows. Documented behavior, not worked around.
Any LazyFrame, any catalog
score_bars output mirrors PROPERTY_CATALOG, but nothing is tied to it:
derive a catalog from any frame's schema and point partition at your group
column.
>>> import polars as pl
>>> from scanlang import apply, catalog_from_schema
>>> lf = bars.rename({"symbol": "ticker"}).lazy() # rename at your edge
>>> cat = catalog_from_schema(lf) # schema -> catalog
>>> rsi_hot = {"filters": [{
... "property": {"fn": "rsi", "args": [{"col": "close"}, 14]},
... "op": ">", "value": 70,
... }]}
>>> apply(lf, rsi_hot, catalog=cat, partition="ticker").collect().head(3)
shape: (3, 3)
┌────────┬────────────┬───────┐
│ ticker ┆ session ┆ close │
│ --- ┆ --- ┆ --- │
│ str ┆ date ┆ f64 │
╞════════╪════════════╪═══════╡
│ AAA ┆ 2026-01-15 ┆ 24.0 │
│ AAA ┆ 2026-01-16 ┆ 25.0 │
│ AAA ┆ 2026-01-17 ┆ 26.0 │
└────────┴────────────┴───────┘
# 46 rows total: the uptrend clears RSI 70 from bar 15 on; the downtrend never
API
| Function | Purpose |
|---|---|
compile(scan_def, *, catalog=PROPERTY_CATALOG, partition="symbol") |
scan def -> one polars predicate Expr |
validate(scan_def, *, catalog=...) |
list[str] of errors; empty = valid |
apply(frame, scan_def, *, catalog=..., partition=...) |
filter + order_by + limit (eager or lazy) |
catalog_from_schema(frame) |
polars schema -> catalog dict; unmapped dtypes skipped |
score_bars(bars, *, min_bars=30, freshness_days=5) |
phase/scan scoring over OHLCV; lazy in, lazy out |
forward_stats / backtest_summary (+ HORIZONS) |
forward-return evidence for a scan's past runs |
Caller contract: the frame is sorted (partition, time) ascending.
Nonstandard column names are renamed at your edge (lf.rename({"date": "session"})).
Examples
Runnable scripts in docs/examples/ (each block is a notebook cell if you
paste into marimo/jupyter):
01_quickstart.py— score_bars + validate + apply02_groups.py— flat defs, all/any/not groups03_computed_operands.py— col refs, indicators, arithmetic, EMA cross04_custom_partition_and_registry.py— custom catalog + partition, extending INDICATORS05_score_and_stats.py— apply on a LazyFrame + forward_stats/backtest_summary
Run them with .venv/bin/python docs/examples/01_quickstart.py (or your
project interpreter). docs/EXAMPLES.md walks through them with real output.
Docs
docs/IR_FREEZE.md— the frozen IR contract (spec)docs/EXAMPLES.md— annotated walkthroughs with verified outputdocs/RESEARCH_DUCKDB.md— why compile targets polars, not SQL
Development
uv sync # create .venv
.venv/bin/python -m pytest tests/ -q # tests
.venv/bin/python -m ruff check src tests # lint
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file scanlang-0.1.0.tar.gz.
File metadata
- Download URL: scanlang-0.1.0.tar.gz
- Upload date:
- Size: 11.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0bbf6ee9bd8c419b1cb7e98fe919de52d856f6619a2dd5ac57ebbaea478ba7c9
|
|
| MD5 |
e2b8a0a8e8e13062a5f90384b1a4d848
|
|
| BLAKE2b-256 |
2e8b044d0ac9e3523067cef32787133a8d9fdee79ed7d18e14392d66555dc88a
|
File details
Details for the file scanlang-0.1.0-py3-none-any.whl.
File metadata
- Download URL: scanlang-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
498eb181f71e987819a129b9f1c271ca6b388bc317082d9fa87676bb62b8d5d2
|
|
| MD5 |
55d0602da85b523e9b815f74efa81c9e
|
|
| BLAKE2b-256 |
4f8d7167ead03f3c55f13ae5d56eb7546fba9a86dd4e80a360ffd1bce6a97abd
|