Skip to main content

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):

  1. 01_quickstart.py — score_bars + validate + apply
  2. 02_groups.py — flat defs, all/any/not groups
  3. 03_computed_operands.py — col refs, indicators, arithmetic, EMA cross
  4. 04_custom_partition_and_registry.py — custom catalog + partition, extending INDICATORS
  5. 05_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 output
  • docs/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

scanlang-0.1.0.tar.gz (11.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

scanlang-0.1.0-py3-none-any.whl (14.4 kB view details)

Uploaded Python 3

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

Hashes for scanlang-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0bbf6ee9bd8c419b1cb7e98fe919de52d856f6619a2dd5ac57ebbaea478ba7c9
MD5 e2b8a0a8e8e13062a5f90384b1a4d848
BLAKE2b-256 2e8b044d0ac9e3523067cef32787133a8d9fdee79ed7d18e14392d66555dc88a

See more details on using hashes here.

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

Hashes for scanlang-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 498eb181f71e987819a129b9f1c271ca6b388bc317082d9fa87676bb62b8d5d2
MD5 55d0602da85b523e9b815f74efa81c9e
BLAKE2b-256 4f8d7167ead03f3c55f13ae5d56eb7546fba9a86dd4e80a360ffd1bce6a97abd

See more details on using hashes here.

Release history Release notifications | RSS feed

0.4.0

2 files

0.3.1

2 files

0.3.0

2 files

0.2.0

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page