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.
Files changed (65) hide show
  1. trail_lang-0.1.0/.github/workflows/ci.yml +28 -0
  2. trail_lang-0.1.0/.github/workflows/release.yml +39 -0
  3. trail_lang-0.1.0/.gitignore +8 -0
  4. trail_lang-0.1.0/LICENSE +21 -0
  5. trail_lang-0.1.0/PKG-INFO +101 -0
  6. trail_lang-0.1.0/README.md +74 -0
  7. trail_lang-0.1.0/RELEASING.md +29 -0
  8. trail_lang-0.1.0/examples/performance.trail +56 -0
  9. trail_lang-0.1.0/examples/piotroski.trail +25 -0
  10. trail_lang-0.1.0/pyproject.toml +56 -0
  11. trail_lang-0.1.0/tests/__init__.py +0 -0
  12. trail_lang-0.1.0/tests/test_acceptance_performance.py +44 -0
  13. trail_lang-0.1.0/tests/test_acceptance_piotroski.py +70 -0
  14. trail_lang-0.1.0/tests/test_catalog.py +115 -0
  15. trail_lang-0.1.0/tests/test_cli.py +51 -0
  16. trail_lang-0.1.0/tests/test_compiler.py +84 -0
  17. trail_lang-0.1.0/tests/test_config.py +84 -0
  18. trail_lang-0.1.0/tests/test_conform.py +37 -0
  19. trail_lang-0.1.0/tests/test_deps.py +21 -0
  20. trail_lang-0.1.0/tests/test_factor.py +47 -0
  21. trail_lang-0.1.0/tests/test_functions.py +89 -0
  22. trail_lang-0.1.0/tests/test_ops_factor.py +34 -0
  23. trail_lang-0.1.0/tests/test_ops_time.py +36 -0
  24. trail_lang-0.1.0/tests/test_ops_xs.py +38 -0
  25. trail_lang-0.1.0/tests/test_parser_decls.py +69 -0
  26. trail_lang-0.1.0/tests/test_parser_expr.py +47 -0
  27. trail_lang-0.1.0/tests/test_pipeline.py +29 -0
  28. trail_lang-0.1.0/tests/test_registry.py +40 -0
  29. trail_lang-0.1.0/tests/test_schema_fixture.py +30 -0
  30. trail_lang-0.1.0/tests/test_smoke.py +5 -0
  31. trail_lang-0.1.0/tests/test_source.py +84 -0
  32. trail_lang-0.1.0/tests/test_stdlib.py +111 -0
  33. trail_lang-0.1.0/tests/test_timeseries.py +53 -0
  34. trail_lang-0.1.0/tests/test_validate.py +67 -0
  35. trail_lang-0.1.0/trail/__init__.py +1 -0
  36. trail_lang-0.1.0/trail/ast.py +173 -0
  37. trail_lang-0.1.0/trail/catalog.py +245 -0
  38. trail_lang-0.1.0/trail/cli.py +102 -0
  39. trail_lang-0.1.0/trail/compiler.py +140 -0
  40. trail_lang-0.1.0/trail/config.py +64 -0
  41. trail_lang-0.1.0/trail/deps.py +80 -0
  42. trail_lang-0.1.0/trail/fixtures.py +55 -0
  43. trail_lang-0.1.0/trail/grammar.lark +92 -0
  44. trail_lang-0.1.0/trail/library.py +20 -0
  45. trail_lang-0.1.0/trail/macro.py +102 -0
  46. trail_lang-0.1.0/trail/ops.py +133 -0
  47. trail_lang-0.1.0/trail/parser.py +211 -0
  48. trail_lang-0.1.0/trail/pipeline.py +24 -0
  49. trail_lang-0.1.0/trail/py.typed +0 -0
  50. trail_lang-0.1.0/trail/registry.py +50 -0
  51. trail_lang-0.1.0/trail/schema.py +54 -0
  52. trail_lang-0.1.0/trail/source.py +118 -0
  53. trail_lang-0.1.0/trail/sources.py +135 -0
  54. trail_lang-0.1.0/trail/stdlib/__init__.py +0 -0
  55. trail_lang-0.1.0/trail/stdlib/calculus.trail +12 -0
  56. trail_lang-0.1.0/trail/stdlib/core.trail +13 -0
  57. trail_lang-0.1.0/trail/stdlib/factor.trail +13 -0
  58. trail_lang-0.1.0/trail/stdlib/geometry.trail +5 -0
  59. trail_lang-0.1.0/trail/stdlib/math.trail +41 -0
  60. trail_lang-0.1.0/trail/stdlib/stats.trail +17 -0
  61. trail_lang-0.1.0/trail/stdlib/timeseries.trail +20 -0
  62. trail_lang-0.1.0/trail/stdlib/transform.trail +10 -0
  63. trail_lang-0.1.0/trail/testing.py +91 -0
  64. trail_lang-0.1.0/trail/validate.py +140 -0
  65. 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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ .omc/
8
+ .claude/
@@ -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())