timefire 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 (34) hide show
  1. timefire-0.1.0/.gitignore +7 -0
  2. timefire-0.1.0/LICENSE +21 -0
  3. timefire-0.1.0/PKG-INFO +63 -0
  4. timefire-0.1.0/README.md +33 -0
  5. timefire-0.1.0/pyproject.toml +53 -0
  6. timefire-0.1.0/src/timefire/__init__.py +9 -0
  7. timefire-0.1.0/src/timefire/_api.py +43 -0
  8. timefire-0.1.0/src/timefire/_dates.py +18 -0
  9. timefire-0.1.0/src/timefire/_files.py +80 -0
  10. timefire-0.1.0/src/timefire/_frames.py +64 -0
  11. timefire-0.1.0/src/timefire/_http.py +70 -0
  12. timefire-0.1.0/src/timefire/keys/README.md +16 -0
  13. timefire-0.1.0/src/timefire/keys/timefire-ledger-ed25519-v1.txt +4 -0
  14. timefire-0.1.0/src/timefire/keys/timefire-ledger-ed25519-v1.txt.ots +0 -0
  15. timefire-0.1.0/src/timefire/verify.py +118 -0
  16. timefire-0.1.0/tests/conftest.py +38 -0
  17. timefire-0.1.0/tests/fixtures/README.md +10 -0
  18. timefire-0.1.0/tests/fixtures/base64-vectors.json +67 -0
  19. timefire-0.1.0/tests/fixtures/batch-2026-08-27.json +1 -0
  20. timefire-0.1.0/tests/fixtures/batches.csv +7 -0
  21. timefire-0.1.0/tests/fixtures/books-2026-08-27.parquet +0 -0
  22. timefire-0.1.0/tests/fixtures/books.csv +3000 -0
  23. timefire-0.1.0/tests/fixtures/calibration.json +1 -0
  24. timefire-0.1.0/tests/fixtures/manifest.json +36 -0
  25. timefire-0.1.0/tests/fixtures/simulation-nav.csv +31 -0
  26. timefire-0.1.0/tests/fixtures/stock-AAPL.json +1 -0
  27. timefire-0.1.0/tests/fixtures/verify-2026-04-24.json +1 -0
  28. timefire-0.1.0/tests/fixtures/verify-2026-08-27.json +1 -0
  29. timefire-0.1.0/tests/test_api.py +31 -0
  30. timefire-0.1.0/tests/test_files.py +118 -0
  31. timefire-0.1.0/tests/test_keys.py +14 -0
  32. timefire-0.1.0/tests/test_package.py +49 -0
  33. timefire-0.1.0/tests/test_retry.py +101 -0
  34. timefire-0.1.0/tests/test_verify.py +140 -0
@@ -0,0 +1,7 @@
1
+ # Build and local-venv output for packages/timefire-py. Also the ignore file
2
+ # hatchling ships inside the sdist: it takes the nearest one, so without this
3
+ # file the public tarball would carry the repository root's.
4
+ dist/
5
+ build/
6
+ .venv/
7
+ *.egg-info/
timefire-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cromwell Works LLC
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,63 @@
1
+ Metadata-Version: 2.5
2
+ Name: timefire
3
+ Version: 0.1.0
4
+ Summary: Timefire's resolved forecast record as DataFrames, with local signature verification.
5
+ Project-URL: Homepage, https://timefire.ai/developers
6
+ Project-URL: Documentation, https://timefire.ai/developers/python
7
+ Project-URL: Repository, https://github.com/timefire-ai/timefire
8
+ Author: Cromwell Works LLC
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ed25519,forecasts,ledger,pandas,stocks,timefire
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business :: Financial :: Investment
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: cryptography>=42
25
+ Requires-Dist: pandas>=2.0
26
+ Requires-Dist: pyarrow>=15
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=8; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # timefire
32
+
33
+ Timefire's resolved forecast record as DataFrames. Read-only.
34
+
35
+ ```python
36
+ import timefire as tf
37
+
38
+ day = tf.book("2026-08-27") # one graded book
39
+ print(tf.verify("2026-08-27").status) # verified
40
+ ```
41
+
42
+ ## What you get
43
+
44
+ | Call | Returns | Source |
45
+ | --- | --- | --- |
46
+ | `tf.batches()` | one row per graded book | files |
47
+ | `tf.books()` | every prediction from every graded book | files |
48
+ | `tf.book("2026-08-27")` | one book; `.attrs` has its hash, content address, signature and public verification key | files + API |
49
+ | `tf.nav()` | the paper portfolio, day by day | files |
50
+ | `tf.stats()` | counts and hit rates over graded books, the API's resolved-only fields | files |
51
+ | `tf.manifest()` | the export index with sizes and SHA-256 fingerprints | files |
52
+ | `tf.ticker("AAPL")` | one ticker's graded observations | API |
53
+ | `tf.calibration()` | return deciles, rank buckets, side hit rates | API |
54
+ | `tf.verify("2026-08-27")` | local hash and Ed25519 check against the pinned key | API |
55
+ | `tf.verify_record(record, "2026-08-27")` | the same check on a verify record you fetched yourself; pass the date you asked for | none |
56
+
57
+ The data calls return graded books only; a book is graded ten trading days after it was sealed. `verify()` also answers for a book that is still sealed, with the `sealed` status below, and never sees its contents. What each column means: https://timefire.ai/developers/columns. How verification works: https://timefire.ai/developers/verification.
58
+
59
+ `verify()` returns one of `verified`, `sealed`, `failed`, `legacy-unverifiable`. Books before 2026-04-28 predate exact-signed-bytes custody, so a mismatch there is reported as `legacy-unverifiable`, and only when you asked for a pre-cutoff date. Since timefire#995 those books serve their originally signed bytes and verify; treat `legacy-unverifiable` as a failure.
60
+
61
+ ## Changelog
62
+
63
+ - 0.1.0: first release.
@@ -0,0 +1,33 @@
1
+ # timefire
2
+
3
+ Timefire's resolved forecast record as DataFrames. Read-only.
4
+
5
+ ```python
6
+ import timefire as tf
7
+
8
+ day = tf.book("2026-08-27") # one graded book
9
+ print(tf.verify("2026-08-27").status) # verified
10
+ ```
11
+
12
+ ## What you get
13
+
14
+ | Call | Returns | Source |
15
+ | --- | --- | --- |
16
+ | `tf.batches()` | one row per graded book | files |
17
+ | `tf.books()` | every prediction from every graded book | files |
18
+ | `tf.book("2026-08-27")` | one book; `.attrs` has its hash, content address, signature and public verification key | files + API |
19
+ | `tf.nav()` | the paper portfolio, day by day | files |
20
+ | `tf.stats()` | counts and hit rates over graded books, the API's resolved-only fields | files |
21
+ | `tf.manifest()` | the export index with sizes and SHA-256 fingerprints | files |
22
+ | `tf.ticker("AAPL")` | one ticker's graded observations | API |
23
+ | `tf.calibration()` | return deciles, rank buckets, side hit rates | API |
24
+ | `tf.verify("2026-08-27")` | local hash and Ed25519 check against the pinned key | API |
25
+ | `tf.verify_record(record, "2026-08-27")` | the same check on a verify record you fetched yourself; pass the date you asked for | none |
26
+
27
+ The data calls return graded books only; a book is graded ten trading days after it was sealed. `verify()` also answers for a book that is still sealed, with the `sealed` status below, and never sees its contents. What each column means: https://timefire.ai/developers/columns. How verification works: https://timefire.ai/developers/verification.
28
+
29
+ `verify()` returns one of `verified`, `sealed`, `failed`, `legacy-unverifiable`. Books before 2026-04-28 predate exact-signed-bytes custody, so a mismatch there is reported as `legacy-unverifiable`, and only when you asked for a pre-cutoff date. Since timefire#995 those books serve their originally signed bytes and verify; treat `legacy-unverifiable` as a failure.
30
+
31
+ ## Changelog
32
+
33
+ - 0.1.0: first release.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "timefire"
7
+ dynamic = ["version"] # single source: src/timefire/__init__.py (__version__)
8
+ description = "Timefire's resolved forecast record as DataFrames, with local signature verification."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Cromwell Works LLC" }]
14
+ keywords = ["timefire", "forecasts", "stocks", "ledger", "ed25519", "pandas"]
15
+ dependencies = ["pandas>=2.0", "pyarrow>=15", "cryptography>=42"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Financial and Insurance Industry",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Office/Business :: Financial :: Investment",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://timefire.ai/developers"
32
+ Documentation = "https://timefire.ai/developers/python"
33
+ # The repository is private today; the changelog ships in this README, which
34
+ # PyPI shows as the project description.
35
+ Repository = "https://github.com/timefire-ai/timefire"
36
+
37
+ [project.optional-dependencies]
38
+ test = ["pytest>=8"]
39
+
40
+ [tool.hatch.version]
41
+ path = "src/timefire/__init__.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/timefire"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ # Only the package, its tests and the metadata files, whatever else sits in
48
+ # this directory. (hatchling also ships the nearest VCS ignore file; the
49
+ # package's own .gitignore exists so that is not the repository root's.)
50
+ only-include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
@@ -0,0 +1,9 @@
1
+ """Timefire's resolved forecast record, as DataFrames. Read-only."""
2
+ __version__ = "0.1.0"
3
+
4
+ FILES_BASE = "https://timefire.ai/data/export/"
5
+ API_BASE = "https://api.timefire.ai/api/v1/"
6
+
7
+ from ._api import calibration, ticker # noqa: E402,F401
8
+ from ._files import batches, book, books, manifest, nav, stats # noqa: E402,F401
9
+ from .verify import VerificationResult, verify, verify_record # noqa: E402,F401
@@ -0,0 +1,43 @@
1
+ """Single objects from the public REST API. Resolved books only, by construction."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+
7
+ import pandas as pd
8
+
9
+ from . import API_BASE, _http
10
+ from ._frames import frame_from_records
11
+
12
+ TICKER = re.compile(r"^[A-Z][A-Z0-9.\-]{0,9}$")
13
+ TICKER_COLUMNS = ["trading_date", "rank", "predicted_return", "is_long_pick", "is_short_pick",
14
+ "realized_return", "hit", "graded_by_policy"]
15
+
16
+
17
+ def get_json(path: str):
18
+ return json.loads(_http.get_bytes(API_BASE + path))
19
+
20
+
21
+ def batch_meta(trading_date: str) -> dict:
22
+ """The seal fields of one book from GET /batch/{date}; predictions are ignored here."""
23
+ return get_json(f"batch/{trading_date}")
24
+
25
+
26
+ def ticker(symbol: str, limit: int = 500) -> pd.DataFrame:
27
+ """One ticker's observations from graded books only, newest first."""
28
+ symbol = symbol.upper()
29
+ if not TICKER.match(symbol):
30
+ raise ValueError("ticker must be a stock symbol like AAPL")
31
+ if not 1 <= limit <= 500:
32
+ raise ValueError("limit must be between 1 and 500")
33
+ rows = get_json(f"stock/{symbol}/history?resolved=true&limit={limit}")
34
+ return frame_from_records(rows, TICKER_COLUMNS)
35
+
36
+
37
+ def calibration() -> dict:
38
+ """Return deciles, rank buckets and side hit rates as DataFrames, plus `meta`."""
39
+ raw = get_json("calibration")
40
+ tables = ("return_deciles", "rank_buckets", "side_hit_rates")
41
+ result = {name: pd.DataFrame(raw[name]) for name in tables}
42
+ result["meta"] = {key: value for key, value in raw.items() if key not in tables}
43
+ return result
@@ -0,0 +1,18 @@
1
+ """One ISO-date check for every path. ASCII digits only, no trailing newline:
2
+ Python's `$` would accept "2026-04-24\\n", the TS client's regex does not, and
3
+ the legacy carve-out must agree with it."""
4
+ from __future__ import annotations
5
+
6
+ import re
7
+
8
+ ISO_DATE = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z", re.ASCII)
9
+
10
+
11
+ def is_iso_date(value: str) -> bool:
12
+ return isinstance(value, str) and ISO_DATE.fullmatch(value) is not None
13
+
14
+
15
+ def check_date(trading_date: str) -> str:
16
+ if not is_iso_date(trading_date):
17
+ raise ValueError("trading_date must be YYYY-MM-DD")
18
+ return trading_date
@@ -0,0 +1,80 @@
1
+ """The flat-file export: bulk data as DataFrames."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ import pandas as pd
7
+
8
+ from . import FILES_BASE, _api, _http
9
+ from ._dates import check_date
10
+ from ._frames import read_csv_bytes, read_parquet_bytes
11
+
12
+
13
+ def _csv(name: str) -> pd.DataFrame:
14
+ return read_csv_bytes(_http.get_bytes(FILES_BASE + name))
15
+
16
+
17
+ def manifest() -> dict:
18
+ """The export index: every file with its path, bytes, rows and sha256."""
19
+ return json.loads(_http.get_bytes(FILES_BASE + "manifest.json"))
20
+
21
+
22
+ def batches() -> pd.DataFrame:
23
+ """One row per fully graded book."""
24
+ return _csv("batches.csv")
25
+
26
+
27
+ def books() -> pd.DataFrame:
28
+ """Every prediction from every fully graded book."""
29
+ return _csv("books.csv")
30
+
31
+
32
+ def book(trading_date: str) -> pd.DataFrame:
33
+ """One graded book, same columns and dtypes as books(). `.attrs` carries
34
+ the seal: payload_hash, ipfs_cid, signature, signature_pubkey, and the era."""
35
+ trading_date = check_date(trading_date)
36
+ frame = read_parquet_bytes(_http.get_bytes(f"{FILES_BASE}books/{trading_date}.parquet"))
37
+ meta = _api.batch_meta(trading_date)
38
+ frame.attrs = {
39
+ "trading_date": trading_date,
40
+ "payload_hash": meta["payload_hash"],
41
+ "ipfs_cid": meta["ipfs_cid"],
42
+ "signature": meta["signature"],
43
+ "signature_pubkey": meta["signature_pubkey"],
44
+ "era": str(frame["era"].iloc[0]) if len(frame) else None,
45
+ }
46
+ return frame
47
+
48
+
49
+ def nav() -> pd.DataFrame:
50
+ """The paper portfolio, day by day, through the last graded date."""
51
+ return _csv("simulation-nav.csv")
52
+
53
+
54
+ def stats() -> dict:
55
+ """The API's resolved-only track record fields, computed from the export.
56
+ Same groups as the backend's _resolved_track_record_stats: realized rows
57
+ are those with a return, hit rates are over realized rows with a verdict."""
58
+ b, rows = batches(), books()
59
+ realized = rows[rows["realized_return"].notna()]
60
+ hits = realized[realized["hit"].notna()]
61
+ longs, shorts = hits[hits["side"] == "long"], hits[hits["side"] == "short"]
62
+
63
+ def rate(group: pd.DataFrame):
64
+ return float(group["hit"].astype("float64").mean()) if len(group) else None
65
+
66
+ def day(value) -> str | None:
67
+ return None if pd.isna(value) else value.strftime("%Y-%m-%d")
68
+
69
+ return {
70
+ "scope": "fully_resolved_books",
71
+ "date_from": day(b["trading_date"].min()),
72
+ "date_to": day(b["trading_date"].max()),
73
+ "n_resolved_batches": int(len(b)),
74
+ "n_predictions": int(len(rows)),
75
+ "n_realized": int(len(realized)),
76
+ "mean_realized_return": float(realized["realized_return"].mean()) if len(realized) else None,
77
+ "hit_rate": rate(hits),
78
+ "long_hit_rate": rate(longs),
79
+ "short_hit_rate": rate(shorts),
80
+ }
@@ -0,0 +1,64 @@
1
+ """One dtype vocabulary for every path in, whichever of CSV, Parquet or JSON
2
+ the rows came from: dates are datetimes, counts are nullable Int64, money
3
+ and returns are float64, `hit` is a nullable boolean, everything else is
4
+ text with an empty cell as missing. CSV is read as text first, so a ticker
5
+ spelled NA stays a ticker instead of becoming a missing value."""
6
+ from __future__ import annotations
7
+
8
+ import io
9
+
10
+ import pandas as pd
11
+
12
+ DATE_COLUMNS = frozenset(("trading_date", "open_date", "close_date", "cohort_open_date", "cohort_close_date"))
13
+ INT_COLUMNS = frozenset(("rank", "n_predictions", "n_realized", "n_open_positions", "n_opened_today",
14
+ "n_closed_today")) # trade `id` is a UUID: text
15
+ FLOAT_COLUMNS = frozenset(("predicted_return", "weight", "realized_return", "open_price", "close_price",
16
+ "portfolio_return_10d", "spy_return_10d", "alpha_10d", "cash", "positions_value",
17
+ "nav", "daily_return", "cumulative_return", "realized_pnl_today", "spy_nav",
18
+ "spy_cumulative_return", "weight_in_cohort", "allocated_value", "open_value",
19
+ "close_value", "unspent_cash", "pnl", "shares")) # shares are fractional
20
+ BOOL_COLUMNS = frozenset(("hit", "is_long_pick", "is_short_pick"))
21
+ _BOOL_WORDS = {"true": True, "false": False, True: True, False: False}
22
+
23
+
24
+ def _blank_to_na(series: pd.Series) -> pd.Series:
25
+ if series.dtype == object or str(series.dtype) == "str":
26
+ return series.mask(series == "", pd.NA)
27
+ return series
28
+
29
+
30
+ def _to_boolean(series: pd.Series) -> pd.Series:
31
+ if series.dtype == object or str(series.dtype) == "str":
32
+ series = series.map(lambda v: _BOOL_WORDS.get(v.lower() if isinstance(v, str) else v, pd.NA))
33
+ return series.astype("boolean")
34
+
35
+
36
+ def normalise(frame: pd.DataFrame) -> pd.DataFrame:
37
+ for column in frame.columns:
38
+ series = frame[column]
39
+ if column in DATE_COLUMNS:
40
+ # one resolution whatever the source: CSV text parses to [us] on
41
+ # pandas 3 and [ns] on 2, Parquet's date32 arrives as [s]
42
+ frame[column] = pd.to_datetime(_blank_to_na(series)).astype("datetime64[ns]")
43
+ elif column in INT_COLUMNS:
44
+ frame[column] = pd.to_numeric(_blank_to_na(series)).astype("Int64")
45
+ elif column in FLOAT_COLUMNS:
46
+ frame[column] = pd.to_numeric(_blank_to_na(series)).astype("float64")
47
+ elif column in BOOL_COLUMNS:
48
+ frame[column] = _to_boolean(series)
49
+ else:
50
+ frame[column] = _blank_to_na(series).astype("string")
51
+ return frame
52
+
53
+
54
+ def read_csv_bytes(data: bytes) -> pd.DataFrame:
55
+ """Every cell as text, nothing treated as missing until normalise() decides."""
56
+ return normalise(pd.read_csv(io.BytesIO(data), dtype=str, keep_default_na=False, na_filter=False))
57
+
58
+
59
+ def read_parquet_bytes(data: bytes) -> pd.DataFrame:
60
+ return normalise(pd.read_parquet(io.BytesIO(data)))
61
+
62
+
63
+ def frame_from_records(rows: list, columns: list[str]) -> pd.DataFrame:
64
+ return normalise(pd.DataFrame(rows, columns=columns))
@@ -0,0 +1,70 @@
1
+ """Every byte the client fetches comes through get_bytes(); tests replace it."""
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ import email.utils
6
+ import random
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ from . import __version__
12
+
13
+ USER_AGENT = f"timefire-python/{__version__} (+https://timefire.ai/developers/python)"
14
+ TIMEOUT_SECONDS = 60
15
+
16
+ # 429 handling (#817). The API shares one per-address budget across every
17
+ # route, so a burst of calls can hit it. Every call here is an idempotent GET,
18
+ # so a 429 is retried after the server's Retry-After, a bounded number of
19
+ # times and within a bounded total wait. Nothing else is retried.
20
+ MAX_RETRIES = 3
21
+ MAX_TOTAL_WAIT_SECONDS = 120.0
22
+ BACKOFF_BASE_SECONDS = 1.0
23
+
24
+
25
+ def _retry_after_seconds(value: str | None) -> float | None:
26
+ """Retry-After as seconds: delta-seconds or an HTTP date; None if unusable."""
27
+ if not value:
28
+ return None
29
+ value = value.strip()
30
+ if value.isdigit():
31
+ return float(value)
32
+ try:
33
+ when = email.utils.parsedate_to_datetime(value)
34
+ except (TypeError, ValueError):
35
+ return None
36
+ if when is None:
37
+ return None
38
+ if when.tzinfo is None: # asctime form has no zone; every HTTP-date is UTC (RFC 9110 §5.6.7)
39
+ when = when.replace(tzinfo=datetime.timezone.utc)
40
+ return max(0.0, when.timestamp() - time.time())
41
+
42
+
43
+ def _delay(retry_after: str | None, attempt: int) -> float:
44
+ """The server's wait plus up to a second of jitter, or jittered
45
+ exponential backoff when the header is missing or unreadable."""
46
+ server = _retry_after_seconds(retry_after)
47
+ if server is not None:
48
+ return server + random.uniform(0.0, 1.0)
49
+ backoff = BACKOFF_BASE_SECONDS * (2 ** attempt)
50
+ return backoff / 2 + random.uniform(0.0, backoff / 2)
51
+
52
+
53
+ def get_bytes(url: str) -> bytes:
54
+ waited = 0.0
55
+ attempt = 0
56
+ while True:
57
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
58
+ try:
59
+ with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
60
+ return response.read()
61
+ except urllib.error.HTTPError as err:
62
+ if err.code != 429 or attempt >= MAX_RETRIES:
63
+ raise
64
+ delay = _delay(err.headers.get("Retry-After") if err.headers else None, attempt)
65
+ if waited + delay > MAX_TOTAL_WAIT_SECONDS:
66
+ raise
67
+ err.close()
68
+ time.sleep(delay)
69
+ waited += delay
70
+ attempt += 1
@@ -0,0 +1,16 @@
1
+ # Timefire ledger signing key
2
+
3
+ `timefire-ledger-ed25519-v1.txt` is the canonical public statement for the
4
+ Ed25519 key pinned by this verifier package. Its `.ots` companion is an
5
+ OpenTimestamps receipt over the exact statement bytes.
6
+
7
+ Verify the receipt without contacting Timefire:
8
+
9
+ ```sh
10
+ ots verify timefire-ledger-ed25519-v1.txt.ots
11
+ ```
12
+
13
+ The receipt carries Bitcoin block attestations (blocks 964634, 964639 and
14
+ 964667; upgraded from the calendars on 2026-09-12), so it proves the statement
15
+ existed before those blocks. It does not replace the verifier's pinned-key
16
+ check or authorize a future key rotation.
@@ -0,0 +1,4 @@
1
+ timefire-ledger-signing-key-v1
2
+ algorithm: Ed25519
3
+ public-key-base64: 7ZqgyDLHcrwkUlfZGNdDYRzY7NXWLEMxsFcOVl0IE2I=
4
+ purpose: prediction-ledger
@@ -0,0 +1,118 @@
1
+ """Local Ed25519 verification of a published book. Verdict-for-verdict port
2
+ of packages/timefire-client/src/verify.ts; keep the two in step.
3
+
4
+ Scheme (mirrors the backend):
5
+ payload_hash = sha256_hex(utf8(canonical_payload))
6
+ signature = ed25519_sign(utf8(payload_hash)) # over the ASCII hex
7
+
8
+ Trust direction: nothing the server sends decides the verdict category. The
9
+ signature is checked against the hash WE compute from the served bytes, the
10
+ public key is pinned here, and the legacy carve-out is keyed off the date the
11
+ CALLER asked for, never the record's own date. A sealed record (no bytes) is
12
+ the one place the server-asserted hash is trusted, and it earns its own
13
+ provisional verdict, never a plain success."""
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ from dataclasses import dataclass
19
+ import hashlib
20
+ import re
21
+
22
+ from cryptography.exceptions import InvalidSignature
23
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
24
+
25
+ from . import _api
26
+ from ._dates import check_date, is_iso_date
27
+
28
+ TIMEFIRE_LEDGER_PUBKEYS_B64: tuple[str, ...] = ("7ZqgyDLHcrwkUlfZGNdDYRzY7NXWLEMxsFcOVl0IE2I=",)
29
+ LEGACY_BYTES_CUTOFF = "2026-04-28"
30
+ _B64_ALPHABET = re.compile(r"^[A-Za-z0-9+/]*$")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class VerificationResult:
35
+ status: str # "verified" | "sealed" | "failed" | "legacy-unverifiable"
36
+ payload_hash_local: str | None # None when sealed: nothing to hash, not a mismatch
37
+ payload_hash_server: str
38
+ hash_matches: bool | None # None when sealed
39
+ signature_valid: bool
40
+ pubkey_pinned: bool
41
+ date_matches: bool
42
+ sealed: bool
43
+ revealed_at: str | None = None
44
+
45
+
46
+ def _atob(text: str) -> bytes:
47
+ """The browser's atob(): WHATWG forgiving-base64. ASCII whitespace is
48
+ dropped, missing padding is tolerated, up to two trailing `=` are
49
+ allowed only when the length is a multiple of four, a length of 1 mod 4
50
+ or a character outside the alphabet raises. Same accept/reject set as
51
+ the TS client, so the two give the same verdict on the same bytes."""
52
+ data = re.sub(r"[\t\n\f\r ]", "", text)
53
+ if len(data) % 4 == 0 and data.endswith("=="):
54
+ data = data[:-2]
55
+ elif len(data) % 4 == 0 and data.endswith("="):
56
+ data = data[:-1]
57
+ if len(data) % 4 == 1 or not _B64_ALPHABET.match(data):
58
+ raise ValueError("not base64")
59
+ return base64.b64decode(data + "=" * (-len(data) % 4), validate=True)
60
+
61
+
62
+ def _signature_ok(signature_b64: str, message: str, pubkey_b64: str) -> bool:
63
+ try:
64
+ Ed25519PublicKey.from_public_bytes(_atob(pubkey_b64)).verify(_atob(signature_b64), message.encode("utf-8"))
65
+ return True
66
+ except (InvalidSignature, ValueError, binascii.Error):
67
+ return False
68
+
69
+
70
+ def verify_batch(
71
+ *,
72
+ expected_trading_date: str,
73
+ served_trading_date: str,
74
+ canonical_payload: str | None,
75
+ server_payload_hash: str,
76
+ signature_b64: str,
77
+ pubkey_b64: str,
78
+ revealed_at: str | None = None,
79
+ pinned_pubkeys_b64: tuple[str, ...] = TIMEFIRE_LEDGER_PUBKEYS_B64,
80
+ ) -> VerificationResult:
81
+ pubkey_pinned = pubkey_b64 in pinned_pubkeys_b64
82
+ date_matches = served_trading_date == expected_trading_date
83
+ sealed = canonical_payload is None
84
+ payload_hash_local = None if sealed else hashlib.sha256(canonical_payload.encode("utf-8")).hexdigest()
85
+ hash_matches = None if sealed else payload_hash_local == server_payload_hash.lower()
86
+ signed_hash = server_payload_hash.lower() if sealed else payload_hash_local
87
+ signature_valid = bool(pubkey_pinned and signed_hash and _signature_ok(signature_b64, signed_hash, pubkey_b64))
88
+
89
+ if sealed:
90
+ status = "sealed" if (signature_valid and pubkey_pinned and date_matches) else "failed"
91
+ return VerificationResult(status, None, server_payload_hash, None, signature_valid,
92
+ pubkey_pinned, date_matches, True, revealed_at)
93
+
94
+ ok = hash_matches is True and signature_valid and pubkey_pinned and date_matches
95
+ legacy_eligible = (date_matches and is_iso_date(expected_trading_date)
96
+ and expected_trading_date < LEGACY_BYTES_CUTOFF)
97
+ status = "verified" if ok else "legacy-unverifiable" if legacy_eligible else "failed"
98
+ return VerificationResult(status, payload_hash_local, server_payload_hash, hash_matches,
99
+ signature_valid, pubkey_pinned, date_matches, False, revealed_at)
100
+
101
+
102
+ def verify_record(record: dict, expected_trading_date: str, **options) -> VerificationResult:
103
+ """Adapter from the /verify/{date} response. Pass the date YOU asked for."""
104
+ return verify_batch(
105
+ expected_trading_date=expected_trading_date,
106
+ served_trading_date=record["trading_date"],
107
+ canonical_payload=record.get("canonical_payload"), # absent or null = sealed
108
+ server_payload_hash=record["payload_hash"],
109
+ signature_b64=record["signature"],
110
+ pubkey_b64=record["signature_pubkey"],
111
+ revealed_at=record.get("revealed_at"),
112
+ **options,
113
+ )
114
+
115
+
116
+ def verify(trading_date: str) -> VerificationResult:
117
+ """Fetch the verify record for a date and check it locally."""
118
+ return verify_record(_api.get_json(f"verify/{check_date(trading_date)}"), trading_date)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import timefire
8
+ from timefire import _http
9
+
10
+ FIXTURES = Path(__file__).parent / "fixtures"
11
+ ORIGINAL_GET_BYTES = _http.get_bytes # the real fetcher, kept for the User-Agent regression test
12
+ ROUTES = {
13
+ timefire.FILES_BASE + "manifest.json": "manifest.json",
14
+ timefire.FILES_BASE + "batches.csv": "batches.csv",
15
+ timefire.FILES_BASE + "books.csv": "books.csv",
16
+ timefire.FILES_BASE + "books/2026-08-27.parquet": "books-2026-08-27.parquet",
17
+ timefire.FILES_BASE + "simulation-nav.csv": "simulation-nav.csv",
18
+ timefire.API_BASE + "batch/2026-08-27": "batch-2026-08-27.json",
19
+ timefire.API_BASE + "verify/2026-08-27": "verify-2026-08-27.json",
20
+ timefire.API_BASE + "verify/2026-04-24": "verify-2026-04-24.json",
21
+ timefire.API_BASE + "stock/AAPL/history?resolved=true&limit=500": "stock-AAPL.json",
22
+ timefire.API_BASE + "calibration": "calibration.json",
23
+ }
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def offline(monkeypatch):
28
+ """Serve recorded bytes; any URL outside ROUTES is a test failure."""
29
+ calls: list[str] = []
30
+
31
+ def fake_get_bytes(url: str) -> bytes:
32
+ calls.append(url)
33
+ if url not in ROUTES:
34
+ raise AssertionError(f"unexpected network call: {url}")
35
+ return (FIXTURES / ROUTES[url]).read_bytes()
36
+
37
+ monkeypatch.setattr(_http, "get_bytes", fake_get_bytes)
38
+ return calls