scanlang 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scanlang-0.1.0/LICENSE ADDED
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Volker Lorrmann
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.4
2
+ Name: scanlang
3
+ Version: 0.1.0
4
+ Summary: Screener DSL and scan compiler: signal definitions -> polars/duckdb pushdown filters.
5
+ Keywords: screener,scanner,stock-screening,polars,duckdb,finance
6
+ Author: Volker Lorrmann
7
+ Author-email: Volker Lorrmann <volker.lorrmann@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Office/Business :: Financial :: Investment
18
+ Requires-Dist: polars>=1.44.0
19
+ Requires-Dist: ta-lib>=0.7.1 ; extra == 'talib'
20
+ Requires-Python: >=3.11
21
+ Project-URL: Homepage, https://github.com/legout/scanlang
22
+ Project-URL: Repository, https://github.com/legout/scanlang
23
+ Project-URL: Issues, https://github.com/legout/scanlang/issues
24
+ Provides-Extra: talib
25
+ Description-Content-Type: text/markdown
26
+
27
+ # scanlang
28
+
29
+ Screener DSL and scan compiler: signal definitions -> polars pushdown filters.
30
+
31
+ A scan definition is a plain dict — JSON from a UI, a Python literal from a
32
+ notebook — that `scanlang` compiles into a single validated polars predicate.
33
+ There is no string interpolation, so there is no injection surface. Filters
34
+ run on any eager `DataFrame` or lazy `LazyFrame`; window semantics
35
+ (indicators, crosses) are computed per partition, so 10 symbols or 10,000
36
+ behave the same.
37
+
38
+ Status: v0.1. The IR is frozen — see `docs/IR_FREEZE.md` for the exact
39
+ contract (additive changes only). Consumers: marketdata-screens (Lab UI),
40
+ REPL, jupyter/marimo.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ uv add scanlang # or: pip install scanlang
46
+ ```
47
+
48
+ Requires Python >= 3.11 and polars >= 1.44. The optional `talib` extra
49
+ (`uv add scanlang --optional talib`) is reserved for a future value-parity
50
+ indicator module.
51
+
52
+ ## Quickstart
53
+
54
+ `score_bars` turns OHLCV bars into one scored row per symbol; `apply`
55
+ filters/orders/limits that frame with a scan definition. (Data setup elided —
56
+ full runnable version in `docs/examples/01_quickstart.py`.)
57
+
58
+ ```python
59
+ >>> from scanlang import apply, compile, score_bars, validate
60
+ >>> scored = score_bars(bars.lazy()).collect() # lazy in, lazy out — collect at your edge
61
+ >>> scored.select("symbol", "close", "score", "phase")
62
+ shape: (2, 4)
63
+ ┌────────┬───────┬───────┬───────┐
64
+ │ symbol ┆ close ┆ score ┆ phase │
65
+ │ --- ┆ --- ┆ --- ┆ --- │
66
+ │ str ┆ f64 ┆ i16 ┆ str │
67
+ ╞════════╪═══════╪═══════╪═══════╡
68
+ │ AAA ┆ 69.0 ┆ 60 ┆ BASE │
69
+ │ BBB ┆ 1.0 ┆ 20 ┆ NONE │
70
+ └────────┴───────┴───────┴───────┘
71
+ ```
72
+
73
+ A scan definition is a plain dict. `validate` returns `[]` when it's valid;
74
+ `apply` runs it; `compile` hands you the bare polars expression:
75
+
76
+ ```python
77
+ >>> scan_def = {
78
+ ... "filters": [
79
+ ... {"property": "score", "op": ">=", "value": 40},
80
+ ... {"any": [
81
+ ... {"property": "phase", "op": "in", "value": ["BREAKOUT", "TREND"]},
82
+ ... {"not": {"property": "phase", "op": "==", "value": "NONE"}},
83
+ ... ]},
84
+ ... ],
85
+ ... "order_by": [{"property": "score", "dir": "desc"}],
86
+ ... "limit": 5,
87
+ ... }
88
+ >>> validate(scan_def)
89
+ []
90
+ >>> apply(scored, scan_def).select("symbol", "score", "phase")
91
+ shape: (1, 3)
92
+ ┌────────┬───────┬───────┐
93
+ │ symbol ┆ score ┆ phase │
94
+ │ --- ┆ --- ┆ --- │
95
+ │ str ┆ f64 ┆ i16 ┆ str │
96
+ ╞════════╪═══════╪═══════╡
97
+ │ AAA ┆ 60 ┆ BASE │
98
+ └────────┴───────┴───────┘
99
+ >>> expr = compile(scan_def) # a single polars predicate
100
+ ```
101
+
102
+ (Transcript above is machine-generated from a live REPL —
103
+ `scripts/gen_repl.py` regenerates it.)
104
+
105
+ ## The scan definition (the IR)
106
+
107
+ Top level: `{"filters": [node, ...], "order_by": [...], "limit": int}` —
108
+ `order_by` and `limit` are optional; a bare `filters` list of leaves is
109
+ valid, so today's flat defs keep working.
110
+
111
+ **Nodes** nest arbitrarily:
112
+
113
+ - `{"all": [node, ...]}` — AND (nonempty)
114
+ - `{"any": [node, ...]}` — OR (nonempty)
115
+ - `{"not": {node}}` — unary NOT
116
+ - leaf: `{"property": <prop>, "op": <op>, "value": <operand>}`
117
+
118
+ **Ops:** `>= <= > < == != between in contains`, plus `cross_above` /
119
+ `cross_below`, which compile to `a > b AND shift(a,1) <= shift(b,1)` over the
120
+ partition (mirrored for below).
121
+
122
+ **Properties and operands** — a `property` is a catalog column name or a
123
+ computed operand; comparison `value`s are operands too (`in`/`between`/
124
+ `contains` values stay literal-only):
125
+
126
+ - bare scalar — literal: `60`, `"BREAKOUT"`, `False`
127
+ - `{"col": "close"}` — column ref
128
+ - `{"fn": "sma", "args": [operand, ...]}` — indicator call, args recursive
129
+ (`sma(rsi(close,14), 5)` is legal)
130
+ - `{"+": [a, b]}`, `"-"`, `"*"`, `"/"` — arithmetic fold (n-ary;
131
+ `{"-": [x]}` negates)
132
+
133
+ **Indicators** (`INDICATORS`, extensible by insertion): `sma`, `ema`, `rsi`,
134
+ `atr`, `rmin`, `rmax`, `shift`. Window ops are computed `.over(partition)`
135
+ (default `"symbol"`).
136
+
137
+ **Validation split:** literal leaves are totally validated — malformed defs
138
+ raise `ValueError` from `compile`/`apply` and return error strings from
139
+ `validate`, never a polars `ComputeError` at filter time. Computed operands
140
+ are structurally validated (known fn, known col, arg types, required cols);
141
+ dtype mismatches there surface at collect time.
142
+
143
+ **Nulls:** comparisons and `not` on null yield null, and filter drops null
144
+ rows. Documented behavior, not worked around.
145
+
146
+ ## Any LazyFrame, any catalog
147
+
148
+ `score_bars` output mirrors `PROPERTY_CATALOG`, but nothing is tied to it:
149
+ derive a catalog from any frame's schema and point `partition` at your group
150
+ column.
151
+
152
+ ```python
153
+ >>> import polars as pl
154
+ >>> from scanlang import apply, catalog_from_schema
155
+ >>> lf = bars.rename({"symbol": "ticker"}).lazy() # rename at your edge
156
+ >>> cat = catalog_from_schema(lf) # schema -> catalog
157
+ >>> rsi_hot = {"filters": [{
158
+ ... "property": {"fn": "rsi", "args": [{"col": "close"}, 14]},
159
+ ... "op": ">", "value": 70,
160
+ ... }]}
161
+ >>> apply(lf, rsi_hot, catalog=cat, partition="ticker").collect().head(3)
162
+ shape: (3, 3)
163
+ ┌────────┬────────────┬───────┐
164
+ │ ticker ┆ session ┆ close │
165
+ │ --- ┆ --- ┆ --- │
166
+ │ str ┆ date ┆ f64 │
167
+ ╞════════╪════════════╪═══════╡
168
+ │ AAA ┆ 2026-01-15 ┆ 24.0 │
169
+ │ AAA ┆ 2026-01-16 ┆ 25.0 │
170
+ │ AAA ┆ 2026-01-17 ┆ 26.0 │
171
+ └────────┴────────────┴───────┘
172
+ # 46 rows total: the uptrend clears RSI 70 from bar 15 on; the downtrend never
173
+ ```
174
+
175
+ ## API
176
+
177
+ | Function | Purpose |
178
+ | --- | --- |
179
+ | `compile(scan_def, *, catalog=PROPERTY_CATALOG, partition="symbol")` | scan def -> one polars predicate `Expr` |
180
+ | `validate(scan_def, *, catalog=...)` | `list[str]` of errors; empty = valid |
181
+ | `apply(frame, scan_def, *, catalog=..., partition=...)` | filter + order_by + limit (eager or lazy) |
182
+ | `catalog_from_schema(frame)` | polars schema -> catalog dict; unmapped dtypes skipped |
183
+ | `score_bars(bars, *, min_bars=30, freshness_days=5)` | phase/scan scoring over OHLCV; lazy in, lazy out |
184
+ | `forward_stats` / `backtest_summary` (+ `HORIZONS`) | forward-return evidence for a scan's past runs |
185
+
186
+ Caller contract: the frame is sorted `(partition, time)` ascending.
187
+ Nonstandard column names are renamed at your edge (`lf.rename({"date": "session"})`).
188
+
189
+ ## Examples
190
+
191
+ Runnable scripts in `docs/examples/` (each block is a notebook cell if you
192
+ paste into marimo/jupyter):
193
+
194
+ 1. `01_quickstart.py` — score_bars + validate + apply
195
+ 2. `02_groups.py` — flat defs, all/any/not groups
196
+ 3. `03_computed_operands.py` — col refs, indicators, arithmetic, EMA cross
197
+ 4. `04_custom_partition_and_registry.py` — custom catalog + partition, extending INDICATORS
198
+ 5. `05_score_and_stats.py` — apply on a LazyFrame + forward_stats/backtest_summary
199
+
200
+ Run them with `.venv/bin/python docs/examples/01_quickstart.py` (or your
201
+ project interpreter). `docs/EXAMPLES.md` walks through them with real output.
202
+
203
+ ## Docs
204
+
205
+ - `docs/IR_FREEZE.md` — the frozen IR contract (spec)
206
+ - `docs/EXAMPLES.md` — annotated walkthroughs with verified output
207
+ - `docs/RESEARCH_DUCKDB.md` — why compile targets polars, not SQL
208
+
209
+ ## Development
210
+
211
+ ```sh
212
+ uv sync # create .venv
213
+ .venv/bin/python -m pytest tests/ -q # tests
214
+ .venv/bin/python -m ruff check src tests # lint
215
+ ```
216
+
217
+ ## License
218
+
219
+ MIT
@@ -0,0 +1,193 @@
1
+ # scanlang
2
+
3
+ Screener DSL and scan compiler: signal definitions -> polars pushdown filters.
4
+
5
+ A scan definition is a plain dict — JSON from a UI, a Python literal from a
6
+ notebook — that `scanlang` compiles into a single validated polars predicate.
7
+ There is no string interpolation, so there is no injection surface. Filters
8
+ run on any eager `DataFrame` or lazy `LazyFrame`; window semantics
9
+ (indicators, crosses) are computed per partition, so 10 symbols or 10,000
10
+ behave the same.
11
+
12
+ Status: v0.1. The IR is frozen — see `docs/IR_FREEZE.md` for the exact
13
+ contract (additive changes only). Consumers: marketdata-screens (Lab UI),
14
+ REPL, jupyter/marimo.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ uv add scanlang # or: pip install scanlang
20
+ ```
21
+
22
+ Requires Python >= 3.11 and polars >= 1.44. The optional `talib` extra
23
+ (`uv add scanlang --optional talib`) is reserved for a future value-parity
24
+ indicator module.
25
+
26
+ ## Quickstart
27
+
28
+ `score_bars` turns OHLCV bars into one scored row per symbol; `apply`
29
+ filters/orders/limits that frame with a scan definition. (Data setup elided —
30
+ full runnable version in `docs/examples/01_quickstart.py`.)
31
+
32
+ ```python
33
+ >>> from scanlang import apply, compile, score_bars, validate
34
+ >>> scored = score_bars(bars.lazy()).collect() # lazy in, lazy out — collect at your edge
35
+ >>> scored.select("symbol", "close", "score", "phase")
36
+ shape: (2, 4)
37
+ ┌────────┬───────┬───────┬───────┐
38
+ │ symbol ┆ close ┆ score ┆ phase │
39
+ │ --- ┆ --- ┆ --- ┆ --- │
40
+ │ str ┆ f64 ┆ i16 ┆ str │
41
+ ╞════════╪═══════╪═══════╪═══════╡
42
+ │ AAA ┆ 69.0 ┆ 60 ┆ BASE │
43
+ │ BBB ┆ 1.0 ┆ 20 ┆ NONE │
44
+ └────────┴───────┴───────┴───────┘
45
+ ```
46
+
47
+ A scan definition is a plain dict. `validate` returns `[]` when it's valid;
48
+ `apply` runs it; `compile` hands you the bare polars expression:
49
+
50
+ ```python
51
+ >>> scan_def = {
52
+ ... "filters": [
53
+ ... {"property": "score", "op": ">=", "value": 40},
54
+ ... {"any": [
55
+ ... {"property": "phase", "op": "in", "value": ["BREAKOUT", "TREND"]},
56
+ ... {"not": {"property": "phase", "op": "==", "value": "NONE"}},
57
+ ... ]},
58
+ ... ],
59
+ ... "order_by": [{"property": "score", "dir": "desc"}],
60
+ ... "limit": 5,
61
+ ... }
62
+ >>> validate(scan_def)
63
+ []
64
+ >>> apply(scored, scan_def).select("symbol", "score", "phase")
65
+ shape: (1, 3)
66
+ ┌────────┬───────┬───────┐
67
+ │ symbol ┆ score ┆ phase │
68
+ │ --- ┆ --- ┆ --- │
69
+ │ str ┆ f64 ┆ i16 ┆ str │
70
+ ╞════════╪═══════╪═══════╡
71
+ │ AAA ┆ 60 ┆ BASE │
72
+ └────────┴───────┴───────┘
73
+ >>> expr = compile(scan_def) # a single polars predicate
74
+ ```
75
+
76
+ (Transcript above is machine-generated from a live REPL —
77
+ `scripts/gen_repl.py` regenerates it.)
78
+
79
+ ## The scan definition (the IR)
80
+
81
+ Top level: `{"filters": [node, ...], "order_by": [...], "limit": int}` —
82
+ `order_by` and `limit` are optional; a bare `filters` list of leaves is
83
+ valid, so today's flat defs keep working.
84
+
85
+ **Nodes** nest arbitrarily:
86
+
87
+ - `{"all": [node, ...]}` — AND (nonempty)
88
+ - `{"any": [node, ...]}` — OR (nonempty)
89
+ - `{"not": {node}}` — unary NOT
90
+ - leaf: `{"property": <prop>, "op": <op>, "value": <operand>}`
91
+
92
+ **Ops:** `>= <= > < == != between in contains`, plus `cross_above` /
93
+ `cross_below`, which compile to `a > b AND shift(a,1) <= shift(b,1)` over the
94
+ partition (mirrored for below).
95
+
96
+ **Properties and operands** — a `property` is a catalog column name or a
97
+ computed operand; comparison `value`s are operands too (`in`/`between`/
98
+ `contains` values stay literal-only):
99
+
100
+ - bare scalar — literal: `60`, `"BREAKOUT"`, `False`
101
+ - `{"col": "close"}` — column ref
102
+ - `{"fn": "sma", "args": [operand, ...]}` — indicator call, args recursive
103
+ (`sma(rsi(close,14), 5)` is legal)
104
+ - `{"+": [a, b]}`, `"-"`, `"*"`, `"/"` — arithmetic fold (n-ary;
105
+ `{"-": [x]}` negates)
106
+
107
+ **Indicators** (`INDICATORS`, extensible by insertion): `sma`, `ema`, `rsi`,
108
+ `atr`, `rmin`, `rmax`, `shift`. Window ops are computed `.over(partition)`
109
+ (default `"symbol"`).
110
+
111
+ **Validation split:** literal leaves are totally validated — malformed defs
112
+ raise `ValueError` from `compile`/`apply` and return error strings from
113
+ `validate`, never a polars `ComputeError` at filter time. Computed operands
114
+ are structurally validated (known fn, known col, arg types, required cols);
115
+ dtype mismatches there surface at collect time.
116
+
117
+ **Nulls:** comparisons and `not` on null yield null, and filter drops null
118
+ rows. Documented behavior, not worked around.
119
+
120
+ ## Any LazyFrame, any catalog
121
+
122
+ `score_bars` output mirrors `PROPERTY_CATALOG`, but nothing is tied to it:
123
+ derive a catalog from any frame's schema and point `partition` at your group
124
+ column.
125
+
126
+ ```python
127
+ >>> import polars as pl
128
+ >>> from scanlang import apply, catalog_from_schema
129
+ >>> lf = bars.rename({"symbol": "ticker"}).lazy() # rename at your edge
130
+ >>> cat = catalog_from_schema(lf) # schema -> catalog
131
+ >>> rsi_hot = {"filters": [{
132
+ ... "property": {"fn": "rsi", "args": [{"col": "close"}, 14]},
133
+ ... "op": ">", "value": 70,
134
+ ... }]}
135
+ >>> apply(lf, rsi_hot, catalog=cat, partition="ticker").collect().head(3)
136
+ shape: (3, 3)
137
+ ┌────────┬────────────┬───────┐
138
+ │ ticker ┆ session ┆ close │
139
+ │ --- ┆ --- ┆ --- │
140
+ │ str ┆ date ┆ f64 │
141
+ ╞════════╪════════════╪═══════╡
142
+ │ AAA ┆ 2026-01-15 ┆ 24.0 │
143
+ │ AAA ┆ 2026-01-16 ┆ 25.0 │
144
+ │ AAA ┆ 2026-01-17 ┆ 26.0 │
145
+ └────────┴────────────┴───────┘
146
+ # 46 rows total: the uptrend clears RSI 70 from bar 15 on; the downtrend never
147
+ ```
148
+
149
+ ## API
150
+
151
+ | Function | Purpose |
152
+ | --- | --- |
153
+ | `compile(scan_def, *, catalog=PROPERTY_CATALOG, partition="symbol")` | scan def -> one polars predicate `Expr` |
154
+ | `validate(scan_def, *, catalog=...)` | `list[str]` of errors; empty = valid |
155
+ | `apply(frame, scan_def, *, catalog=..., partition=...)` | filter + order_by + limit (eager or lazy) |
156
+ | `catalog_from_schema(frame)` | polars schema -> catalog dict; unmapped dtypes skipped |
157
+ | `score_bars(bars, *, min_bars=30, freshness_days=5)` | phase/scan scoring over OHLCV; lazy in, lazy out |
158
+ | `forward_stats` / `backtest_summary` (+ `HORIZONS`) | forward-return evidence for a scan's past runs |
159
+
160
+ Caller contract: the frame is sorted `(partition, time)` ascending.
161
+ Nonstandard column names are renamed at your edge (`lf.rename({"date": "session"})`).
162
+
163
+ ## Examples
164
+
165
+ Runnable scripts in `docs/examples/` (each block is a notebook cell if you
166
+ paste into marimo/jupyter):
167
+
168
+ 1. `01_quickstart.py` — score_bars + validate + apply
169
+ 2. `02_groups.py` — flat defs, all/any/not groups
170
+ 3. `03_computed_operands.py` — col refs, indicators, arithmetic, EMA cross
171
+ 4. `04_custom_partition_and_registry.py` — custom catalog + partition, extending INDICATORS
172
+ 5. `05_score_and_stats.py` — apply on a LazyFrame + forward_stats/backtest_summary
173
+
174
+ Run them with `.venv/bin/python docs/examples/01_quickstart.py` (or your
175
+ project interpreter). `docs/EXAMPLES.md` walks through them with real output.
176
+
177
+ ## Docs
178
+
179
+ - `docs/IR_FREEZE.md` — the frozen IR contract (spec)
180
+ - `docs/EXAMPLES.md` — annotated walkthroughs with verified output
181
+ - `docs/RESEARCH_DUCKDB.md` — why compile targets polars, not SQL
182
+
183
+ ## Development
184
+
185
+ ```sh
186
+ uv sync # create .venv
187
+ .venv/bin/python -m pytest tests/ -q # tests
188
+ .venv/bin/python -m ruff check src tests # lint
189
+ ```
190
+
191
+ ## License
192
+
193
+ MIT
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "scanlang"
3
+ version = "0.1.0"
4
+ description = "Screener DSL and scan compiler: signal definitions -> polars/duckdb pushdown filters."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Volker Lorrmann", email = "volker.lorrmann@gmail.com" }]
9
+ requires-python = ">=3.11"
10
+ keywords = ["screener", "scanner", "stock-screening", "polars", "duckdb", "finance"]
11
+ classifiers = [
12
+ "Development Status :: 2 - Pre-Alpha",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Office/Business :: Financial :: Investment",
20
+ ]
21
+ dependencies = ["polars>=1.44.0"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/legout/scanlang"
25
+ Repository = "https://github.com/legout/scanlang"
26
+ Issues = "https://github.com/legout/scanlang/issues"
27
+
28
+ [project.optional-dependencies]
29
+ talib = ["ta-lib>=0.7.1"]
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=8.0.0", "ruff>=0.8.0"]
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.11.8,<0.12.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.uv.build-backend]
39
+ module-name = "scanlang"
40
+ module-root = "src"
@@ -0,0 +1,35 @@
1
+ """Screener DSL and scan compiler.
2
+
3
+ Signal dict (IR) -> validated polars expressions -> lazy pushdown over any
4
+ LazyFrame source. Optional text DSL parses to the same IR (deferred).
5
+
6
+ >>> import polars as pl
7
+ >>> from scanlang import compile, apply
8
+ >>> scan_def = {"filters": [{"property": "score", "op": ">=", "value": 50}]}
9
+ """
10
+
11
+ from scanlang.compiler import (
12
+ PROPERTY_CATALOG,
13
+ apply,
14
+ catalog_from_schema,
15
+ compile,
16
+ validate,
17
+ )
18
+ from scanlang.indicators import INDICATORS
19
+ from scanlang.scoring import score_bars
20
+ from scanlang.stats import HORIZONS, backtest_summary, forward_stats
21
+
22
+ __all__ = [
23
+ "HORIZONS",
24
+ "INDICATORS",
25
+ "PROPERTY_CATALOG",
26
+ "apply",
27
+ "backtest_summary",
28
+ "catalog_from_schema",
29
+ "compile",
30
+ "forward_stats",
31
+ "score_bars",
32
+ "validate",
33
+ ]
34
+
35
+ __version__ = "0.1.0"
@@ -0,0 +1,371 @@
1
+ """Compile scan-definition dicts into polars predicates.
2
+
3
+ A scan definition is a plain dict (JSON from the Lab UI, REPL, notebooks)::
4
+
5
+ {"filters": [
6
+ {"property": "rsi", "op": ">=", "value": 60},
7
+ {"property": "phase", "op": "in", "value": ["BREAKOUT"]},
8
+ {"any": [
9
+ {"property": "score", "op": "between", "value": [50, 90]},
10
+ {"not": {"property": "spring", "op": "==", "value": False}},
11
+ ]},
12
+ # computed operands: {"col": x} refs, {"fn": name, "args": [...]}
13
+ # indicators, {"+": [a, b]} arithmetic; scalars stay literals
14
+ {"property": {"fn": "ema", "args": [{"col": "close"}, 5]},
15
+ "op": "cross_above",
16
+ "value": {"fn": "ema", "args": [{"col": "close"}, 20]}},
17
+ ],
18
+ "order_by": [{"property": "score", "dir": "desc"}],
19
+ "limit": 50}
20
+
21
+ See docs/IR_FREEZE.md for the full contract. Nothing is string-interpolated,
22
+ so there is no injection surface. Validation is total for literal leaves
23
+ (never a polars ComputeError at filter time) and structural for computed
24
+ operands — dtype mismatches there surface at collect time.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import datetime as dt
30
+ import operator
31
+ from functools import reduce
32
+
33
+ import polars as pl
34
+
35
+ from scanlang.indicators import INDICATORS
36
+
37
+ __all__ = ["PROPERTY_CATALOG", "apply", "catalog_from_schema", "compile", "validate"]
38
+
39
+ # Mirrors scoring.score_bars() output columns. dtype: str, int, float, bool, date.
40
+ PROPERTY_CATALOG: dict[str, dict[str, str]] = {
41
+ "symbol": {"label": "Symbol", "dtype": "str"},
42
+ "session": {"label": "Session", "dtype": "date"},
43
+ "close": {"label": "Close", "dtype": "float"},
44
+ "score": {"label": "Score", "dtype": "int"},
45
+ "phase": {"label": "Phase", "dtype": "str"},
46
+ "vol_ratio": {"label": "Vol Ratio", "dtype": "float"},
47
+ "atr_ratio": {"label": "ATR Ratio", "dtype": "float"},
48
+ "rsi": {"label": "RSI", "dtype": "float"},
49
+ "acc_score": {"label": "Accumulation", "dtype": "float"},
50
+ "spring": {"label": "Spring", "dtype": "bool"},
51
+ "ema_stack": {"label": "EMA Stack", "dtype": "bool"},
52
+ "recent_cross": {"label": "Recent Cross", "dtype": "bool"},
53
+ "upper_wick_pct": {"label": "Upper Wick %", "dtype": "float"},
54
+ "near_52w_low": {"label": "Near 52w Low", "dtype": "bool"},
55
+ "bars": {"label": "Bars", "dtype": "int"},
56
+ }
57
+
58
+ # op -> builder(lhs, rhs) -> pl.Expr
59
+ _OPS = {
60
+ ">=": lambda col, v: col >= v,
61
+ "<=": lambda col, v: col <= v,
62
+ ">": lambda col, v: col > v,
63
+ "<": lambda col, v: col < v,
64
+ "==": lambda col, v: col == v,
65
+ "!=": lambda col, v: col != v,
66
+ "between": lambda col, v: col.is_between(v[0], v[1], closed="both"),
67
+ "in": lambda col, v: col.is_in(v),
68
+ "contains": lambda col, v: col.str.contains(v, literal=True),
69
+ }
70
+ _CROSS = ("cross_above", "cross_below")
71
+ _ARITH = {
72
+ "+": operator.add,
73
+ "-": operator.sub,
74
+ "*": operator.mul,
75
+ "/": operator.truediv,
76
+ }
77
+ _LIST_OPS = ("in", "between", "contains")
78
+
79
+
80
+ def _ok_date(v) -> bool:
81
+ if not isinstance(v, str):
82
+ return False
83
+ try:
84
+ dt.date.fromisoformat(v)
85
+ except ValueError:
86
+ return False
87
+ return True
88
+
89
+
90
+ def _is(dtype: str, value) -> bool:
91
+ """JSON value type-check against a catalog dtype (bool is not int)."""
92
+ if dtype == "int":
93
+ return isinstance(value, int) and not isinstance(value, bool)
94
+ if dtype == "float":
95
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
96
+ if dtype == "bool":
97
+ return isinstance(value, bool)
98
+ return isinstance(value, str) # str and date both arrive as JSON strings
99
+
100
+
101
+ # --- operand parsing -------------------------------------------------------
102
+
103
+
104
+ def _operand(spec, *, catalog: dict, partition: str) -> pl.Expr:
105
+ """Operand dict/scalar -> pl.Expr. Assumes validate() already passed."""
106
+ if isinstance(spec, dict):
107
+ if "col" in spec:
108
+ return pl.col(spec["col"])
109
+ if "fn" in spec:
110
+ arg_spec, builder, _req = INDICATORS[spec["fn"]]
111
+ parsed = [
112
+ a if tag == "int" else _operand(a, catalog=catalog, partition=partition)
113
+ for tag, a in zip(arg_spec, spec["args"])
114
+ ]
115
+ return builder(*parsed, partition=partition)
116
+ key = next(k for k in spec if k in _ARITH)
117
+ vals = [_operand(a, catalog=catalog, partition=partition) for a in spec[key]]
118
+ if len(vals) == 1: # unary fold; freeze names only negate: {"-": [x]}
119
+ return -vals[0] if key == "-" else vals[0]
120
+ return reduce(_ARITH[key], vals)
121
+ return pl.lit(spec)
122
+
123
+
124
+ def _operand_errors(spec, where: str, catalog: dict, errors: list[str]) -> None:
125
+ if spec is None:
126
+ errors.append(f"{where}: operand must not be null")
127
+ elif isinstance(spec, (bool, int, float, str)):
128
+ pass # literal
129
+ elif not isinstance(spec, dict):
130
+ errors.append(f"{where}: operand must be scalar, col, fn, or arithmetic, got {spec!r}")
131
+ elif "col" in spec:
132
+ name = spec["col"]
133
+ if not isinstance(name, str) or name not in catalog:
134
+ errors.append(f"{where}.col: unknown column: {name!r}")
135
+ elif "fn" in spec:
136
+ name = spec["fn"]
137
+ entry = INDICATORS.get(name) if isinstance(name, str) else None
138
+ if entry is None:
139
+ errors.append(f"{where}.fn: unknown indicator: {name!r}")
140
+ return
141
+ arg_spec, _builder, required = entry
142
+ args = spec.get("args")
143
+ if not isinstance(args, list) or len(args) != len(arg_spec):
144
+ got = len(args) if isinstance(args, list) else args
145
+ errors.append(f"{where}: {name!r} takes {len(arg_spec)} args, got {got}")
146
+ return
147
+ for i, (tag, a) in enumerate(zip(arg_spec, args)):
148
+ if tag == "int":
149
+ if not isinstance(a, int) or isinstance(a, bool) or a < 1:
150
+ errors.append(f"{where}.args[{i}]: must be an int >= 1, got {a!r}")
151
+ else:
152
+ _operand_errors(a, f"{where}.args[{i}]", catalog, errors)
153
+ for col in required:
154
+ if col not in catalog:
155
+ errors.append(f"{where}: indicator {name!r} requires column {col!r}")
156
+ elif len(ks := [k for k in spec if k in _ARITH]) == 1:
157
+ vals = spec[ks[0]]
158
+ if not isinstance(vals, list) or not vals:
159
+ errors.append(f"{where}.{ks[0]} must be a nonempty list")
160
+ elif len(vals) == 1 and ks[0] != "-":
161
+ errors.append(f"{where}.{ks[0]} must have >= 2 operands")
162
+ else:
163
+ for i, a in enumerate(vals):
164
+ _operand_errors(a, f"{where}.{ks[0]}[{i}]", catalog, errors)
165
+ else:
166
+ errors.append(f"{where}: operand must be col, fn, or arithmetic, got keys {sorted(spec)}")
167
+
168
+
169
+ # --- node validation -------------------------------------------------------
170
+
171
+
172
+ def _val_ok(dtype: str, value) -> bool:
173
+ return _ok_date(value) if dtype == "date" else _is(dtype, value)
174
+
175
+
176
+ def _leaf_errors(f, where: str, catalog: dict, errors: list[str]) -> None:
177
+ prop = f.get("property")
178
+ op = f.get("op")
179
+ computed_lhs = isinstance(prop, dict)
180
+ spec = catalog.get(prop) if isinstance(prop, str) else None
181
+ if computed_lhs:
182
+ _operand_errors(prop, f"{where}.property", catalog, errors)
183
+ elif spec is None:
184
+ errors.append(f"{where}: unknown property: {prop!r}")
185
+ if op not in _OPS and op not in _CROSS:
186
+ errors.append(f"{where}: unknown operator: {op!r}")
187
+ return
188
+ value = f.get("value")
189
+ if op in _CROSS:
190
+ _operand_errors(value, f"{where}.value", catalog, errors)
191
+ elif op in _LIST_OPS:
192
+ if computed_lhs:
193
+ errors.append(f"{where}: computed left side not supported for {op!r}")
194
+ if spec is None:
195
+ return
196
+ dtype = spec["dtype"]
197
+ if op == "contains":
198
+ if dtype != "str" or not isinstance(value, str):
199
+ errors.append(f"{where}: 'contains' needs a string value on a string property")
200
+ elif op == "between":
201
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
202
+ errors.append(f"{where}: 'between' needs [lo, hi]")
203
+ elif not all(_val_ok(dtype, v) for v in value):
204
+ errors.append(f"{where}: 'between' bounds must be {dtype} values")
205
+ elif not isinstance(value, list) or not value:
206
+ errors.append(f"{where}: 'in' needs a nonempty list of values")
207
+ elif not all(_val_ok(dtype, v) for v in value):
208
+ errors.append(f"{where}: 'in' values must be {dtype} values")
209
+ elif isinstance(value, dict):
210
+ _operand_errors(value, f"{where}.value", catalog, errors)
211
+ elif value is None:
212
+ errors.append(f"{where}: value must not be null")
213
+ elif spec is not None and not _val_ok(spec["dtype"], value):
214
+ dtype = spec["dtype"]
215
+ if dtype == "date":
216
+ errors.append(
217
+ f"{where}: value for {prop!r} (date) must be an ISO date string, got {value!r}"
218
+ )
219
+ else:
220
+ errors.append(f"{where}: value for {prop!r} ({dtype}) must be {dtype}, got {value!r}")
221
+
222
+
223
+ def _node_errors(node, where: str, catalog: dict, errors: list[str]) -> None:
224
+ if not isinstance(node, dict):
225
+ errors.append(f"{where}: node must be an object, got {node!r}")
226
+ elif "all" in node or "any" in node:
227
+ key = "all" if "all" in node else "any"
228
+ kids = node[key]
229
+ if not isinstance(kids, list) or not kids:
230
+ errors.append(f"{where}.{key} must be a nonempty list")
231
+ else:
232
+ for i, kid in enumerate(kids):
233
+ _node_errors(kid, f"{where}.{key}[{i}]", catalog, errors)
234
+ elif "not" in node:
235
+ if not isinstance(node["not"], dict):
236
+ errors.append(f"{where}.not must be an object")
237
+ else:
238
+ _node_errors(node["not"], f"{where}.not", catalog, errors)
239
+ else:
240
+ _leaf_errors(node, where, catalog, errors)
241
+
242
+
243
+ def _collect(scan_def, *, catalog: dict) -> list[str]:
244
+ """Collect every validation error in the definition (empty list = valid)."""
245
+ if not isinstance(scan_def, dict):
246
+ return [f"scan definition must be an object, got {scan_def!r}"]
247
+ errors: list[str] = []
248
+ filters = scan_def.get("filters") or []
249
+ if not isinstance(filters, list):
250
+ errors.append("filters must be a list")
251
+ else:
252
+ for i, node in enumerate(filters):
253
+ _node_errors(node, f"filters[{i}]", catalog, errors)
254
+ order_by = scan_def.get("order_by") or []
255
+ if not isinstance(order_by, list):
256
+ errors.append("order_by must be a list")
257
+ else:
258
+ for ob in order_by:
259
+ if not isinstance(ob, dict):
260
+ errors.append(f"order entry must be an object, got {ob!r}")
261
+ continue
262
+ if ob.get("property") not in catalog:
263
+ errors.append(f"unknown property: {ob.get('property')!r}")
264
+ if ob.get("dir", "asc") not in ("asc", "desc"):
265
+ errors.append(f"unknown direction: {ob.get('dir')!r}")
266
+ limit = scan_def.get("limit")
267
+ if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool) or limit < 0):
268
+ errors.append("limit must be a nonnegative integer")
269
+ return errors
270
+
271
+
272
+ def validate(scan_def, *, catalog: dict = PROPERTY_CATALOG) -> list[str]:
273
+ """Return error strings (empty = valid); human-facing, keyed to fields."""
274
+ return _collect(scan_def, catalog=catalog)
275
+
276
+
277
+ # --- compilation -----------------------------------------------------------
278
+
279
+
280
+ def _compile_leaf(f, *, catalog: dict, partition: str) -> pl.Expr:
281
+ prop = f["property"]
282
+ op = f["op"]
283
+ lhs = _operand(prop, catalog=catalog, partition=partition) if isinstance(prop, dict) else pl.col(prop)
284
+ value = f["value"]
285
+ spec = catalog.get(prop) if isinstance(prop, str) else None
286
+ if (
287
+ op in _OPS
288
+ and spec is not None
289
+ and spec["dtype"] == "date"
290
+ and isinstance(value, (str, list, tuple))
291
+ ):
292
+ # date literals arrive as ISO strings; parse so polars compares
293
+ # date-to-date (parseability validated in _leaf_errors)
294
+ value = (
295
+ dt.date.fromisoformat(value)
296
+ if isinstance(value, str)
297
+ else [dt.date.fromisoformat(v) for v in value]
298
+ )
299
+ if op in _CROSS:
300
+ rhs = _operand(f["value"], catalog=catalog, partition=partition)
301
+ prev_lhs, prev_rhs = lhs.shift(1).over(partition), rhs.shift(1).over(partition)
302
+ return (lhs > rhs) & (prev_lhs <= prev_rhs) if op == "cross_above" else (lhs < rhs) & (prev_lhs >= prev_rhs)
303
+ if op in _LIST_OPS:
304
+ # in/between/contains values are validated literal-only: raw scalars,
305
+ # never operand exprs, so the raw value is the compiled form.
306
+ return _OPS[op](lhs, value)
307
+ return _OPS[op](lhs, _operand(value, catalog=catalog, partition=partition))
308
+
309
+
310
+ def _compile_node(node, *, catalog: dict, partition: str) -> pl.Expr:
311
+ if "all" in node:
312
+ return reduce(operator.and_, (_compile_node(n, catalog=catalog, partition=partition) for n in node["all"]))
313
+ if "any" in node:
314
+ return reduce(operator.or_, (_compile_node(n, catalog=catalog, partition=partition) for n in node["any"]))
315
+ if "not" in node:
316
+ return ~_compile_node(node["not"], catalog=catalog, partition=partition)
317
+ return _compile_leaf(node, catalog=catalog, partition=partition)
318
+
319
+
320
+ def compile(scan_def, *, catalog: dict = PROPERTY_CATALOG, partition: str = "symbol") -> pl.Expr:
321
+ """AND all top-level filter nodes into one polars predicate expression."""
322
+ errors = _collect(scan_def, catalog=catalog)
323
+ if errors:
324
+ raise ValueError(errors[0])
325
+ expr = pl.lit(True)
326
+ for node in scan_def.get("filters") or []:
327
+ expr = expr & _compile_node(node, catalog=catalog, partition=partition)
328
+ return expr
329
+
330
+
331
+ def apply(frame, scan_def, *, catalog: dict = PROPERTY_CATALOG, partition: str = "symbol"):
332
+ """Filter + order_by + limit a frame (eager or lazy) by a scan definition."""
333
+ out = frame.filter(compile(scan_def, catalog=catalog, partition=partition))
334
+ order_by = scan_def.get("order_by") or []
335
+ if order_by:
336
+ keys = [ob["property"] for ob in order_by]
337
+ dirs = [ob.get("dir", "asc") == "desc" for ob in order_by]
338
+ out = out.sort(keys, descending=dirs)
339
+ limit = scan_def.get("limit")
340
+ if limit is not None:
341
+ out = out.head(limit)
342
+ return out
343
+
344
+
345
+ # --- catalogs --------------------------------------------------------------
346
+
347
+
348
+ def catalog_from_schema(frame) -> dict[str, dict[str, str]]:
349
+ """polars schema (DataFrame or LazyFrame) -> catalog; unmapped dtypes skipped."""
350
+ mapping = (
351
+ (pl.Boolean, "bool"),
352
+ (pl.Int64, "int"),
353
+ (pl.Int32, "int"),
354
+ (pl.Int16, "int"),
355
+ (pl.Int8, "int"),
356
+ (pl.UInt64, "int"),
357
+ (pl.UInt32, "int"),
358
+ (pl.UInt16, "int"),
359
+ (pl.UInt8, "int"),
360
+ (pl.Float64, "float"),
361
+ (pl.Float32, "float"),
362
+ (pl.String, "str"),
363
+ (pl.Date, "date"),
364
+ (pl.Datetime, "date"),
365
+ )
366
+ return {
367
+ name: {"label": name, "dtype": dtype}
368
+ for name, dt in frame.collect_schema().items()
369
+ for base, dtype in mapping
370
+ if isinstance(dt, base)
371
+ }
@@ -0,0 +1,71 @@
1
+ """Indicator registry: name -> (arg_spec, builder, required_cols).
2
+
3
+ Each entry:
4
+ - ``arg_spec``: tuple with one tag per positional arg — ``"expr"`` (any operand) or
5
+ ``"int"`` (literal int >= 1).
6
+ - ``builder(*parsed, partition) -> pl.Expr``: polars-native; every window op uses
7
+ ``.over(partition)``.
8
+ - ``required_cols``: columns that must exist in the catalog (e.g. ``atr`` needs
9
+ ``high, low, close``).
10
+
11
+ Extend by inserting entries; this shape is the contract. A future
12
+ ``scanlang.talib`` module (pyproject ``talib`` extra) populates the same dict for
13
+ exact-value parity on collected results — it cannot participate in lazy pushdown.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+
20
+ import polars as pl
21
+
22
+ __all__ = ["INDICATORS"]
23
+
24
+
25
+ def _rsi(e: pl.Expr, n: int, partition: str) -> pl.Expr:
26
+ delta = e.diff().over(partition)
27
+ gain = delta.clip(lower_bound=0).rolling_mean(n).over(partition)
28
+ loss = (-delta.clip(upper_bound=0)).rolling_mean(n).over(partition)
29
+ return (100 - 100 / (1 + gain / loss)).fill_null(50.0)
30
+
31
+
32
+ def _atr(n: int, partition: str) -> pl.Expr:
33
+ pc = pl.col("close").shift(1).over(partition)
34
+ tr = pl.max_horizontal(
35
+ pl.col("high") - pl.col("low"),
36
+ (pl.col("high") - pc).abs(),
37
+ (pc - pl.col("low")).abs(),
38
+ )
39
+ return tr.rolling_mean(n).over(partition)
40
+
41
+
42
+ # name -> (arg_spec, builder, required_cols)
43
+ INDICATORS: dict[str, tuple[tuple[str, ...], Callable, tuple[str, ...]]] = {
44
+ "sma": (
45
+ ("expr", "int"),
46
+ lambda e, n, partition: e.rolling_mean(n).over(partition),
47
+ (),
48
+ ),
49
+ "ema": (
50
+ ("expr", "int"),
51
+ lambda e, span, partition: e.ewm_mean(span=span, adjust=False).over(partition),
52
+ (),
53
+ ),
54
+ "rsi": (("expr", "int"), _rsi, ()),
55
+ "atr": (("int",), _atr, ("high", "low", "close")),
56
+ "rmin": (
57
+ ("expr", "int"),
58
+ lambda e, n, partition: e.rolling_min(n).over(partition),
59
+ (),
60
+ ),
61
+ "rmax": (
62
+ ("expr", "int"),
63
+ lambda e, n, partition: e.rolling_max(n).over(partition),
64
+ (),
65
+ ),
66
+ "shift": (
67
+ ("expr", "int"),
68
+ lambda e, n, partition: e.shift(n).over(partition),
69
+ (),
70
+ ),
71
+ }
@@ -0,0 +1,176 @@
1
+ """Parabolic breakout scoring: vectorized polars over OHLCV bars.
2
+
3
+ Detects BASE / BREAKOUT / TREND / CLIMAX phases per symbol using EMA
4
+ alignment, ATR expansion, volume confirmation, accumulation, spring
5
+ patterns, and RSI. One lazy polars pass over all symbols.
6
+
7
+ Input: LazyFrame (eager DataFrame accepted, coerced) with columns
8
+ ``symbol, session, open, high, low, close, volume``, sorted
9
+ ``symbol, session`` ascending (caller guarantees the sort, caller
10
+ collects at its edge — the return is lazy). Output columns mirror
11
+ ``compiler.PROPERTY_CATALOG``.
12
+
13
+ Composite weights: spring +15, accumulation +10, volume +5/+15, EMA stack
14
+ +10/+20, fresh EMA5/20 cross +15, EMA50 rising +10, price>EMA5 +5, ATR
15
+ expansion +10/+15, wide range +5, RSI>70 +5, upper wick +10, near-52w-low
16
+ +5. Phase thresholds: CLIMAX >=70 with a blow-off condition, TREND >=60
17
+ with stack+ATR, BREAKOUT >=50 with volume or spring + EMA5>EMA20, BASE >=40.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import polars as pl
23
+
24
+ __all__ = ["FRESHNESS_DAYS", "MIN_BARS", "score_bars"]
25
+
26
+ # Skip symbols with too little history for ATR/EMA50 to be meaningful.
27
+ MIN_BARS = 30
28
+ # Only score symbols whose latest bar is this close to the lake's max date.
29
+ FRESHNESS_DAYS = 5
30
+
31
+
32
+ def score_bars(
33
+ bars: pl.LazyFrame | pl.DataFrame,
34
+ *,
35
+ min_bars: int = MIN_BARS,
36
+ freshness_days: int = FRESHNESS_DAYS,
37
+ ) -> pl.LazyFrame:
38
+ """Score every symbol's latest bar in ``bars`` and classify its phase."""
39
+ bars = bars.lazy()
40
+ c = pl.col
41
+
42
+ ind = (
43
+ bars.with_columns(
44
+ ema5=c("close").ewm_mean(span=5, adjust=False).over("symbol"),
45
+ ema20=c("close").ewm_mean(span=20, adjust=False).over("symbol"),
46
+ ema50=c("close").ewm_mean(span=50, adjust=False).over("symbol"),
47
+ _pc=c("close").shift(1).over("symbol"),
48
+ _up=c("close") > c("open"),
49
+ _dn=c("close") < c("open"),
50
+ _n=c("close").count().over("symbol"),
51
+ )
52
+ .with_columns(
53
+ _tr=pl.max_horizontal(
54
+ c("high") - c("low"),
55
+ (c("high") - c("_pc")).abs(),
56
+ (c("low") - c("_pc")).abs(),
57
+ ),
58
+ )
59
+ .with_columns(atr=c("_tr").rolling_mean(14).over("symbol"))
60
+ .with_columns(
61
+ baseline_atr=c("atr").rolling_mean(20, min_samples=1).over("symbol"),
62
+ avg_vol=c("volume").cast(pl.Float64).rolling_mean(20, min_samples=1).over("symbol"),
63
+ _delta=c("close").diff().over("symbol"),
64
+ )
65
+ .with_columns(
66
+ avg_up_vol=pl.when(c("_up"))
67
+ .then(c("volume").cast(pl.Float64))
68
+ .otherwise(None)
69
+ .rolling_mean(20, min_samples=1)
70
+ .over("symbol"),
71
+ avg_dn_vol=pl.when(c("_dn"))
72
+ .then(c("volume").cast(pl.Float64))
73
+ .otherwise(None)
74
+ .rolling_mean(20, min_samples=1)
75
+ .over("symbol"),
76
+ _gain=c("_delta").clip(lower_bound=0).rolling_mean(14).over("symbol"),
77
+ _loss=(-c("_delta").clip(upper_bound=0)).rolling_mean(14).over("symbol"),
78
+ )
79
+ .with_columns(
80
+ acc_score=pl.when(c("avg_vol") == 0)
81
+ .then(0.0)
82
+ .when(c("_dn").cast(pl.UInt8).rolling_sum(20, min_samples=1).over("symbol") == 0)
83
+ .then(1.0)
84
+ .otherwise((c("avg_up_vol").fill_null(0.0) - c("avg_dn_vol").fill_null(0.0)) / c("avg_vol")),
85
+ rsi=(100 - 100 / (1 + c("_gain") / c("_loss"))).fill_null(50.0),
86
+ vol_ratio=pl.when(c("avg_vol") > 0).then(c("volume") / c("avg_vol")).otherwise(1.0),
87
+ atr_ratio=pl.when(c("baseline_atr") > 0)
88
+ .then(c("atr").fill_null(c("baseline_atr")) / c("baseline_atr"))
89
+ .otherwise(1.0),
90
+ _spring=(c("low").shift(1).over("symbol") < c("low").shift(2).over("symbol"))
91
+ .and_(c("close").shift(1).over("symbol") > c("open").shift(1).over("symbol"))
92
+ .and_(c("close").shift(1).over("symbol") > c("low").shift(2).over("symbol")),
93
+ _ema_stack=(c("ema5") > c("ema20")).and_(c("ema20") > c("ema50")),
94
+ _cross=(c("ema5") > c("ema20")).and_(
95
+ c("ema5").shift(1).over("symbol") <= c("ema20").shift(1).over("symbol")
96
+ ),
97
+ )
98
+ .with_columns(
99
+ recent_cross=c("_cross")
100
+ .fill_null(False)
101
+ .cast(pl.UInt8)
102
+ .rolling_sum(5, min_samples=1)
103
+ .over("symbol")
104
+ > 0,
105
+ ema50_rising=c("ema50") > c("ema50").shift(1).over("symbol"),
106
+ _low52=c("low").rolling_min(252, min_samples=1).over("symbol"),
107
+ _range=c("high") - c("low"),
108
+ _last=c("session").max().over("symbol"),
109
+ )
110
+ .with_columns(
111
+ upper_wick_pct=pl.when(c("_range") > 0)
112
+ .then((c("high") - pl.max_horizontal(c("close"), c("open"))) / c("_range"))
113
+ .otherwise(0.0),
114
+ near_52w_low=(c("_low52") > 0).and_(c("close") < c("_low52") * 1.30),
115
+ is_latest=c("session") == c("_last"),
116
+ )
117
+ )
118
+
119
+ score = (
120
+ pl.when(c("_spring")).then(15).otherwise(0)
121
+ + pl.when(c("acc_score") > 0.10).then(10).otherwise(0)
122
+ + pl.when(c("vol_ratio") > 1.5).then(15).when(c("vol_ratio") > 1.0).then(5).otherwise(0)
123
+ + pl.when(c("_ema_stack")).then(20).when(c("ema5") > c("ema20")).then(10).otherwise(0)
124
+ + pl.when(c("recent_cross")).then(15).otherwise(0)
125
+ + pl.when(c("ema50_rising")).then(10).otherwise(0)
126
+ + pl.when(c("close") > c("ema5")).then(5).otherwise(0)
127
+ + pl.when(c("atr_ratio") > 2.0).then(15).when(c("atr_ratio") > 1.5).then(10).otherwise(0)
128
+ + pl.when(c("_range") > 2 * c("atr").fill_null(c("baseline_atr"))).then(5).otherwise(0)
129
+ + pl.when(c("rsi") > 70).then(5).otherwise(0)
130
+ + pl.when(c("upper_wick_pct") > 0.4).then(10).otherwise(0)
131
+ + pl.when(c("near_52w_low")).then(5).otherwise(0)
132
+ )
133
+
134
+ return (
135
+ ind.with_columns(score=score.cast(pl.Int16))
136
+ .with_columns(
137
+ phase=pl.when(
138
+ (c("score") >= 70).and_((c("vol_ratio") > 3).or_(c("atr_ratio") > 2.5).or_(c("rsi") > 85))
139
+ )
140
+ .then(pl.lit("CLIMAX"))
141
+ .when((c("score") >= 60).and_(c("_ema_stack")).and_(c("atr_ratio") > 1.5))
142
+ .then(pl.lit("TREND"))
143
+ .when(
144
+ (c("score") >= 50)
145
+ .and_((c("vol_ratio") > 1.5).or_(c("_spring")))
146
+ .and_(c("ema5") > c("ema20"))
147
+ )
148
+ .then(pl.lit("BREAKOUT"))
149
+ .when(c("score") >= 40)
150
+ .then(pl.lit("BASE"))
151
+ .otherwise(pl.lit("NONE")),
152
+ )
153
+ .filter(c("is_latest"))
154
+ .filter(c("_n") >= min_bars)
155
+ # freshness: the symbol's latest bar must be within N days of the
156
+ # GLOBAL max session in the frame (not its own max — that's always 0)
157
+ .filter((c("session").max() - c("session")).dt.total_days() <= freshness_days)
158
+ .select(
159
+ "symbol",
160
+ "session",
161
+ "close",
162
+ "score",
163
+ "phase",
164
+ vol_ratio=c("vol_ratio").round(2),
165
+ atr_ratio=c("atr_ratio").round(2),
166
+ rsi=c("rsi").round(1),
167
+ acc_score=c("acc_score").round(3),
168
+ spring=c("_spring"),
169
+ ema_stack=c("_ema_stack"),
170
+ recent_cross=c("recent_cross"),
171
+ upper_wick_pct=c("upper_wick_pct").round(2),
172
+ near_52w_low=c("near_52w_low"),
173
+ bars=c("_n"),
174
+ )
175
+ .sort(["score", "symbol"], descending=[True, False])
176
+ )
@@ -0,0 +1,60 @@
1
+ """Forward-return evidence for a scan's past runs. Pure — no frame deps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from bisect import bisect_left
7
+
8
+ __all__ = ["HORIZONS", "backtest_summary", "forward_stats"]
9
+
10
+ HORIZONS = (("5d", 5), ("10d", 10), ("20d", 20))
11
+
12
+
13
+ def forward_stats(
14
+ sessions: list[dt.date], closes: list[float], ran_on: dt.date
15
+ ) -> dict[str, float] | None:
16
+ """+5/+10/+20d return of the run's entry price vs the latest lake close.
17
+
18
+ Entry anchors at the first session on/after ``ran_on`` (a scan picked
19
+ symbols at that day's close; the next session is the first tradable).
20
+ ``sessions``/``closes`` are ascending and aligned. Returns None when the
21
+ 20d forward window hasn't elapsed in the lake yet (fresh run) or the run
22
+ predates the lake window — the caller excludes the run.
23
+ """
24
+ i = bisect_left(sessions, ran_on)
25
+ if i + HORIZONS[-1][1] >= len(closes):
26
+ return None
27
+ entry = closes[i]
28
+ return {label: (closes[i + n] / entry - 1) * 100 for label, n in HORIZONS}
29
+
30
+
31
+ def backtest_summary(runs: list[dict], stats_fn) -> dict:
32
+ """Aggregate forward returns across a scan's runs: hit-rate + avg per horizon.
33
+
34
+ ``runs`` are run records (``ran_at`` text + ``symbols`` list).
35
+ ``stats_fn(symbol, ran_on)`` returns ``{label: return%}`` or None when the
36
+ symbol has no evaluable bars. Runs whose forward window hasn't elapsed are
37
+ excluded — caller surfaces "n included / m total".
38
+ """
39
+ total = len(runs)
40
+ picks = 0
41
+ per: dict[str, list[float]] = {label: [] for label, _ in HORIZONS}
42
+ for run in runs:
43
+ ran_on = dt.date.fromisoformat((run["ran_at"] or " ")[:10])
44
+ for symbol in run["symbols"]:
45
+ st = stats_fn(symbol, ran_on)
46
+ if st is None:
47
+ continue
48
+ picks += 1
49
+ for label, ret in st.items():
50
+ per[label].append(ret)
51
+ horizons = [
52
+ (
53
+ label,
54
+ 100.0 * sum(r > 0 for r in rets) / len(rets) if rets else 0.0,
55
+ sum(rets) / len(rets) if rets else 0.0,
56
+ len(rets),
57
+ )
58
+ for label, rets in per.items()
59
+ ]
60
+ return {"included": picks, "total": total, "horizons": horizons}