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 +3 -0
- scanlang-0.1.0/PKG-INFO +219 -0
- scanlang-0.1.0/README.md +193 -0
- scanlang-0.1.0/pyproject.toml +40 -0
- scanlang-0.1.0/src/scanlang/__init__.py +35 -0
- scanlang-0.1.0/src/scanlang/compiler.py +371 -0
- scanlang-0.1.0/src/scanlang/indicators.py +71 -0
- scanlang-0.1.0/src/scanlang/scoring.py +176 -0
- scanlang-0.1.0/src/scanlang/stats.py +60 -0
scanlang-0.1.0/LICENSE
ADDED
scanlang-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
scanlang-0.1.0/README.md
ADDED
|
@@ -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}
|