Skip to main content

scanlang

PyPI Python License: MIT Status: pre-alpha

Screener DSL and scan compiler: signal definitions to polars pushdown filters.

A scan definition is a plain dict (JSON from a UI, a Python literal from a notebook) that scanlang compiles into one validated polars predicate. Nothing is string-interpolated, so there is no injection surface. Filters run on eager DataFrame or lazy LazyFrame; window semantics are computed per partition.

Status: v0.3, pre-alpha. The IR is frozen (additive changes only). For the design rationale, see docs/explanation/ir-design.md.

Install

uv add scanlang              # or: pip install scanlang

Requires Python >= 3.11 and polars >= 1.44. The optional duckdb extra (uv add 'scanlang[duckdb]' or pip install 'scanlang[duckdb]') pulls duckdb >= 1.5 and enables the scanlang.duckdb_sql backend, which compiles the same IR to parameterized SQL against the community talib extension. The optional talib extra remains a placeholder for value-parity indicator helpers.

Quickstart (eager)

Copy-pasteable end-to-end. Defines its own small OHLCV frame, scores it, applies a scan, and prints the picks. The full annotated walkthrough lives in docs/examples/01_quickstart.py.

import polars as pl
from scanlang import apply, score_bars, validate

bars = pl.DataFrame({
    "symbol": ["AAA"] * 30 + ["BBB"] * 30,
    "session": pl.date_range(pl.date(2026, 1, 1), pl.date(2026, 3, 1), interval="1d", eager=True)[:30].to_list() * 2,
    "open":   [10 + i for i in range(30)] + [60 - i for i in range(30)],
    "high":   [11 + i for i in range(30)] + [61 - i for i in range(30)],
    "low":    [9  + i for i in range(30)] + [59 - i for i in range(30)],
    "close":  [10 + i for i in range(30)] + [60 - i for i in range(30)],
    "volume": [1000.0] * 60,
})

scored = score_bars(bars).collect()                    # LazyFrame -> DataFrame at the edge
scan_def = {
    "filters":  [{"property": "score", "op": ">=", "value": 40}],
    "order_by": [{"property": "score", "dir": "desc"}],
    "limit":    5,
}
validate(scan_def)                                      # [] when valid; never raises
print(apply(scored, scan_def).select("symbol", "score", "phase"))

The full quickstart script (lazy in, collect at the edge) is docs/examples/01_quickstart.py. Side-by-side eager/lazy/piped/renamed modes: docs/examples/07_lazy_vs_sync.py.

Quickstart (text DSL)

Prefer a one-liner over the dict? parse turns human syntax into the same scan dict. The golden-cross form uses cross_above so the signal only fires when the 20-EMA actually crosses above the 50-EMA:

from scanlang import parse, validate

ir = parse("cross_above(ema(20), ema(50)) and rsi(close, 14) > 70")
validate(ir)                                            # [] when valid

ema(20) and sma(20) imply the close column; indicators that take an expression like rsi need it spelled out (rsi(close, 14)). Full grammar and operator reference: docs/reference/operators.md, docs/how-to/scan-from-text.md.

Eager vs lazy at a glance

Mode In Out When to use it
Sync / eager pl.DataFrame pl.DataFrame Notebook, REPL, small script. Collect at the data edge, then everything stays eager.
Lazy end-to-end pl.LazyFrame pl.LazyFrame Pipeline that pipes into more polars ops. Add .collect() once at the end.
Mixed pl.LazyFrame (start) -> pl.DataFrame (collect once) pl.DataFrame One .collect() at the polars -> non-polars boundary. Don't collect "to be safe" earlier; you lose predicate pushdown.

apply is shape-preserving (eager in -> eager out, lazy in -> lazy out). score_bars is always lazy out: it returns a LazyFrame so it can fold into a bigger polars plan; call .collect() at the edge if you want a DataFrame. Full guide: docs/how-to/eager-frames.md.

Engines at a glance

Two backends consume the same scan-def dict. Pick per data edge.

Engine Backend module When to use it
polars (default) scanlang.apply Frame is pl.DataFrame / pl.LazyFrame; notebook, REPL, small script; lazy pipeline into more polars ops.
duckdb (opt-in) scanlang.duckdb_sql.apply_sql Data already lives in duckdb; need ht_trendline / stoch_k / stoch_d (duckdb-only talib names); full-universe scans where the benchmark shows SQL pushdown beats polars.

The duckdb backend:

import duckdb
from scanlang import validate
from scanlang.duckdb_sql import apply_sql

con = duckdb.connect()
con.execute("CREATE VIEW bars AS SELECT * FROM 'daily_bars.parquet'")

scan_def = {
    "filters": [
        {"property": {"fn": "macd", "args": [12]}, "op": ">", "value": 0},
        {"property": "score", "op": ">=", "value": 40},
    ],
    "order_by": [{"property": "score", "dir": "desc"}],
    "limit": 20,
}
validate(scan_def, engine="duckdb")     # [] when valid
hits = apply_sql(con, scan_def, relation="bars")

apply_sql calls INSTALL talib FROM community; LOAD talib on the connection itself. The full module surface, lowering rules, and SQL_INDICATORS registry: docs/reference/duckdb-backend.md. Install and connect walk-through: docs/how-to/duckdb-backend.md.

Docs (Diataxis)

Build the site locally with uv run --group docs zensical build (config: zensical.toml). Follows the Diataxis split:

  • Tutorials - learning-oriented; get to a first scan.
  • How-to guides - task-oriented; solve a specific problem (custom catalog/partition, extending indicators, scan from text, score + stats, duckdb backend).
  • Explanation - understanding-oriented; IR design, lazy contract, null semantics, validation split, and the SQL backend that supersedes the "why no duckdb" verdict.
  • Reference - information-oriented; API, operators, indicators, examples index, notebooks, IR freeze, duckdb backend.

Notebooks: 01_first_scan.ipynb (Jupyter) and 02_first_scan_marimo.py (marimo)

Development

uv sync --group docs                              # create .venv with zensical
.venv/bin/python -m pytest tests/ -q              # tests
.venv/bin/python -m ruff check src tests          # lint
.venv/bin/zensical serve                          # live-reload docs at :8000
.venv/bin/zensical build                          # static build -> site/

License

MIT.

Metadata

Release files for scanlang 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for scanlang 0.4.0
File Size Uploaded Provenance Actions
scanlang-0.4.0.tar.gz 44.0 kB Without provenance Details for scanlang-0.4.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for scanlang 0.4.0
File Interpreter ABI Platform Actions
scanlang-0.4.0-py3-none-any.whl Python 3 none any Details for scanlang-0.4.0-py3-none-any.whl

Total release size: 92.3 kB

Release files / scanlang-0.4.0.tar.gz

Download URL scanlang-0.4.0.tar.gz
Size 44.0 kB
Tags Source
SHA-256 checksum
How to use checksums
325a0d6192ab6b56007068ea6f23377d83c141548ad5c431f62984011d05a931
BLAKE2b-256 checksum
How to use checksums
eedbd432057d253ec41843eaf3c597ed44a066da166f4feda814a4ff1ebe2d77
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","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":true}

Release files / scanlang-0.4.0-py3-none-any.whl

Download URL scanlang-0.4.0-py3-none-any.whl
Size 48.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a9c24ee7c584e3cc050bf08e7b80051747e2567d96c4c7151b79931247df975b
BLAKE2b-256 checksum
How to use checksums
1b657a351eb6b2fb71590540389e2f7160a89c9cdb0147f71d76aeeb77c128d1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","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":true}

Security for scanlang 0.4.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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