ahasignals-pit 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.
- ahasignals_pit-0.1.0/.gitignore +3 -0
- ahasignals_pit-0.1.0/CHANGELOG.md +9 -0
- ahasignals_pit-0.1.0/LICENSE +21 -0
- ahasignals_pit-0.1.0/PKG-INFO +124 -0
- ahasignals_pit-0.1.0/README.md +101 -0
- ahasignals_pit-0.1.0/examples/quarterly-cash.json +78 -0
- ahasignals_pit-0.1.0/examples/select.json +33 -0
- ahasignals_pit-0.1.0/pyproject.toml +40 -0
- ahasignals_pit-0.1.0/src/ahasignals_pit/__init__.py +5 -0
- ahasignals_pit-0.1.0/src/ahasignals_pit/__main__.py +46 -0
- ahasignals_pit-0.1.0/src/ahasignals_pit/core.py +209 -0
- ahasignals_pit-0.1.0/tests/test_core.py +155 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Select the latest eligible fact with an exact period, unit and dimension match.
|
|
6
|
+
- Check declared disclosure and observation clocks, retaining source references.
|
|
7
|
+
- Reconstruct quarterly cash flows from exact quarter or matching fiscal YTD durations.
|
|
8
|
+
- Add an offline JSON CLI, explicit refusal reasons and synthetic examples.
|
|
9
|
+
- Port the financial-query-checks v1 rules to Python; reject malformed Python inputs and ambiguous JSON keys.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AhaSignals
|
|
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,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ahasignals-pit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Point-in-time financial fact selection and quarterly cash-flow checks
|
|
5
|
+
Project-URL: Homepage, https://ahasignals.com/research/point-in-time-financial-data/
|
|
6
|
+
Project-URL: Documentation, https://ahasignals.com/examples/ahasignals-pit/v0.1.0/README.md
|
|
7
|
+
Project-URL: Source, https://ahasignals.com/examples/ahasignals-pit/v0.1.0/ahasignals_pit-0.1.0.tar.gz
|
|
8
|
+
Project-URL: Related benchmark dataset, https://huggingface.co/datasets/AhaSignals/financial-ai-pit-integrity
|
|
9
|
+
Project-URL: Related paper, https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7415198
|
|
10
|
+
Author: AhaSignals
|
|
11
|
+
Maintainer-email: AhaSignals <research@ahasignals.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: backtesting,financial-data,point-in-time,reproducibility
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# AhaSignals PIT
|
|
25
|
+
|
|
26
|
+
Select financial facts that match a declared historical cutoff, then inspect the period, scope and source version behind each answer. Reconstruct quarterly operating cash flow less cash PP&E without treating cumulative cash flows as a quarter.
|
|
27
|
+
|
|
28
|
+
Python 3.9 or later. No runtime dependencies. No network requests, telemetry, account or API key required. Version 0.1.0 is an initial alpha release with a deliberately narrow contract.
|
|
29
|
+
|
|
30
|
+
## Install and run
|
|
31
|
+
|
|
32
|
+
After the release is available on PyPI:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install ahasignals-pit==0.1.0
|
|
36
|
+
ahasignals-pit input.json
|
|
37
|
+
# Equivalent:
|
|
38
|
+
python -m ahasignals_pit input.json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For a source checkout, run `python -m pip install .` from this directory, then:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
ahasignals-pit examples/select.json
|
|
45
|
+
ahasignals-pit examples/quarterly-cash.json
|
|
46
|
+
python -m unittest discover -s tests -v
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Both examples are fully synthetic. Their identifiers, amounts, dates, example.org source URL and zero hash do not represent a real issuer or authenticated document. The first selects 120; the second returns quarterly operating cash of 160, cash PP&E of 40 and cash after PP&E of 120 USD. These are arithmetic examples, not investment results.
|
|
50
|
+
|
|
51
|
+
## Python API
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import json
|
|
55
|
+
from ahasignals_pit import run, select_fact, quarterly_cash
|
|
56
|
+
|
|
57
|
+
with open('examples/select.json', encoding='utf-8') as stream:
|
|
58
|
+
payload = json.load(stream)
|
|
59
|
+
result = select_fact(payload['facts'], payload['query'])
|
|
60
|
+
assert result['status'] == 'answer'
|
|
61
|
+
print(result['value'], result['inputs'])
|
|
62
|
+
# run(payload) dispatches by task: select or quarterly-cash.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The public functions accept JSON-compatible dictionaries and lists. Input field names retain the camelCase contract of the related financial query checker. Output status is `answer`, `withheld` or `invalid`. Never replace a withheld value with zero. The CLI returns exit codes 0, 1 and 2 respectively; it also includes the package version and SHA-256 of the exact input bytes. It accepts a filename or `-` for stdin, at most 2 MB, and rejects duplicate JSON keys and non-finite constants. Its output includes supplied source references: review inputs before sharing outputs.
|
|
66
|
+
|
|
67
|
+
## Exact fact selection
|
|
68
|
+
|
|
69
|
+
A query requires:
|
|
70
|
+
|
|
71
|
+
| Field | Contract |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `cik` | String of exactly 10 ASCII digits |
|
|
74
|
+
| `taxonomy`, `concept`, `unit` | Nonempty strings; exact match, no alias or currency conversion |
|
|
75
|
+
| `start`, `end` | ISO dates; use empty `start` for an instant fact |
|
|
76
|
+
| `dimensions` | List of `{axis, member}` objects; unique axes; empty for consolidated scope |
|
|
77
|
+
| `cutoff` | Date and time with a known timezone |
|
|
78
|
+
| `mode` | `disclosure-reconstruction` or `observed-pipeline` |
|
|
79
|
+
| `accession` | Optional exact filing filter, `##########-##-######` |
|
|
80
|
+
|
|
81
|
+
Every fact needs the identity fields plus finite numeric `value`, `accession`, `acceptedAt`, `documentSha256` (64 lowercase hex characters), `contextId`, and an HTTPS `sourceUrl`. Values must have absolute magnitude at most 2^53−1. Booleans, NaN and infinity are rejected. Missing or null `observedAt` is permitted only for disclosure reconstruction. At most 2,000 facts and 32 dimensions per fact are accepted. All supplied facts must have valid required fields, including unmatched facts.
|
|
82
|
+
|
|
83
|
+
The checker matches the full identity and selects the latest eligible acceptance time. Conflicting values, accessions or hashes at that time produce `ambiguous-latest-fact`. Duplicate facts with the same value, accession and hash are resolved by context ID. No source file is fetched. A syntactically valid URL, hash or timestamp is not evidence that it is authentic.
|
|
84
|
+
|
|
85
|
+
Timestamps support 1–9 fractional digits and known offsets through ±14:00. Missing zones, `-00:00`, leap seconds, invalid calendar dates and years before 1900 are rejected. Comparisons retain nanosecond precision.
|
|
86
|
+
|
|
87
|
+
### Two time modes
|
|
88
|
+
|
|
89
|
+
- `disclosure-reconstruction`: use acceptance timestamps supplied by the caller. This reconstructs disclosure eligibility; it does not establish actual historical system access, market dissemination or tradability.
|
|
90
|
+
- `observed-pipeline`: also require observation timestamps at or before cutoff and at or after acceptance. Unknown observation time on any matching accepted candidate withholds the answer instead of silently choosing an older fact.
|
|
91
|
+
|
|
92
|
+
The caller must establish trustworthy timestamps independently. Collecting a document today cannot establish that the system observed it years ago.
|
|
93
|
+
|
|
94
|
+
## Quarterly cash
|
|
95
|
+
|
|
96
|
+
Call `quarterly_cash(facts, request)` with `cik`, `fiscalStart`, `quarterStart`, `end`, `cutoff`, and `mode`. Add `computedAt` in observed-pipeline mode. See `examples/quarterly-cash.json` for a complete request.
|
|
97
|
+
|
|
98
|
+
Only consolidated `us-gaap` whole-USD facts for these two concepts are supported:
|
|
99
|
+
|
|
100
|
+
- `NetCashProvidedByUsedInOperatingActivities`
|
|
101
|
+
- `PaymentsToAcquirePropertyPlantAndEquipment`, expressed as a positive cash outflow
|
|
102
|
+
|
|
103
|
+
An exact-quarter duration takes precedence. If none matches, current fiscal YTD minus the period ending immediately before the quarter is used. Missing prior periods withhold an answer. A matching but ineligible or ambiguous direct quarter also withholds rather than falling back. The caller supplies and verifies the issuer's fiscal calendar; the tool only checks date validity, ordering and a 60–120-day quarter length.
|
|
104
|
+
|
|
105
|
+
All selected facts and filing versions remain in `inputs`. Different filing vintages may be combined; review their accessions and presentation comparability before use. Fractional cash dollars, out-of-range results and negative derived cash PP&E are withheld. In observed-pipeline mode, computation must occur after all input observations and no later than cutoff.
|
|
106
|
+
|
|
107
|
+
`cashAfterPpe` excludes acquisitions, noncash additions, leases and other investment spending. It is not a universal free-cash-flow definition. No price data, factor returns, security universe or backtest performance is supplied.
|
|
108
|
+
|
|
109
|
+
## Research and citation
|
|
110
|
+
|
|
111
|
+
- [Point-in-time financial data research](https://ahasignals.com/research/point-in-time-financial-data/)
|
|
112
|
+
- [Worked financial query checks](https://ahasignals.com/research/financial-query-correctness/)
|
|
113
|
+
- [Related benchmark dataset](https://huggingface.co/datasets/AhaSignals/financial-ai-pit-integrity)
|
|
114
|
+
- [Related working paper](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7415198)
|
|
115
|
+
|
|
116
|
+
This package implements financial-query rules. It is not the frozen PIT benchmark scorer and does not reproduce the paper's model scores. Public calibration cases are not held-out evaluation. External datasets and papers retain their own versions and licenses; no third-party document bodies are bundled.
|
|
117
|
+
|
|
118
|
+
For software use, cite: **AhaSignals. AhaSignals PIT, version 0.1.0.** Include the source commit and input dataset version used in your analysis. Cite the relevant paper separately when its research is used.
|
|
119
|
+
|
|
120
|
+
## License and scope
|
|
121
|
+
|
|
122
|
+
Code, documentation, tests and synthetic examples in this distribution: MIT, copyright AhaSignals. No attribution link or network call is required to execute the package. Dataset rights are separate from software rights.
|
|
123
|
+
|
|
124
|
+
AhaSignals is an independent research publisher, unaffiliated with referenced regulators, issuers and platforms. Third-party names are factual source or compatibility references. Research and education only; no investment, trading, legal, accounting or tax advice. Passing these checks does not certify a backtest or establish predictive value.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# AhaSignals PIT
|
|
2
|
+
|
|
3
|
+
Select financial facts that match a declared historical cutoff, then inspect the period, scope and source version behind each answer. Reconstruct quarterly operating cash flow less cash PP&E without treating cumulative cash flows as a quarter.
|
|
4
|
+
|
|
5
|
+
Python 3.9 or later. No runtime dependencies. No network requests, telemetry, account or API key required. Version 0.1.0 is an initial alpha release with a deliberately narrow contract.
|
|
6
|
+
|
|
7
|
+
## Install and run
|
|
8
|
+
|
|
9
|
+
After the release is available on PyPI:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python -m pip install ahasignals-pit==0.1.0
|
|
13
|
+
ahasignals-pit input.json
|
|
14
|
+
# Equivalent:
|
|
15
|
+
python -m ahasignals_pit input.json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For a source checkout, run `python -m pip install .` from this directory, then:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ahasignals-pit examples/select.json
|
|
22
|
+
ahasignals-pit examples/quarterly-cash.json
|
|
23
|
+
python -m unittest discover -s tests -v
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Both examples are fully synthetic. Their identifiers, amounts, dates, example.org source URL and zero hash do not represent a real issuer or authenticated document. The first selects 120; the second returns quarterly operating cash of 160, cash PP&E of 40 and cash after PP&E of 120 USD. These are arithmetic examples, not investment results.
|
|
27
|
+
|
|
28
|
+
## Python API
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import json
|
|
32
|
+
from ahasignals_pit import run, select_fact, quarterly_cash
|
|
33
|
+
|
|
34
|
+
with open('examples/select.json', encoding='utf-8') as stream:
|
|
35
|
+
payload = json.load(stream)
|
|
36
|
+
result = select_fact(payload['facts'], payload['query'])
|
|
37
|
+
assert result['status'] == 'answer'
|
|
38
|
+
print(result['value'], result['inputs'])
|
|
39
|
+
# run(payload) dispatches by task: select or quarterly-cash.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The public functions accept JSON-compatible dictionaries and lists. Input field names retain the camelCase contract of the related financial query checker. Output status is `answer`, `withheld` or `invalid`. Never replace a withheld value with zero. The CLI returns exit codes 0, 1 and 2 respectively; it also includes the package version and SHA-256 of the exact input bytes. It accepts a filename or `-` for stdin, at most 2 MB, and rejects duplicate JSON keys and non-finite constants. Its output includes supplied source references: review inputs before sharing outputs.
|
|
43
|
+
|
|
44
|
+
## Exact fact selection
|
|
45
|
+
|
|
46
|
+
A query requires:
|
|
47
|
+
|
|
48
|
+
| Field | Contract |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `cik` | String of exactly 10 ASCII digits |
|
|
51
|
+
| `taxonomy`, `concept`, `unit` | Nonempty strings; exact match, no alias or currency conversion |
|
|
52
|
+
| `start`, `end` | ISO dates; use empty `start` for an instant fact |
|
|
53
|
+
| `dimensions` | List of `{axis, member}` objects; unique axes; empty for consolidated scope |
|
|
54
|
+
| `cutoff` | Date and time with a known timezone |
|
|
55
|
+
| `mode` | `disclosure-reconstruction` or `observed-pipeline` |
|
|
56
|
+
| `accession` | Optional exact filing filter, `##########-##-######` |
|
|
57
|
+
|
|
58
|
+
Every fact needs the identity fields plus finite numeric `value`, `accession`, `acceptedAt`, `documentSha256` (64 lowercase hex characters), `contextId`, and an HTTPS `sourceUrl`. Values must have absolute magnitude at most 2^53−1. Booleans, NaN and infinity are rejected. Missing or null `observedAt` is permitted only for disclosure reconstruction. At most 2,000 facts and 32 dimensions per fact are accepted. All supplied facts must have valid required fields, including unmatched facts.
|
|
59
|
+
|
|
60
|
+
The checker matches the full identity and selects the latest eligible acceptance time. Conflicting values, accessions or hashes at that time produce `ambiguous-latest-fact`. Duplicate facts with the same value, accession and hash are resolved by context ID. No source file is fetched. A syntactically valid URL, hash or timestamp is not evidence that it is authentic.
|
|
61
|
+
|
|
62
|
+
Timestamps support 1–9 fractional digits and known offsets through ±14:00. Missing zones, `-00:00`, leap seconds, invalid calendar dates and years before 1900 are rejected. Comparisons retain nanosecond precision.
|
|
63
|
+
|
|
64
|
+
### Two time modes
|
|
65
|
+
|
|
66
|
+
- `disclosure-reconstruction`: use acceptance timestamps supplied by the caller. This reconstructs disclosure eligibility; it does not establish actual historical system access, market dissemination or tradability.
|
|
67
|
+
- `observed-pipeline`: also require observation timestamps at or before cutoff and at or after acceptance. Unknown observation time on any matching accepted candidate withholds the answer instead of silently choosing an older fact.
|
|
68
|
+
|
|
69
|
+
The caller must establish trustworthy timestamps independently. Collecting a document today cannot establish that the system observed it years ago.
|
|
70
|
+
|
|
71
|
+
## Quarterly cash
|
|
72
|
+
|
|
73
|
+
Call `quarterly_cash(facts, request)` with `cik`, `fiscalStart`, `quarterStart`, `end`, `cutoff`, and `mode`. Add `computedAt` in observed-pipeline mode. See `examples/quarterly-cash.json` for a complete request.
|
|
74
|
+
|
|
75
|
+
Only consolidated `us-gaap` whole-USD facts for these two concepts are supported:
|
|
76
|
+
|
|
77
|
+
- `NetCashProvidedByUsedInOperatingActivities`
|
|
78
|
+
- `PaymentsToAcquirePropertyPlantAndEquipment`, expressed as a positive cash outflow
|
|
79
|
+
|
|
80
|
+
An exact-quarter duration takes precedence. If none matches, current fiscal YTD minus the period ending immediately before the quarter is used. Missing prior periods withhold an answer. A matching but ineligible or ambiguous direct quarter also withholds rather than falling back. The caller supplies and verifies the issuer's fiscal calendar; the tool only checks date validity, ordering and a 60–120-day quarter length.
|
|
81
|
+
|
|
82
|
+
All selected facts and filing versions remain in `inputs`. Different filing vintages may be combined; review their accessions and presentation comparability before use. Fractional cash dollars, out-of-range results and negative derived cash PP&E are withheld. In observed-pipeline mode, computation must occur after all input observations and no later than cutoff.
|
|
83
|
+
|
|
84
|
+
`cashAfterPpe` excludes acquisitions, noncash additions, leases and other investment spending. It is not a universal free-cash-flow definition. No price data, factor returns, security universe or backtest performance is supplied.
|
|
85
|
+
|
|
86
|
+
## Research and citation
|
|
87
|
+
|
|
88
|
+
- [Point-in-time financial data research](https://ahasignals.com/research/point-in-time-financial-data/)
|
|
89
|
+
- [Worked financial query checks](https://ahasignals.com/research/financial-query-correctness/)
|
|
90
|
+
- [Related benchmark dataset](https://huggingface.co/datasets/AhaSignals/financial-ai-pit-integrity)
|
|
91
|
+
- [Related working paper](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7415198)
|
|
92
|
+
|
|
93
|
+
This package implements financial-query rules. It is not the frozen PIT benchmark scorer and does not reproduce the paper's model scores. Public calibration cases are not held-out evaluation. External datasets and papers retain their own versions and licenses; no third-party document bodies are bundled.
|
|
94
|
+
|
|
95
|
+
For software use, cite: **AhaSignals. AhaSignals PIT, version 0.1.0.** Include the source commit and input dataset version used in your analysis. Cite the relevant paper separately when its research is used.
|
|
96
|
+
|
|
97
|
+
## License and scope
|
|
98
|
+
|
|
99
|
+
Code, documentation, tests and synthetic examples in this distribution: MIT, copyright AhaSignals. No attribution link or network call is required to execute the package. Dataset rights are separate from software rights.
|
|
100
|
+
|
|
101
|
+
AhaSignals is an independent research publisher, unaffiliated with referenced regulators, issuers and platforms. Third-party names are factual source or compatibility references. Research and education only; no investment, trading, legal, accounting or tax advice. Passing these checks does not certify a backtest or establish predictive value.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"exampleKind": "fully-synthetic-not-company-data",
|
|
3
|
+
"task": "quarterly-cash",
|
|
4
|
+
"facts": [
|
|
5
|
+
{
|
|
6
|
+
"cik": "0000000001",
|
|
7
|
+
"taxonomy": "us-gaap",
|
|
8
|
+
"concept": "NetCashProvidedByUsedInOperatingActivities",
|
|
9
|
+
"unit": "USD",
|
|
10
|
+
"start": "2025-01-01",
|
|
11
|
+
"end": "2025-03-31",
|
|
12
|
+
"dimensions": [],
|
|
13
|
+
"value": 120,
|
|
14
|
+
"accession": "0000000001-25-000001",
|
|
15
|
+
"acceptedAt": "2025-05-01T00:00:00Z",
|
|
16
|
+
"observedAt": "2025-05-02T00:00:00Z",
|
|
17
|
+
"sourceUrl": "https://example.org/synthetic-filing",
|
|
18
|
+
"documentSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
19
|
+
"contextId": "synthetic-q1"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"cik": "0000000001",
|
|
23
|
+
"taxonomy": "us-gaap",
|
|
24
|
+
"concept": "NetCashProvidedByUsedInOperatingActivities",
|
|
25
|
+
"unit": "USD",
|
|
26
|
+
"start": "2025-01-01",
|
|
27
|
+
"end": "2025-06-30",
|
|
28
|
+
"dimensions": [],
|
|
29
|
+
"value": 280,
|
|
30
|
+
"accession": "0000000001-25-000002",
|
|
31
|
+
"acceptedAt": "2025-08-01T00:00:00Z",
|
|
32
|
+
"observedAt": "2025-08-02T00:00:00Z",
|
|
33
|
+
"sourceUrl": "https://example.org/synthetic-filing",
|
|
34
|
+
"documentSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
35
|
+
"contextId": "synthetic-h1"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"cik": "0000000001",
|
|
39
|
+
"taxonomy": "us-gaap",
|
|
40
|
+
"concept": "PaymentsToAcquirePropertyPlantAndEquipment",
|
|
41
|
+
"unit": "USD",
|
|
42
|
+
"start": "2025-01-01",
|
|
43
|
+
"end": "2025-03-31",
|
|
44
|
+
"dimensions": [],
|
|
45
|
+
"value": 50,
|
|
46
|
+
"accession": "0000000001-25-000001",
|
|
47
|
+
"acceptedAt": "2025-05-01T00:00:00Z",
|
|
48
|
+
"observedAt": "2025-05-02T00:00:00Z",
|
|
49
|
+
"sourceUrl": "https://example.org/synthetic-filing",
|
|
50
|
+
"documentSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
51
|
+
"contextId": "synthetic-q1"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"cik": "0000000001",
|
|
55
|
+
"taxonomy": "us-gaap",
|
|
56
|
+
"concept": "PaymentsToAcquirePropertyPlantAndEquipment",
|
|
57
|
+
"unit": "USD",
|
|
58
|
+
"start": "2025-01-01",
|
|
59
|
+
"end": "2025-06-30",
|
|
60
|
+
"dimensions": [],
|
|
61
|
+
"value": 90,
|
|
62
|
+
"accession": "0000000001-25-000002",
|
|
63
|
+
"acceptedAt": "2025-08-01T00:00:00Z",
|
|
64
|
+
"observedAt": "2025-08-02T00:00:00Z",
|
|
65
|
+
"sourceUrl": "https://example.org/synthetic-filing",
|
|
66
|
+
"documentSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
67
|
+
"contextId": "synthetic-h1"
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"request": {
|
|
71
|
+
"cik": "0000000001",
|
|
72
|
+
"fiscalStart": "2025-01-01",
|
|
73
|
+
"quarterStart": "2025-04-01",
|
|
74
|
+
"end": "2025-06-30",
|
|
75
|
+
"cutoff": "2025-09-01T00:00:00Z",
|
|
76
|
+
"mode": "disclosure-reconstruction"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"exampleKind": "fully-synthetic-not-company-data",
|
|
3
|
+
"task": "select",
|
|
4
|
+
"facts": [
|
|
5
|
+
{
|
|
6
|
+
"cik": "0000000001",
|
|
7
|
+
"taxonomy": "us-gaap",
|
|
8
|
+
"concept": "NetCashProvidedByUsedInOperatingActivities",
|
|
9
|
+
"unit": "USD",
|
|
10
|
+
"start": "2025-01-01",
|
|
11
|
+
"end": "2025-03-31",
|
|
12
|
+
"dimensions": [],
|
|
13
|
+
"value": 120,
|
|
14
|
+
"accession": "0000000001-25-000001",
|
|
15
|
+
"acceptedAt": "2025-05-01T00:00:00Z",
|
|
16
|
+
"observedAt": "2025-05-02T00:00:00Z",
|
|
17
|
+
"sourceUrl": "https://example.org/synthetic-filing",
|
|
18
|
+
"documentSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
19
|
+
"contextId": "synthetic-q1"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"query": {
|
|
23
|
+
"cik": "0000000001",
|
|
24
|
+
"taxonomy": "us-gaap",
|
|
25
|
+
"concept": "NetCashProvidedByUsedInOperatingActivities",
|
|
26
|
+
"unit": "USD",
|
|
27
|
+
"start": "2025-01-01",
|
|
28
|
+
"end": "2025-03-31",
|
|
29
|
+
"dimensions": [],
|
|
30
|
+
"cutoff": "2025-09-01T00:00:00Z",
|
|
31
|
+
"mode": "disclosure-reconstruction"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ahasignals-pit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Point-in-time financial fact selection and quarterly cash-flow checks"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "AhaSignals"}]
|
|
14
|
+
maintainers = [{name = "AhaSignals", email = "research@ahasignals.com"}]
|
|
15
|
+
keywords = ["point-in-time", "financial-data", "reproducibility", "backtesting"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Topic :: Office/Business :: Financial",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://ahasignals.com/research/point-in-time-financial-data/"
|
|
28
|
+
Documentation = "https://ahasignals.com/examples/ahasignals-pit/v0.1.0/README.md"
|
|
29
|
+
Source = "https://ahasignals.com/examples/ahasignals-pit/v0.1.0/ahasignals_pit-0.1.0.tar.gz"
|
|
30
|
+
"Related benchmark dataset" = "https://huggingface.co/datasets/AhaSignals/financial-ai-pit-integrity"
|
|
31
|
+
"Related paper" = "https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7415198"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
ahasignals-pit = "ahasignals_pit.__main__:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/ahasignals_pit"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
only-include = ["src/ahasignals_pit", "tests/test_core.py", "examples/select.json", "examples/quarterly-cash.json", "README.md", "LICENSE", "pyproject.toml", "CHANGELOG.md"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""JSON CLI. Exit 0: answer; 1: withheld; 2: invalid input."""
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
from . import __version__, run
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _object(pairs):
|
|
11
|
+
result = {}
|
|
12
|
+
for key, value in pairs:
|
|
13
|
+
if key in result:
|
|
14
|
+
raise ValueError('duplicate JSON key')
|
|
15
|
+
result[key] = value
|
|
16
|
+
return result
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _constant(value):
|
|
20
|
+
raise ValueError('non-finite JSON constant')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
25
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
26
|
+
parser.add_argument('input', help='JSON file, or - for stdin; at most 2 MB')
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
try:
|
|
29
|
+
if args.input == '-':
|
|
30
|
+
raw = sys.stdin.buffer.read(2_000_001)
|
|
31
|
+
else:
|
|
32
|
+
with Path(args.input).open('rb') as stream:
|
|
33
|
+
raw = stream.read(2_000_001)
|
|
34
|
+
if len(raw) > 2_000_000:
|
|
35
|
+
raise ValueError('input size')
|
|
36
|
+
payload = json.loads(raw.decode('utf-8'), object_pairs_hook=_object, parse_constant=_constant)
|
|
37
|
+
result = run(payload)
|
|
38
|
+
print(json.dumps(dict(version=__version__, inputSha256=hashlib.sha256(raw).hexdigest(), **result), allow_nan=False, indent=2))
|
|
39
|
+
return {'answer': 0, 'withheld': 1, 'invalid': 2}[result['status']]
|
|
40
|
+
except (OSError, ValueError, TypeError, RecursionError, OverflowError):
|
|
41
|
+
print('Invalid input. Supply a UTF-8 JSON file no larger than 2 MB with unique keys and finite numbers.', file=sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == '__main__':
|
|
46
|
+
sys.exit(main())
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Deterministic checks of supplied facts, provenance fields and declared clocks."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from datetime import date, datetime, timedelta
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
MAX_VALUE = 2**53 - 1
|
|
8
|
+
MODES = ('disclosure-reconstruction', 'observed-pipeline')
|
|
9
|
+
CLOCK = re.compile(r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})', re.ASCII)
|
|
10
|
+
EVIDENCE = ('cik', 'taxonomy', 'concept', 'unit', 'start', 'end', 'dimensions',
|
|
11
|
+
'value', 'accession', 'acceptedAt', 'observedAt', 'sourceUrl',
|
|
12
|
+
'documentSha256', 'contextId')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _matches(pattern, value):
|
|
16
|
+
return isinstance(value, str) and re.fullmatch(pattern, value, re.ASCII) is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _instant(value):
|
|
20
|
+
"""Return integer nanoseconds, rejecting ambiguous zones and invalid dates."""
|
|
21
|
+
if not isinstance(value, str):
|
|
22
|
+
return None
|
|
23
|
+
m = CLOCK.fullmatch(value)
|
|
24
|
+
if not m:
|
|
25
|
+
return None
|
|
26
|
+
year, month, day, hour, minute, second = map(int, m.groups()[:6])
|
|
27
|
+
fraction, zone = m.groups()[6:]
|
|
28
|
+
if year < 1900 or zone == '-00:00':
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
clock = datetime(year, month, day, hour, minute, second)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None
|
|
34
|
+
offset = 0
|
|
35
|
+
if zone != 'Z':
|
|
36
|
+
hours, minutes = int(zone[1:3]), int(zone[4:6])
|
|
37
|
+
if hours > 14 or minutes > 59 or (hours == 14 and minutes):
|
|
38
|
+
return None
|
|
39
|
+
offset = (hours * 60 + minutes) * 60 * (1 if zone[0] == '+' else -1)
|
|
40
|
+
delta = clock - datetime(1970, 1, 1)
|
|
41
|
+
return (delta.days * 86400 + delta.seconds - offset) * 10**9 + int((fraction or '').ljust(9, '0'))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _date(value):
|
|
45
|
+
return _matches(r'\d{4}-\d{2}-\d{2}', value) and _instant(value + 'T00:00:00Z') is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _text(value):
|
|
49
|
+
return isinstance(value, str) and bool(value.strip())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dimensions(value):
|
|
53
|
+
if not isinstance(value, list) or len(value) > 32:
|
|
54
|
+
return None
|
|
55
|
+
pairs = []
|
|
56
|
+
axes = set()
|
|
57
|
+
for item in value:
|
|
58
|
+
if (not isinstance(item, dict) or set(item) != {'axis', 'member'}
|
|
59
|
+
or not _text(item['axis']) or not _text(item['member']) or item['axis'] in axes):
|
|
60
|
+
return None
|
|
61
|
+
axes.add(item['axis'])
|
|
62
|
+
pairs.append((item['axis'], item['member']))
|
|
63
|
+
return tuple(sorted(pairs))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _identity(value):
|
|
67
|
+
return (isinstance(value, dict) and _matches(r'\d{10}', value.get('cik'))
|
|
68
|
+
and all(_text(value.get(k)) for k in ('taxonomy', 'concept', 'unit'))
|
|
69
|
+
and _date(value.get('end'))
|
|
70
|
+
and (value.get('start') == '' or (_date(value.get('start')) and value['start'] <= value['end']))
|
|
71
|
+
and _dimensions(value.get('dimensions')) is not None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _key(value):
|
|
75
|
+
return tuple(value[k] for k in ('cik', 'taxonomy', 'concept', 'unit', 'start', 'end')) + (_dimensions(value['dimensions']),)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _number(value):
|
|
79
|
+
return type(value) in (int, float) and abs(value) <= MAX_VALUE and math.isfinite(value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _whole(value):
|
|
83
|
+
return _number(value) and int(value) == value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _valid_fact(fact):
|
|
87
|
+
return (_identity(fact) and _number(fact.get('value'))
|
|
88
|
+
and _matches(r'\d{10}-\d{2}-\d{6}', fact.get('accession'))
|
|
89
|
+
and _instant(fact.get('acceptedAt')) is not None
|
|
90
|
+
and _matches(r'[a-f0-9]{64}', fact.get('documentSha256'))
|
|
91
|
+
and _text(fact.get('contextId')) and isinstance(fact.get('sourceUrl'), str)
|
|
92
|
+
and fact['sourceUrl'].startswith('https://'))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _stop(reason, status='withheld'):
|
|
96
|
+
return dict(status=status, reason=reason, value=None, inputs=[])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def select_fact(facts, query):
|
|
100
|
+
"""Select the latest eligible exact context; return answer/withheld/invalid.
|
|
101
|
+
|
|
102
|
+
Inputs use the camelCase JSON contract documented in README.md. This does
|
|
103
|
+
not fetch documents or authenticate their hashes or declared timestamps.
|
|
104
|
+
"""
|
|
105
|
+
if (not isinstance(facts, list) or len(facts) > 2000 or not _identity(query)
|
|
106
|
+
or _instant(query.get('cutoff')) is None or query.get('mode') not in MODES):
|
|
107
|
+
return _stop('invalid-query-or-facts', 'invalid')
|
|
108
|
+
if 'accession' in query and not _matches(r'\d{10}-\d{2}-\d{6}', query['accession']):
|
|
109
|
+
return _stop('invalid-accession-filter', 'invalid')
|
|
110
|
+
if not all(_valid_fact(f) for f in facts):
|
|
111
|
+
return _stop('invalid-fact-or-missing-provenance', 'invalid')
|
|
112
|
+
matched = [f for f in facts if _key(f) == _key(query) and ('accession' not in query or f['accession'] == query['accession'])]
|
|
113
|
+
if not matched:
|
|
114
|
+
return _stop('no-matching-context')
|
|
115
|
+
cutoff = _instant(query['cutoff'])
|
|
116
|
+
accepted = [f for f in matched if _instant(f['acceptedAt']) <= cutoff]
|
|
117
|
+
if not accepted:
|
|
118
|
+
return _stop('source-after-cutoff')
|
|
119
|
+
eligible = accepted
|
|
120
|
+
if query['mode'] == 'observed-pipeline':
|
|
121
|
+
if any(_instant(f.get('observedAt')) is None for f in accepted):
|
|
122
|
+
return _stop('unknown-observation-time')
|
|
123
|
+
if any(_instant(f['observedAt']) < _instant(f['acceptedAt']) for f in accepted):
|
|
124
|
+
return _stop('observation-before-acceptance')
|
|
125
|
+
eligible = [f for f in accepted if _instant(f['observedAt']) <= cutoff]
|
|
126
|
+
if not eligible:
|
|
127
|
+
return _stop('observation-after-cutoff')
|
|
128
|
+
latest = max(_instant(f['acceptedAt']) for f in eligible)
|
|
129
|
+
selected = [f for f in eligible if _instant(f['acceptedAt']) == latest]
|
|
130
|
+
if len({(f['value'], f['accession'], f['documentSha256']) for f in selected}) != 1:
|
|
131
|
+
return _stop('ambiguous-latest-fact')
|
|
132
|
+
fact = min(selected, key=lambda f: f['contextId'])
|
|
133
|
+
return dict(status='answer', reason='latest-eligible-exact-context', value=fact['value'],
|
|
134
|
+
inputs=[{k: deepcopy(fact.get(k)) for k in EVIDENCE}], mode=query['mode'], cutoff=query['cutoff'])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def quarterly_cash(facts, request):
|
|
138
|
+
"""Reconstruct consolidated whole-USD quarterly OCF less cash PP&E.
|
|
139
|
+
|
|
140
|
+
Exact-quarter duration wins; otherwise subtract matched fiscal YTD periods.
|
|
141
|
+
This is not a universal free-cash-flow definition or a trading signal.
|
|
142
|
+
"""
|
|
143
|
+
if not isinstance(request, dict):
|
|
144
|
+
return _stop('invalid-quarter-request', 'invalid')
|
|
145
|
+
fiscal, start, end = (request.get(k) for k in ('fiscalStart', 'quarterStart', 'end'))
|
|
146
|
+
mode, cutoff, computed = (request.get(k) for k in ('mode', 'cutoff', 'computedAt'))
|
|
147
|
+
if (not all(_date(d) for d in (fiscal, start, end)) or fiscal > start or start > end
|
|
148
|
+
or mode not in MODES or _instant(cutoff) is None):
|
|
149
|
+
return _stop('invalid-quarter-request', 'invalid')
|
|
150
|
+
if not 60 <= (date.fromisoformat(end) - date.fromisoformat(start)).days + 1 <= 120:
|
|
151
|
+
return _stop('not-a-quarter-length')
|
|
152
|
+
previous = (date.fromisoformat(start) - timedelta(days=1)).isoformat()
|
|
153
|
+
inputs, amounts, methods = [], {}, []
|
|
154
|
+
for name, concept in (('operatingCash', 'NetCashProvidedByUsedInOperatingActivities'),
|
|
155
|
+
('cashPpe', 'PaymentsToAcquirePropertyPlantAndEquipment')):
|
|
156
|
+
query = dict(cik=request.get('cik'), taxonomy='us-gaap', concept=concept, unit='USD',
|
|
157
|
+
start=fiscal, end=end, dimensions=[], cutoff=cutoff, mode=mode)
|
|
158
|
+
direct = select_fact(facts, dict(query, start=start))
|
|
159
|
+
if direct['status'] == 'answer':
|
|
160
|
+
if not _whole(direct['value']):
|
|
161
|
+
return _stop('cash-values-must-be-exact-whole-dollars')
|
|
162
|
+
amounts[name] = int(direct['value'])
|
|
163
|
+
inputs.extend(direct['inputs'])
|
|
164
|
+
methods.append('direct-quarter-duration')
|
|
165
|
+
continue
|
|
166
|
+
if direct['reason'] != 'no-matching-context':
|
|
167
|
+
return dict(direct, reason=name + ':' + direct['reason'])
|
|
168
|
+
current = select_fact(facts, query)
|
|
169
|
+
if current['status'] != 'answer':
|
|
170
|
+
return dict(current, reason=name + ':' + current['reason'])
|
|
171
|
+
prior = dict(value=0, inputs=[])
|
|
172
|
+
if fiscal != start:
|
|
173
|
+
prior = select_fact(facts, dict(query, end=previous))
|
|
174
|
+
if prior['status'] != 'answer':
|
|
175
|
+
return dict(prior, reason=name + ':' + prior['reason'])
|
|
176
|
+
if not all(_whole(v) for v in (current['value'], prior['value'])):
|
|
177
|
+
return _stop('cash-values-must-be-exact-whole-dollars')
|
|
178
|
+
amounts[name] = int(current['value']) - int(prior['value'])
|
|
179
|
+
inputs.extend(current['inputs'] + prior['inputs'])
|
|
180
|
+
methods.append('difference-of-matched-ytd-periods')
|
|
181
|
+
if amounts['cashPpe'] < 0:
|
|
182
|
+
return _stop('negative-cash-ppe-needs-source-review')
|
|
183
|
+
cash = amounts['operatingCash'] - amounts['cashPpe']
|
|
184
|
+
if not all(_whole(v) for v in (*amounts.values(), cash)):
|
|
185
|
+
return _stop('derived-value-out-of-range')
|
|
186
|
+
if mode == 'observed-pipeline':
|
|
187
|
+
ct = _instant(computed)
|
|
188
|
+
if ct is None:
|
|
189
|
+
return _stop('unknown-computation-time')
|
|
190
|
+
if ct > _instant(cutoff):
|
|
191
|
+
return _stop('computation-after-cutoff')
|
|
192
|
+
if any(_instant(f['observedAt']) > ct for f in inputs):
|
|
193
|
+
return _stop('computation-before-input')
|
|
194
|
+
return dict(status='answer', reason='+'.join(dict.fromkeys(methods)), **amounts,
|
|
195
|
+
cashAfterPpe=cash, unit='USD', start=start, end=end, cutoff=cutoff,
|
|
196
|
+
mode=mode, computedAt=computed, inputs=inputs,
|
|
197
|
+
selectionPolicy='Latest eligible filing per exact fiscal period; cross-filing inputs are identified, not assumed to have a common presentation vintage.',
|
|
198
|
+
boundary='Declared consolidated US-GAAP cash flows. Cash PP&E excludes noncash additions and other investment spending. No historical observation or computation time is inferred; no predictive claim.')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def run(payload):
|
|
202
|
+
"""Dispatch a JSON-compatible select or quarterly-cash request."""
|
|
203
|
+
if not isinstance(payload, dict):
|
|
204
|
+
return dict(status='invalid', reason='invalid-input')
|
|
205
|
+
if payload.get('task') == 'select':
|
|
206
|
+
return select_fact(payload.get('facts'), payload.get('query'))
|
|
207
|
+
if payload.get('task') == 'quarterly-cash':
|
|
208
|
+
return quarterly_cash(payload.get('facts'), payload.get('request'))
|
|
209
|
+
return dict(status='invalid', reason='unknown-task')
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Synthetic regression tests; no issuer data or network access."""
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from ahasignals_pit import run, select_fact, quarterly_cash
|
|
9
|
+
from ahasignals_pit.core import _instant, MAX_VALUE
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Checks(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.select = json.loads((ROOT / 'examples/select.json').read_text())
|
|
17
|
+
self.cash = json.loads((ROOT / 'examples/quarterly-cash.json').read_text())
|
|
18
|
+
self.facts, self.query = self.select['facts'], self.select['query']
|
|
19
|
+
|
|
20
|
+
def test_examples(self):
|
|
21
|
+
self.assertEqual(run(self.select)['value'], 120)
|
|
22
|
+
result = run(self.cash)
|
|
23
|
+
self.assertEqual((result['operatingCash'], result['cashPpe'], result['cashAfterPpe']), (160, 40, 120))
|
|
24
|
+
self.assertEqual(len(result['inputs']), 4)
|
|
25
|
+
|
|
26
|
+
def test_future_revision_excluded(self):
|
|
27
|
+
self.facts.append(dict(self.facts[0], value=999, acceptedAt='2026-01-01T00:00:00Z'))
|
|
28
|
+
self.assertEqual(run(self.select)['value'], 120)
|
|
29
|
+
self.query['cutoff'] = '2026-02-01T00:00:00Z'
|
|
30
|
+
self.assertEqual(run(self.select)['value'], 999)
|
|
31
|
+
|
|
32
|
+
def test_exact_identity(self):
|
|
33
|
+
for field, value in [('unit', 'EUR'), ('start', '2025-02-01'), ('concept', 'other'), ('cik', '0000000002'), ('dimensions', [{'axis': 'segment', 'member': 'cloud'}])]:
|
|
34
|
+
with self.subTest(field=field):
|
|
35
|
+
self.assertEqual(select_fact(self.facts, dict(self.query, **{field: value}))['reason'], 'no-matching-context')
|
|
36
|
+
|
|
37
|
+
def test_dimension_order_and_duplicate_axes(self):
|
|
38
|
+
dims = [{'axis': 'b', 'member': '2'}, {'axis': 'a', 'member': '1'}]
|
|
39
|
+
self.facts[0]['dimensions'] = dims
|
|
40
|
+
self.query['dimensions'] = dims[::-1]
|
|
41
|
+
self.assertEqual(run(self.select)['status'], 'answer')
|
|
42
|
+
self.query['dimensions'] = dims + [dims[0]]
|
|
43
|
+
self.assertEqual(run(self.select)['status'], 'invalid')
|
|
44
|
+
|
|
45
|
+
def test_conflicting_latest_and_duplicate(self):
|
|
46
|
+
self.facts.append(dict(self.facts[0], contextId='a'))
|
|
47
|
+
self.assertEqual(run(self.select)['inputs'][0]['contextId'], 'a')
|
|
48
|
+
self.facts[1]['value'] = 121
|
|
49
|
+
self.assertEqual(run(self.select)['reason'], 'ambiguous-latest-fact')
|
|
50
|
+
|
|
51
|
+
def test_observation_modes(self):
|
|
52
|
+
self.facts[0]['observedAt'] = None
|
|
53
|
+
self.assertEqual(run(self.select)['status'], 'answer')
|
|
54
|
+
self.query['mode'] = 'observed-pipeline'
|
|
55
|
+
self.assertEqual(run(self.select)['reason'], 'unknown-observation-time')
|
|
56
|
+
self.facts[0]['observedAt'] = '2025-04-01T00:00:00Z'
|
|
57
|
+
self.assertEqual(run(self.select)['reason'], 'observation-before-acceptance')
|
|
58
|
+
self.facts[0]['observedAt'] = '2025-10-01T00:00:00Z'
|
|
59
|
+
self.assertEqual(run(self.select)['reason'], 'observation-after-cutoff')
|
|
60
|
+
|
|
61
|
+
def test_unknown_newer_observation_withholds(self):
|
|
62
|
+
self.query['mode'] = 'observed-pipeline'
|
|
63
|
+
self.facts.append(dict(self.facts[0], acceptedAt='2025-08-01T00:00:00Z', observedAt=None))
|
|
64
|
+
self.assertEqual(run(self.select)['reason'], 'unknown-observation-time')
|
|
65
|
+
|
|
66
|
+
def test_strict_clocks(self):
|
|
67
|
+
for value in ['2025-02-30T00:00:00Z', '2025-01-01', '2025-01-01T00:00:00-00:00', '2025-01-01T00:00:00+14:01', '2025-01-01T00:00:60Z']:
|
|
68
|
+
self.assertIsNone(_instant(value))
|
|
69
|
+
self.assertEqual(_instant('2025-01-01T01:00:00+01:00'), _instant('2025-01-01T00:00:00Z'))
|
|
70
|
+
self.assertEqual(_instant('2025-01-01T00:00:00.000000001Z') - _instant('2025-01-01T00:00:00Z'), 1)
|
|
71
|
+
|
|
72
|
+
def test_nanosecond_cutoff(self):
|
|
73
|
+
self.facts[0]['acceptedAt'] = '2025-05-01T00:00:00.000000001Z'
|
|
74
|
+
self.query['cutoff'] = '2025-05-01T00:00:00Z'
|
|
75
|
+
self.assertEqual(run(self.select)['reason'], 'source-after-cutoff')
|
|
76
|
+
|
|
77
|
+
def test_invalid_values(self):
|
|
78
|
+
for value in [True, float('nan'), float('inf'), MAX_VALUE + 1, 10**500, '120', None]:
|
|
79
|
+
with self.subTest(value=type(value).__name__):
|
|
80
|
+
self.assertEqual(select_fact([dict(self.facts[0], value=value)], self.query)['status'], 'invalid')
|
|
81
|
+
|
|
82
|
+
def test_invalid_shapes(self):
|
|
83
|
+
for bad in [None, [], True, 7, 'x']:
|
|
84
|
+
self.assertEqual(select_fact([bad], self.query)['status'], 'invalid')
|
|
85
|
+
self.assertEqual(select_fact(self.facts, bad)['status'], 'invalid')
|
|
86
|
+
self.assertEqual(quarterly_cash(self.facts, bad)['status'], 'invalid')
|
|
87
|
+
self.assertEqual(select_fact(self.facts * 2001, self.query)['status'], 'invalid')
|
|
88
|
+
|
|
89
|
+
def test_missing_provenance(self):
|
|
90
|
+
for field in ['accession', 'acceptedAt', 'documentSha256', 'contextId', 'sourceUrl']:
|
|
91
|
+
fact = dict(self.facts[0]); del fact[field]
|
|
92
|
+
self.assertEqual(select_fact([fact], self.query)['status'], 'invalid')
|
|
93
|
+
|
|
94
|
+
def test_accession_filter(self):
|
|
95
|
+
q = dict(self.query, accession='0000000001-25-000002')
|
|
96
|
+
self.assertEqual(select_fact(self.facts, q)['reason'], 'no-matching-context')
|
|
97
|
+
q['accession'] = None
|
|
98
|
+
self.assertEqual(select_fact(self.facts, q)['status'], 'invalid')
|
|
99
|
+
|
|
100
|
+
def test_missing_prior_cash(self):
|
|
101
|
+
self.cash['facts'] = self.cash['facts'][1:]
|
|
102
|
+
self.assertEqual(run(self.cash)['reason'], 'operatingCash:no-matching-context')
|
|
103
|
+
|
|
104
|
+
def test_direct_quarter_wins(self):
|
|
105
|
+
direct = dict(self.cash['facts'][1], start='2025-04-01', value=180)
|
|
106
|
+
self.cash['facts'].append(direct)
|
|
107
|
+
self.assertEqual(run(self.cash)['operatingCash'], 180)
|
|
108
|
+
direct['acceptedAt'] = '2026-01-01T00:00:00Z'
|
|
109
|
+
self.assertEqual(run(self.cash)['reason'], 'operatingCash:source-after-cutoff')
|
|
110
|
+
|
|
111
|
+
def test_negative_ppe(self):
|
|
112
|
+
self.cash['facts'][3]['value'] = 40
|
|
113
|
+
self.assertEqual(run(self.cash)['reason'], 'negative-cash-ppe-needs-source-review')
|
|
114
|
+
|
|
115
|
+
def test_cash_fractions_and_range(self):
|
|
116
|
+
self.cash['facts'][1]['value'] = 280.5
|
|
117
|
+
self.assertEqual(run(self.cash)['reason'], 'cash-values-must-be-exact-whole-dollars')
|
|
118
|
+
self.cash['facts'][1]['value'] = MAX_VALUE
|
|
119
|
+
self.cash['facts'][0]['value'] = -120
|
|
120
|
+
self.assertEqual(run(self.cash)['reason'], 'derived-value-out-of-range')
|
|
121
|
+
|
|
122
|
+
def test_quarter_length(self):
|
|
123
|
+
self.cash['request']['quarterStart'] = '2025-06-01'
|
|
124
|
+
self.assertEqual(run(self.cash)['reason'], 'not-a-quarter-length')
|
|
125
|
+
|
|
126
|
+
def test_computation_clock(self):
|
|
127
|
+
self.cash['request']['mode'] = 'observed-pipeline'
|
|
128
|
+
for computed, reason in [(None, 'unknown-computation-time'), ('2025-10-01T00:00:00Z', 'computation-after-cutoff'), ('2025-08-01T00:00:00Z', 'computation-before-input')]:
|
|
129
|
+
self.cash['request']['computedAt'] = computed
|
|
130
|
+
self.assertEqual(run(self.cash)['reason'], reason)
|
|
131
|
+
self.cash['request']['computedAt'] = '2025-08-03T00:00:00Z'
|
|
132
|
+
self.assertEqual(run(self.cash)['status'], 'answer')
|
|
133
|
+
|
|
134
|
+
def test_no_mutation(self):
|
|
135
|
+
before = copy.deepcopy(self.select)
|
|
136
|
+
result = run(self.select)
|
|
137
|
+
result['inputs'][0]['dimensions'].append({'axis': 'x', 'member': 'y'})
|
|
138
|
+
self.assertEqual(before, self.select)
|
|
139
|
+
|
|
140
|
+
def test_cli(self):
|
|
141
|
+
def cli(raw):
|
|
142
|
+
return subprocess.run([sys.executable, '-m', 'ahasignals_pit', '-'], input=raw, capture_output=True)
|
|
143
|
+
good = cli(json.dumps(self.select).encode())
|
|
144
|
+
self.assertEqual(good.returncode, 0)
|
|
145
|
+
self.assertEqual(len(json.loads(good.stdout)['inputSha256']), 64)
|
|
146
|
+
self.query['cutoff'] = '2024-01-01T00:00:00Z'
|
|
147
|
+
self.assertEqual(cli(json.dumps(self.select).encode()).returncode, 1)
|
|
148
|
+
for raw in [b'{"task":"select","task":"x"}', b'NaN', b'{' , b' ' * 2_000_001]:
|
|
149
|
+
result = cli(raw)
|
|
150
|
+
self.assertEqual(result.returncode, 2)
|
|
151
|
+
self.assertNotIn(b'Traceback', result.stderr)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == '__main__':
|
|
155
|
+
unittest.main()
|