timefire 0.1.0__py3-none-any.whl
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.
- timefire/__init__.py +9 -0
- timefire/_api.py +43 -0
- timefire/_dates.py +18 -0
- timefire/_files.py +80 -0
- timefire/_frames.py +64 -0
- timefire/_http.py +70 -0
- timefire/keys/README.md +16 -0
- timefire/keys/timefire-ledger-ed25519-v1.txt +4 -0
- timefire/keys/timefire-ledger-ed25519-v1.txt.ots +0 -0
- timefire/verify.py +118 -0
- timefire-0.1.0.dist-info/METADATA +63 -0
- timefire-0.1.0.dist-info/RECORD +14 -0
- timefire-0.1.0.dist-info/WHEEL +4 -0
- timefire-0.1.0.dist-info/licenses/LICENSE +21 -0
timefire/__init__.py
ADDED
|
@@ -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
|
timefire/_api.py
ADDED
|
@@ -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
|
timefire/_dates.py
ADDED
|
@@ -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
|
timefire/_files.py
ADDED
|
@@ -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
|
+
}
|
timefire/_frames.py
ADDED
|
@@ -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))
|
timefire/_http.py
ADDED
|
@@ -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
|
timefire/keys/README.md
ADDED
|
@@ -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.
|
|
Binary file
|
timefire/verify.py
ADDED
|
@@ -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,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,14 @@
|
|
|
1
|
+
timefire/__init__.py,sha256=YfaNlpy1NTfo1nkAciG8ViT_VGroClUq8UyexI6M7mg,406
|
|
2
|
+
timefire/_api.py,sha256=ArB__VqbU6vQVH9AMQhubn9n_VaiqTORuzMYD7TVUjo,1571
|
|
3
|
+
timefire/_dates.py,sha256=a1iwPGw0rKoaeiyYicXUn-AtZs6RlVQQEBVL5QzzC1s,587
|
|
4
|
+
timefire/_files.py,sha256=san4AGpD6SZkQzH7xHhFgT4bauyG1hP76WAhRDK4n-Y,2788
|
|
5
|
+
timefire/_frames.py,sha256=Ui5NPWim2xL8509mBsabsLvOeHmSXpTuC8SkMLKSLww,3102
|
|
6
|
+
timefire/_http.py,sha256=a228ZFLjZJMpvGimAi31Y_U7kAjYfM6Rgy2cbspxzvQ,2488
|
|
7
|
+
timefire/verify.py,sha256=cWrM5qAsbr69qmZrYPKGqnsVx8Jx806RPNJlB5yA1ZY,5254
|
|
8
|
+
timefire/keys/README.md,sha256=G8MftZ1Oz1FmvLKN1RkeG8uFUO137F8El8Dr8ZKBhFA,610
|
|
9
|
+
timefire/keys/timefire-ledger-ed25519-v1.txt,sha256=YFwffoMF8-wvqY9pyve8j0hBtGrPPJ17AxlALsoEFPs,141
|
|
10
|
+
timefire/keys/timefire-ledger-ed25519-v1.txt.ots,sha256=_h1grC8Gp5B6C2YcsX2DcXxesJ3sX669fSv0Z7R6Hv8,3743
|
|
11
|
+
timefire-0.1.0.dist-info/METADATA,sha256=DoUlxuwlYGqHC05bOnc2XGLHtoYX5TxYBrtzjLFQRq0,3152
|
|
12
|
+
timefire-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
timefire-0.1.0.dist-info/licenses/LICENSE,sha256=cquBCSEVs_UkL5n1W-7dvQq9vmqwASFT97QjxuL4p28,1075
|
|
14
|
+
timefire-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|