trail-lang 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.
- trail_lang-0.1.0/.github/workflows/ci.yml +28 -0
- trail_lang-0.1.0/.github/workflows/release.yml +39 -0
- trail_lang-0.1.0/.gitignore +8 -0
- trail_lang-0.1.0/LICENSE +21 -0
- trail_lang-0.1.0/PKG-INFO +101 -0
- trail_lang-0.1.0/README.md +74 -0
- trail_lang-0.1.0/RELEASING.md +29 -0
- trail_lang-0.1.0/examples/performance.trail +56 -0
- trail_lang-0.1.0/examples/piotroski.trail +25 -0
- trail_lang-0.1.0/pyproject.toml +56 -0
- trail_lang-0.1.0/tests/__init__.py +0 -0
- trail_lang-0.1.0/tests/test_acceptance_performance.py +44 -0
- trail_lang-0.1.0/tests/test_acceptance_piotroski.py +70 -0
- trail_lang-0.1.0/tests/test_catalog.py +115 -0
- trail_lang-0.1.0/tests/test_cli.py +51 -0
- trail_lang-0.1.0/tests/test_compiler.py +84 -0
- trail_lang-0.1.0/tests/test_config.py +84 -0
- trail_lang-0.1.0/tests/test_conform.py +37 -0
- trail_lang-0.1.0/tests/test_deps.py +21 -0
- trail_lang-0.1.0/tests/test_factor.py +47 -0
- trail_lang-0.1.0/tests/test_functions.py +89 -0
- trail_lang-0.1.0/tests/test_ops_factor.py +34 -0
- trail_lang-0.1.0/tests/test_ops_time.py +36 -0
- trail_lang-0.1.0/tests/test_ops_xs.py +38 -0
- trail_lang-0.1.0/tests/test_parser_decls.py +69 -0
- trail_lang-0.1.0/tests/test_parser_expr.py +47 -0
- trail_lang-0.1.0/tests/test_pipeline.py +29 -0
- trail_lang-0.1.0/tests/test_registry.py +40 -0
- trail_lang-0.1.0/tests/test_schema_fixture.py +30 -0
- trail_lang-0.1.0/tests/test_smoke.py +5 -0
- trail_lang-0.1.0/tests/test_source.py +84 -0
- trail_lang-0.1.0/tests/test_stdlib.py +111 -0
- trail_lang-0.1.0/tests/test_timeseries.py +53 -0
- trail_lang-0.1.0/tests/test_validate.py +67 -0
- trail_lang-0.1.0/trail/__init__.py +1 -0
- trail_lang-0.1.0/trail/ast.py +173 -0
- trail_lang-0.1.0/trail/catalog.py +245 -0
- trail_lang-0.1.0/trail/cli.py +102 -0
- trail_lang-0.1.0/trail/compiler.py +140 -0
- trail_lang-0.1.0/trail/config.py +64 -0
- trail_lang-0.1.0/trail/deps.py +80 -0
- trail_lang-0.1.0/trail/fixtures.py +55 -0
- trail_lang-0.1.0/trail/grammar.lark +92 -0
- trail_lang-0.1.0/trail/library.py +20 -0
- trail_lang-0.1.0/trail/macro.py +102 -0
- trail_lang-0.1.0/trail/ops.py +133 -0
- trail_lang-0.1.0/trail/parser.py +211 -0
- trail_lang-0.1.0/trail/pipeline.py +24 -0
- trail_lang-0.1.0/trail/py.typed +0 -0
- trail_lang-0.1.0/trail/registry.py +50 -0
- trail_lang-0.1.0/trail/schema.py +54 -0
- trail_lang-0.1.0/trail/source.py +118 -0
- trail_lang-0.1.0/trail/sources.py +135 -0
- trail_lang-0.1.0/trail/stdlib/__init__.py +0 -0
- trail_lang-0.1.0/trail/stdlib/calculus.trail +12 -0
- trail_lang-0.1.0/trail/stdlib/core.trail +13 -0
- trail_lang-0.1.0/trail/stdlib/factor.trail +13 -0
- trail_lang-0.1.0/trail/stdlib/geometry.trail +5 -0
- trail_lang-0.1.0/trail/stdlib/math.trail +41 -0
- trail_lang-0.1.0/trail/stdlib/stats.trail +17 -0
- trail_lang-0.1.0/trail/stdlib/timeseries.trail +20 -0
- trail_lang-0.1.0/trail/stdlib/transform.trail +10 -0
- trail_lang-0.1.0/trail/testing.py +91 -0
- trail_lang-0.1.0/trail/validate.py +140 -0
- trail_lang-0.1.0/uv.lock +516 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: test (py${{ matrix.python-version }})
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
- name: Sync dependencies
|
|
24
|
+
run: uv sync --frozen
|
|
25
|
+
- name: Lint (ruff)
|
|
26
|
+
run: uv run ruff check .
|
|
27
|
+
- name: Test (pytest)
|
|
28
|
+
run: uv run pytest -q
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: test and build
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- name: Sync
|
|
17
|
+
run: uv sync --frozen
|
|
18
|
+
- name: Test
|
|
19
|
+
run: uv run pytest -q
|
|
20
|
+
- name: Build sdist and wheel
|
|
21
|
+
run: uv build
|
|
22
|
+
- uses: actions/upload-artifact@v4
|
|
23
|
+
with:
|
|
24
|
+
name: dist
|
|
25
|
+
path: dist/
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
name: publish to PyPI
|
|
29
|
+
needs: build
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
environment: pypi
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write # trusted publishing (OIDC), no API token needed
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/download-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: dist
|
|
38
|
+
path: dist/
|
|
39
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
trail_lang-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Usman Shahid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trail-lang
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A declarative expression language for financial indicators, scoring, and screening over security panels
|
|
5
|
+
Project-URL: Homepage, https://github.com/trail-language
|
|
6
|
+
Project-URL: Repository, https://github.com/trail-language/trail-py
|
|
7
|
+
Project-URL: Specification, https://github.com/trail-language/spec
|
|
8
|
+
Project-URL: Issues, https://github.com/trail-language/trail-py/issues
|
|
9
|
+
Author-email: Usman Shahid <u.manshahid@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: expression-language,factor,finance,indicators,polars,quant,screening
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: click>=8.1
|
|
23
|
+
Requires-Dist: lark>=1.2
|
|
24
|
+
Requires-Dist: polars>=1.20
|
|
25
|
+
Requires-Dist: pyyaml>=6
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<img src="https://raw.githubusercontent.com/trail-language/spec/main/brand/icon-color-240.png" alt="Trail" width="120">
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
# trail-py
|
|
33
|
+
|
|
34
|
+
The reference implementation of **Trail** - a small, total, declarative language for computing
|
|
35
|
+
financial indicators, scores, and screening strategies over panels of securities. Trail
|
|
36
|
+
expressions compile to vectorized [Polars](https://pola.rs) operations.
|
|
37
|
+
|
|
38
|
+
The language specification (grammar, reference, standard library) lives in
|
|
39
|
+
[**trail-language/spec**](https://github.com/trail-language/spec).
|
|
40
|
+
|
|
41
|
+
```trail
|
|
42
|
+
model quality on us_main period annual {
|
|
43
|
+
operating_margin = income.operating_income / income.revenue
|
|
44
|
+
score om_score weight 7 {
|
|
45
|
+
2 if operating_margin > 0.12
|
|
46
|
+
1 if operating_margin > 0.05
|
|
47
|
+
else 0
|
|
48
|
+
}
|
|
49
|
+
export composite = weighted_score()
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install trail-lang # or: uv add trail-lang
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## CLI
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
trail validate models/quality.trail # parse, kind-check, dry-run
|
|
63
|
+
trail run models/quality.trail --model quality # evaluate a model
|
|
64
|
+
trail run models/quality.trail --model quality --config trail.yaml
|
|
65
|
+
trail catalog # discover fields, functions, sources
|
|
66
|
+
trail catalog income # fields in a namespace
|
|
67
|
+
trail catalog cagr # describe a function
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The standard library is loaded implicitly; pass `--no-stdlib` to opt out.
|
|
71
|
+
|
|
72
|
+
## Library
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from trail.pipeline import prepare
|
|
76
|
+
from trail.compiler import compile_model
|
|
77
|
+
from trail.fixtures import load_panel # a bundled demo panel
|
|
78
|
+
|
|
79
|
+
program = prepare("model m { export margin = income.operating_income / income.revenue }")
|
|
80
|
+
model = next(d for d in program.decls if type(d).__name__ == "ModelDecl")
|
|
81
|
+
result = compile_model(model, {}).run(load_panel())
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Architecture
|
|
85
|
+
|
|
86
|
+
Parser (Lark LALR) → typed AST → macro expansion (`def` inlining) → kind-checked validation →
|
|
87
|
+
Polars compiler. The engine carries only irreducible **primitives**; the large **derived**
|
|
88
|
+
function library is written in Trail itself and shipped as `trail/stdlib/*.trail` (canonical
|
|
89
|
+
copy in the [spec](https://github.com/trail-language/spec)).
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uv sync
|
|
95
|
+
uv run pytest -q
|
|
96
|
+
uv run ruff check .
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/trail-language/spec/main/brand/icon-color-240.png" alt="Trail" width="120">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# trail-py
|
|
6
|
+
|
|
7
|
+
The reference implementation of **Trail** - a small, total, declarative language for computing
|
|
8
|
+
financial indicators, scores, and screening strategies over panels of securities. Trail
|
|
9
|
+
expressions compile to vectorized [Polars](https://pola.rs) operations.
|
|
10
|
+
|
|
11
|
+
The language specification (grammar, reference, standard library) lives in
|
|
12
|
+
[**trail-language/spec**](https://github.com/trail-language/spec).
|
|
13
|
+
|
|
14
|
+
```trail
|
|
15
|
+
model quality on us_main period annual {
|
|
16
|
+
operating_margin = income.operating_income / income.revenue
|
|
17
|
+
score om_score weight 7 {
|
|
18
|
+
2 if operating_margin > 0.12
|
|
19
|
+
1 if operating_margin > 0.05
|
|
20
|
+
else 0
|
|
21
|
+
}
|
|
22
|
+
export composite = weighted_score()
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install trail-lang # or: uv add trail-lang
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## CLI
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
trail validate models/quality.trail # parse, kind-check, dry-run
|
|
36
|
+
trail run models/quality.trail --model quality # evaluate a model
|
|
37
|
+
trail run models/quality.trail --model quality --config trail.yaml
|
|
38
|
+
trail catalog # discover fields, functions, sources
|
|
39
|
+
trail catalog income # fields in a namespace
|
|
40
|
+
trail catalog cagr # describe a function
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The standard library is loaded implicitly; pass `--no-stdlib` to opt out.
|
|
44
|
+
|
|
45
|
+
## Library
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from trail.pipeline import prepare
|
|
49
|
+
from trail.compiler import compile_model
|
|
50
|
+
from trail.fixtures import load_panel # a bundled demo panel
|
|
51
|
+
|
|
52
|
+
program = prepare("model m { export margin = income.operating_income / income.revenue }")
|
|
53
|
+
model = next(d for d in program.decls if type(d).__name__ == "ModelDecl")
|
|
54
|
+
result = compile_model(model, {}).run(load_panel())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Architecture
|
|
58
|
+
|
|
59
|
+
Parser (Lark LALR) → typed AST → macro expansion (`def` inlining) → kind-checked validation →
|
|
60
|
+
Polars compiler. The engine carries only irreducible **primitives**; the large **derived**
|
|
61
|
+
function library is written in Trail itself and shipped as `trail/stdlib/*.trail` (canonical
|
|
62
|
+
copy in the [spec](https://github.com/trail-language/spec)).
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv sync
|
|
68
|
+
uv run pytest -q
|
|
69
|
+
uv run ruff check .
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
`trail-lang` publishes to PyPI automatically when a version tag is pushed, via GitHub Actions
|
|
4
|
+
trusted publishing (OIDC, no stored token). See `.github/workflows/release.yml`.
|
|
5
|
+
|
|
6
|
+
## One-time setup
|
|
7
|
+
|
|
8
|
+
1. Reserve/create the project `trail-lang` on PyPI.
|
|
9
|
+
2. On PyPI, under the project's **Publishing** settings, add a GitHub trusted publisher:
|
|
10
|
+
- Owner: `trail-language`
|
|
11
|
+
- Repository: `trail-py`
|
|
12
|
+
- Workflow: `release.yml`
|
|
13
|
+
- Environment: `pypi`
|
|
14
|
+
3. In this repository's **Settings > Environments**, create an environment named `pypi`
|
|
15
|
+
(optionally require a reviewer for publish approvals).
|
|
16
|
+
|
|
17
|
+
## Cutting a release
|
|
18
|
+
|
|
19
|
+
1. Bump `version` in `pyproject.toml`.
|
|
20
|
+
2. Commit, then tag and push:
|
|
21
|
+
```bash
|
|
22
|
+
git tag v0.1.0
|
|
23
|
+
git push origin v0.1.0
|
|
24
|
+
```
|
|
25
|
+
3. The `Release` workflow runs the tests, builds the sdist and wheel, and publishes to PyPI.
|
|
26
|
+
|
|
27
|
+
Installability is covered by CI-agnostic packaging: the wheel bundles the grammar and standard
|
|
28
|
+
library, so `pip install trail-lang` and `pipx install trail-lang` both yield a working `trail`
|
|
29
|
+
CLI with no source tree present.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
universe all_active = stocks where meta.is_active
|
|
2
|
+
|
|
3
|
+
model performance on all_active period annual {
|
|
4
|
+
desc "v1 performance.pel translation (fixture-field subset)"
|
|
5
|
+
|
|
6
|
+
revenue_cagr = cagr(income.revenue, 4)
|
|
7
|
+
score revenue_growth_score weight 7 {
|
|
8
|
+
2 if revenue_cagr > 0.15
|
|
9
|
+
1 if revenue_cagr > 0.05
|
|
10
|
+
else 0
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
operating_margin = income.operating_income / income.revenue
|
|
14
|
+
score operating_margin_score weight 7 {
|
|
15
|
+
2 if operating_margin > 0.12
|
|
16
|
+
1 if operating_margin > 0.05
|
|
17
|
+
else 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
net_profit_margin = income.net_income / income.revenue
|
|
21
|
+
score net_margin_score weight 8 {
|
|
22
|
+
2 if net_profit_margin > 0.08
|
|
23
|
+
1 if net_profit_margin > 0.05
|
|
24
|
+
else 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interest_coverage = income.operating_income / income.interest_expense
|
|
28
|
+
score interest_coverage_score weight 6 {
|
|
29
|
+
2 if interest_coverage > 5
|
|
30
|
+
1 if interest_coverage > 2.5
|
|
31
|
+
else 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
debt_to_equity = balance.total_debt / balance.total_equity
|
|
35
|
+
score debt_to_equity_score weight 5 {
|
|
36
|
+
2 if debt_to_equity < 0.5
|
|
37
|
+
1 if debt_to_equity < 1
|
|
38
|
+
else 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
current_ratio = balance.current_assets / balance.current_liabilities
|
|
42
|
+
score current_ratio_score weight 7 {
|
|
43
|
+
2 if current_ratio > 1.5
|
|
44
|
+
1 if current_ratio > 0.8
|
|
45
|
+
else 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fcf_per_share = cash.free_cash_flow / income.weighted_average_shares_diluted
|
|
49
|
+
score fcf_trend_score weight 7 {
|
|
50
|
+
2 if roll_mean(fcf_per_share, 3) > roll_mean(fcf_per_share, 5)
|
|
51
|
+
else 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export composite = weighted_score()
|
|
55
|
+
export revenue_growth = revenue_cagr
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
universe all_active = stocks where meta.is_active
|
|
2
|
+
|
|
3
|
+
model piotroski on all_active period annual {
|
|
4
|
+
desc "Piotroski F-Score (9 binary signals)"
|
|
5
|
+
|
|
6
|
+
roa = income.net_income / avg2(balance.total_assets)
|
|
7
|
+
cfo_assets = cash.cfo / avg2(balance.total_assets)
|
|
8
|
+
ltd_ratio = balance.long_term_debt / avg2(balance.total_assets)
|
|
9
|
+
cur_ratio = balance.current_assets / balance.current_liabilities
|
|
10
|
+
gm = income.gross_profit / income.revenue
|
|
11
|
+
turnover = income.revenue / avg2(balance.total_assets)
|
|
12
|
+
|
|
13
|
+
f_roa_pos = roa > 0
|
|
14
|
+
f_cfo_pos = cash.cfo > 0
|
|
15
|
+
f_droa = roa > lag(roa, 1)
|
|
16
|
+
f_accrual = cfo_assets > roa
|
|
17
|
+
f_leverage = ltd_ratio < lag(ltd_ratio, 1)
|
|
18
|
+
f_liquid = cur_ratio > lag(cur_ratio, 1)
|
|
19
|
+
f_noissue = (cash.stock_issued ?? 0) == 0
|
|
20
|
+
f_margin = gm > lag(gm, 1)
|
|
21
|
+
f_turnover = turnover > lag(turnover, 1)
|
|
22
|
+
|
|
23
|
+
export fscore = count(f_roa_pos, f_cfo_pos, f_droa, f_accrual, f_leverage,
|
|
24
|
+
f_liquid, f_noissue, f_margin, f_turnover)
|
|
25
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "trail-lang"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A declarative expression language for financial indicators, scoring, and screening over security panels"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Usman Shahid", email = "u.manshahid@gmail.com" }]
|
|
10
|
+
keywords = ["finance", "quant", "factor", "screening", "indicators", "expression-language", "polars"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"lark>=1.2",
|
|
23
|
+
"polars>=1.20",
|
|
24
|
+
"click>=8.1",
|
|
25
|
+
"pyyaml>=6",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/trail-language"
|
|
30
|
+
Repository = "https://github.com/trail-language/trail-py"
|
|
31
|
+
Specification = "https://github.com/trail-language/spec"
|
|
32
|
+
Issues = "https://github.com/trail-language/trail-py/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
trail = "trail.cli:main"
|
|
36
|
+
|
|
37
|
+
# Built-in data sources. Third-party providers register additional drivers under the
|
|
38
|
+
# same group, so `pip install trail-<name>` makes `driver: <name>` usable in trail.yaml.
|
|
39
|
+
[project.entry-points."trail.sources"]
|
|
40
|
+
fixture = "trail.sources:FixtureSource"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = ["pytest>=8", "ruff>=0.8", "pandas>=2.2", "pyarrow>=17"]
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["hatchling"]
|
|
47
|
+
build-backend = "hatchling.build"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["trail"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from trail import ast
|
|
7
|
+
from trail.compiler import compile_model
|
|
8
|
+
from trail.fixtures import load_panel
|
|
9
|
+
from trail.pipeline import prepare
|
|
10
|
+
from trail.validate import validate
|
|
11
|
+
|
|
12
|
+
EXAMPLE = Path(__file__).parent.parent / "examples" / "performance.trail"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run():
|
|
16
|
+
program = prepare(EXAMPLE.read_text())
|
|
17
|
+
assert not [i for i in validate(program) if i.severity == "error"]
|
|
18
|
+
universes = {d.name: d for d in program.decls if isinstance(d, ast.UniverseDecl)}
|
|
19
|
+
model = next(d for d in program.decls if isinstance(d, ast.ModelDecl))
|
|
20
|
+
return compile_model(model, universes).run(load_panel())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_composite_bounds_and_null_policy():
|
|
24
|
+
result = _run()
|
|
25
|
+
vals = [v for v in result["composite"].to_list() if v is not None]
|
|
26
|
+
assert vals and all(0.0 <= v <= 1.0 for v in vals)
|
|
27
|
+
# FFF has null interest_expense -> that score skipped, composite still non-null
|
|
28
|
+
fff_2024 = result.filter((pl.col("security") == "FFF") & (pl.col("period") == 2024))
|
|
29
|
+
assert fff_2024["composite"][0] is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_revenue_cagr_known_value():
|
|
33
|
+
# AAA grows 10%/yr exactly -> 4y CAGR = 0.10
|
|
34
|
+
result = _run()
|
|
35
|
+
aaa_2024 = result.filter((pl.col("security") == "AAA") & (pl.col("period") == 2024))
|
|
36
|
+
assert aaa_2024["revenue_growth"][0] == pytest.approx(0.10)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_growth_ordering_matches_fixture_design():
|
|
40
|
+
# EEE shrinks (-3%/yr) so its growth is negative; AAA (10%) positive.
|
|
41
|
+
result = _run()
|
|
42
|
+
last = result.filter(pl.col("period") == 2024)
|
|
43
|
+
by_sec = {r["security"]: r["revenue_growth"] for r in last.iter_rows(named=True)}
|
|
44
|
+
assert by_sec["EEE"] < 0 < by_sec["AAA"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Golden test: Trail-compiled Piotroski vs an independent numpy/pandas re-implementation.
|
|
2
|
+
|
|
3
|
+
The oracle mirrors Trail's null semantics exactly: a flag is undefined (NaN) where any
|
|
4
|
+
of its inputs is missing, and the F-score is undefined if any flag is undefined
|
|
5
|
+
(Trail's `count` propagates null). This validates the whole pipeline AND the null model.
|
|
6
|
+
"""
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from trail import ast
|
|
12
|
+
from trail.compiler import compile_model
|
|
13
|
+
from trail.fixtures import load_panel
|
|
14
|
+
from trail.pipeline import prepare
|
|
15
|
+
|
|
16
|
+
EXAMPLE = Path(__file__).parent.parent / "examples" / "piotroski.trail"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _oracle(pdf):
|
|
20
|
+
out = {}
|
|
21
|
+
for sec, g in pdf.groupby("security"):
|
|
22
|
+
g = g.sort_values("period").reset_index(drop=True)
|
|
23
|
+
assets = g["balance.total_assets"]
|
|
24
|
+
avg_assets = (assets + assets.shift(1)) / 2
|
|
25
|
+
roa = g["income.net_income"] / avg_assets
|
|
26
|
+
cfo_a = g["cash.cfo"] / avg_assets
|
|
27
|
+
ltd = g["balance.long_term_debt"] / avg_assets
|
|
28
|
+
cur = g["balance.current_assets"] / g["balance.current_liabilities"]
|
|
29
|
+
gm = g["income.gross_profit"] / g["income.revenue"]
|
|
30
|
+
to = g["income.revenue"] / avg_assets
|
|
31
|
+
|
|
32
|
+
def flag(cond, *ins):
|
|
33
|
+
f = cond.astype(float).to_numpy().copy()
|
|
34
|
+
for s in ins:
|
|
35
|
+
f[s.isna().to_numpy()] = np.nan
|
|
36
|
+
return f
|
|
37
|
+
|
|
38
|
+
flags = [
|
|
39
|
+
flag(roa > 0, roa),
|
|
40
|
+
flag(g["cash.cfo"] > 0, g["cash.cfo"]),
|
|
41
|
+
flag(roa > roa.shift(1), roa, roa.shift(1)),
|
|
42
|
+
flag(cfo_a > roa, cfo_a, roa),
|
|
43
|
+
flag(ltd < ltd.shift(1), ltd, ltd.shift(1)),
|
|
44
|
+
flag(cur > cur.shift(1), cur, cur.shift(1)),
|
|
45
|
+
flag(g["cash.stock_issued"].fillna(0) == 0), # coalesced -> never null
|
|
46
|
+
flag(gm > gm.shift(1), gm, gm.shift(1)),
|
|
47
|
+
flag(to > to.shift(1), to, to.shift(1)),
|
|
48
|
+
]
|
|
49
|
+
total = np.column_stack(flags).sum(axis=1) # NaN if any flag NaN
|
|
50
|
+
for period, val in zip(g["period"], total):
|
|
51
|
+
out[(sec, int(period))] = None if np.isnan(val) else int(val)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_fscore_matches_pandas_oracle():
|
|
56
|
+
program = prepare(EXAMPLE.read_text())
|
|
57
|
+
universes = {d.name: d for d in program.decls if isinstance(d, ast.UniverseDecl)}
|
|
58
|
+
model = next(d for d in program.decls if isinstance(d, ast.ModelDecl))
|
|
59
|
+
result = compile_model(model, universes).run(load_panel())
|
|
60
|
+
|
|
61
|
+
expected = _oracle(load_panel().to_pandas())
|
|
62
|
+
checked = 0
|
|
63
|
+
for row in result.iter_rows(named=True):
|
|
64
|
+
key = (row["security"], row["period"])
|
|
65
|
+
assert row["fscore"] == expected[key], (key, row["fscore"], expected[key])
|
|
66
|
+
checked += 1
|
|
67
|
+
assert checked == 48
|
|
68
|
+
# Sanity: at least some cells are fully computable and land in 0..9.
|
|
69
|
+
non_null = [v for v in expected.values() if v is not None]
|
|
70
|
+
assert non_null and all(0 <= v <= 9 for v in non_null)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from click.testing import CliRunner
|
|
2
|
+
|
|
3
|
+
from trail import ast
|
|
4
|
+
from trail.catalog import (
|
|
5
|
+
CatalogResult,
|
|
6
|
+
catalog,
|
|
7
|
+
describe,
|
|
8
|
+
evaluate_meta,
|
|
9
|
+
fields,
|
|
10
|
+
functions,
|
|
11
|
+
namespaces,
|
|
12
|
+
sources,
|
|
13
|
+
)
|
|
14
|
+
from trail.cli import main
|
|
15
|
+
from trail.config import DEFAULT_CONFIG
|
|
16
|
+
from trail.parser import parse_expr, parse_program, parse_repl_line
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- discovery core ---
|
|
20
|
+
|
|
21
|
+
def test_namespaces_and_fields():
|
|
22
|
+
assert set(namespaces()) == {"income", "balance", "cash", "price", "meta"}
|
|
23
|
+
inc = fields("income")
|
|
24
|
+
assert isinstance(inc, CatalogResult)
|
|
25
|
+
assert "income.revenue" in inc.frame["field"].to_list()
|
|
26
|
+
assert all(f.startswith("income.") for f in inc.frame["field"].to_list())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_functions_catalog_has_axis_and_arity():
|
|
30
|
+
fr = functions().frame
|
|
31
|
+
row = fr.filter(fr["function"] == "roll_mean").row(0, named=True)
|
|
32
|
+
assert row["axis"] == "time-series" and row["args"] == "2"
|
|
33
|
+
assert "zscore" in fr["function"].to_list() # cross-sectional present too
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_sources_from_config():
|
|
37
|
+
fr = sources(DEFAULT_CONFIG).frame
|
|
38
|
+
assert "fixture" in fr["source"].to_list()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_describe_resolves_field_namespace_function_source():
|
|
42
|
+
assert describe(("income", "revenue")).frame.row(0, named=True) == {
|
|
43
|
+
"property": "column", "value": "income.revenue"
|
|
44
|
+
} or "flow" in describe(("income", "revenue")).frame["value"].to_list()
|
|
45
|
+
assert describe(("income",)).title.startswith("Fields in 'income'")
|
|
46
|
+
assert describe(("roll_mean",)).title == "Function roll_mean"
|
|
47
|
+
assert describe(("fixture",)).title == "Source fixture"
|
|
48
|
+
assert describe(("functions",)).frame.height == functions().frame.height
|
|
49
|
+
assert "Unknown" in describe(("nope",)).title
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_catalog_summary():
|
|
53
|
+
c = catalog(DEFAULT_CONFIG)
|
|
54
|
+
assert "namespace" in c.frame.columns and c.frame.height == len(namespaces())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- REPL dialect parsing (superset of the file grammar) ---
|
|
58
|
+
|
|
59
|
+
def test_meta_command_parsing():
|
|
60
|
+
assert parse_repl_line("?") == ast.MetaCatalog()
|
|
61
|
+
assert parse_repl_line("?income") == ast.MetaDescribe(("income",))
|
|
62
|
+
assert parse_repl_line("?income.revenue") == ast.MetaDescribe(("income", "revenue"))
|
|
63
|
+
assert parse_repl_line("?functions") == ast.MetaDescribe(("functions",))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_repl_line_is_a_superset():
|
|
67
|
+
# a bare expression and a declaration both still parse in the repl dialect
|
|
68
|
+
assert parse_repl_line("income.revenue / 2") == parse_expr("income.revenue / 2")
|
|
69
|
+
decl = parse_repl_line("model m { a = income.revenue }")
|
|
70
|
+
assert isinstance(decl, ast.ModelDecl)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_meta_commands_are_rejected_in_a_model_file():
|
|
74
|
+
# the file grammar must NOT accept meta-commands
|
|
75
|
+
import pytest
|
|
76
|
+
from lark.exceptions import UnexpectedInput
|
|
77
|
+
with pytest.raises(UnexpectedInput):
|
|
78
|
+
parse_program("?income")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_evaluate_meta_routes_to_core():
|
|
82
|
+
assert evaluate_meta(parse_repl_line("?")).frame.columns == ["namespace", "fields"]
|
|
83
|
+
assert evaluate_meta(parse_repl_line("?income")).title.startswith("Fields in 'income'")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- CLI ---
|
|
87
|
+
|
|
88
|
+
def test_cli_catalog():
|
|
89
|
+
r = CliRunner().invoke(main, ["catalog"])
|
|
90
|
+
assert r.exit_code == 0 and "namespace" in r.output
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_cli_catalog_describe_field():
|
|
94
|
+
r = CliRunner().invoke(main, ["catalog", "income.revenue"])
|
|
95
|
+
assert r.exit_code == 0 and "flow" in r.output
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_functions_catalog_includes_derived_macros():
|
|
99
|
+
fr = functions().frame
|
|
100
|
+
assert "layer" in fr.columns
|
|
101
|
+
layers = dict(zip(fr["function"].to_list(), fr["layer"].to_list()))
|
|
102
|
+
assert layers["roll_mean"] == "primitive"
|
|
103
|
+
assert layers["cagr"] == "derived" # migrated builtin, now a stdlib macro
|
|
104
|
+
assert layers["signed_log"] == "derived"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_describe_finds_migrated_macro():
|
|
108
|
+
d = describe(("cagr",))
|
|
109
|
+
assert d.title == "Function cagr" and "derived" in d.frame["value"].to_list()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_internal_helpers_hidden():
|
|
113
|
+
# _grow_start etc. are internal to timeseries.trail and must not surface in discovery
|
|
114
|
+
fr = functions().frame
|
|
115
|
+
assert not any(n.startswith("_") for n in fr["function"].to_list())
|