Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Add unauthenticated public Decision Capsule retrieval and local SHA-256 verification.
- Add a read-only public capsule method to the authenticated client.
- Retry interrupted public capsule response downloads and normalize string error responses.

## 0.1.2

- Publish the current tested Python client with aligned installation and API availability guidance.
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

---

The official Python client supports tokenized-equity market listings, asynchronous company research jobs, and research webhooks. It preserves provider provenance, stable error codes, and request IDs while applying bounded timeouts and retries.
The official Python client supports tokenized-equity market listings, asynchronous company research jobs, research webhooks, and read-only public Decision Capsule verification. It preserves provider provenance, stable error codes, and request IDs while applying bounded timeouts and retries.

## Install

Expand Down Expand Up @@ -38,13 +38,26 @@ for instrument in page.data:
| Tokenized-equity spot listings and pagination | `market:read` | `list_tokenized_equities`, `tokenized_equities` |
| Company research snapshots | `research:run` | `start_company_research`, `get_research_job`, `cancel_research_job`, `wait_for_research_job` |
| Research webhooks and delivery history | `alerts:manage` | `create_webhook`, `update_webhook`, `list_webhooks`, `list_webhook_deliveries` |
| Public Decision Capsule copies | Public bearer link | `get_public_decision_capsule`, `verify_public_decision_capsule` |

A tokenized-equity spot instrument is not a direct share or a stock perpetual. When provider terms block a market response, the SDK surfaces `provider_terms_unconfirmed` without retrying or substituting data.

## Reliability and security

The default request timeout is 10 seconds. Network errors, `429`, and transient `5xx` responses receive at most two bounded retries. Webhook mutations are not automatically retried because the API does not accept webhook idempotency keys. The default transport rejects redirects and restricts bearer credentials to `api.tidelight.app`.

Public capsule links are fetched from `tidelight.app` without an API key and are verified locally against the disclosed snapshot's SHA-256 digest. `public_base_url` can be overridden only for a trusted HTTPS origin or local development. Digest verification detects changed bytes relative to the supplied digest; it does not prove authorship, source authenticity, or research quality.

Use the standalone helper to retrieve a shared capsule without creating an authenticated client:

```python
import os
from tidelight import get_public_decision_capsule

capsule = get_public_decision_capsule(os.environ["TIDELIGHT_PUBLIC_CAPSULE_TOKEN"])
print(capsule["integrityHash"], capsule["snapshot"])
```

Webhook creation and secret rotation return a one-time signing secret. **TODO: Persist** it in your approved secret manager immediately. Do not print it; it cannot be recovered by listing the endpoint.

## Build and test from source
Expand Down
5 changes: 4 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

---

官方 Python 客户端支持代币化股票行情列表、异步公司研究任务和研究 Webhook。客户端保留数据提供方来源、稳定错误代码和请求 ID,并使用有界超时与重试。
官方 Python 客户端支持代币化股票行情列表、异步公司研究任务、研究 Webhook 和只读公开决策胶囊验证。客户端保留数据提供方来源、稳定错误代码和请求 ID,并使用有界超时与重试。

## 安装

Expand Down Expand Up @@ -37,6 +37,7 @@ for instrument in page.data:
| 代币化股票现货列表与分页 | `market:read` | `list_tokenized_equities`、`tokenized_equities` |
| 公司研究快照 | `research:run` | `start_company_research`、`get_research_job`、`cancel_research_job`、`wait_for_research_job` |
| 研究 Webhook 与投递记录 | `alerts:manage` | `create_webhook`、`update_webhook`、`list_webhooks`、`list_webhook_deliveries` |
| 公开决策胶囊副本 | 公开持有者链接 | `get_public_decision_capsule`、`verify_public_decision_capsule` |

代币化股票现货交易工具并非直接持有的股票,也不是股票永续合约。当数据提供方条款阻止行情响应时,SDK 会返回 `provider_terms_unconfirmed`,不会自动改用其他数据源。

Expand All @@ -46,6 +47,8 @@ for instrument in page.data:

创建 Webhook 或轮换密钥时会返回一次性签名密钥。**请立即将其保存到获批的密钥管理器中。** 不要打印该密钥;之后无法通过列出端点找回。

公开胶囊链接从 `tidelight.app` 获取,不发送 API 密钥;客户端会在本地根据已公开快照的 SHA-256 摘要进行验证。摘要验证只能检测相对于给定摘要的字节变化,不能证明作者身份、来源真实性或研究质量。

## 从源代码构建与测试

```sh
Expand Down
4 changes: 2 additions & 2 deletions src/tidelight/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Tidelight Intelligence Cloud Python client."""

from .client import ApiError, Client, Instrument, InstrumentPage, ResearchJob
from .client import ApiError, Client, Instrument, InstrumentPage, ResearchJob, get_public_decision_capsule, verify_public_decision_capsule

__all__ = ["ApiError", "Client", "Instrument", "InstrumentPage", "ResearchJob"]
__all__ = ["ApiError", "Client", "Instrument", "InstrumentPage", "ResearchJob", "get_public_decision_capsule", "verify_public_decision_capsule"]
124 changes: 123 additions & 1 deletion src/tidelight/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
from __future__ import annotations

import json
import hashlib
from http.client import IncompleteRead
import math
import re
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Callable, Iterator
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlparse
Expand Down Expand Up @@ -57,6 +61,72 @@ class ResearchJob:
request_id: str


def _canonical_json(value: Any) -> str:
if value is None or isinstance(value, (str, bool)):
return _canonical_json_string(value)
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if not math.isfinite(value):
raise ValueError("Public capsule snapshots must contain finite numbers.")
if value == 0:
return "0"
raw = repr(value).lower()
magnitude = abs(value)
if 1e-6 <= magnitude < 1e21:
fixed = format(Decimal(raw), "f")
return fixed.rstrip("0").rstrip(".") if "." in fixed else fixed
mantissa, _, exponent = raw.partition("e")
if mantissa.endswith(".0"):
mantissa = mantissa[:-2]
exponent_value = int(exponent or "0")
return f"{mantissa}e{'+' if exponent_value >= 0 else ''}{exponent_value}"
if isinstance(value, list):
return "[" + ",".join(_canonical_json(item) for item in value) + "]"
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
return "{" + ",".join(
_canonical_json_string(key) + ":" + _canonical_json(value[key])
for key in sorted(value)
) + "}"
raise ValueError("Public capsule snapshots must contain JSON values only.")


def _canonical_json_string(value: str | bool | None) -> str:
rendered = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
result: list[str] = []
index = 0
while index < len(rendered):
codepoint = ord(rendered[index])
if 0xD800 <= codepoint <= 0xDBFF and index + 1 < len(rendered):
low = ord(rendered[index + 1])
if 0xDC00 <= low <= 0xDFFF:
result.append(chr(0x10000 + ((codepoint - 0xD800) << 10) + low - 0xDC00))
index += 2
continue
if 0xD800 <= codepoint <= 0xDFFF:
result.append(f"\\u{codepoint:04x}")
else:
result.append(rendered[index])
index += 1
return "".join(result)


def verify_public_decision_capsule(publication: dict[str, Any]) -> bool:
"""Check the disclosed snapshot digest; this does not establish source truth or authorship."""
if not isinstance(publication, dict) or not isinstance(publication.get("snapshot"), dict):
return False
if publication.get("hashAlgorithm") != "sha256":
return False
expected = publication.get("integrityHash")
if not isinstance(expected, str) or not re.fullmatch(r"[a-f0-9]{64}", expected):
return False
try:
actual = hashlib.sha256(_canonical_json(publication.get("snapshot")).encode("utf-8")).hexdigest()
except (TypeError, ValueError, OverflowError):
return False
return actual == expected


class ApiError(Exception):
def __init__(self, status: int, code: str, message: str, request_id: str | None = None):
super().__init__(message)
Expand All @@ -65,8 +135,52 @@ def __init__(self, status: int, code: str, message: str, request_id: str | None
self.request_id = request_id


def get_public_decision_capsule(token: str, *, public_base_url: str = "https://tidelight.app", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] | None = None, sleeper: Callable[[float], None] = time.sleep) -> dict[str, Any]:
if not isinstance(token, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", token):
raise ValueError("Invalid public capsule token.")
public = urlparse(public_base_url)
local_public = public.scheme == "http" and public.hostname in {"localhost", "127.0.0.1"}
if (public.scheme != "https" and not local_public) or (public.scheme == "https" and not public.hostname) or (public.scheme == "https" and public.port not in (None, 443)) or public.path not in ("", "/") or public.username or public.password or public.query or public.fragment:
raise ValueError("The public capsule URL must use HTTPS or a local development origin.")
if not 0.1 <= timeout <= 120 or not isinstance(max_retries, int) or not 0 <= max_retries <= 5:
raise ValueError("Public capsule timeout or retry limit is out of range.")
transport = opener or build_opener(_NoRedirect()).open
pause = sleeper
request = Request(f"{public_base_url.rstrip('/')}/api/decision-capsules/public/{token}", headers={"Accept": "application/json"})
for attempt in range(max_retries + 1):
try:
with transport(request, timeout=timeout) as response:
try:
payload = json.load(response)
except ValueError:
raise ApiError(getattr(response, "status", 0), "invalid_response", "The public capsule response is invalid.") from None
except HTTPError as error:
try:
body = json.load(error)
details = body.get("error", {}) if isinstance(body, dict) else {}
if isinstance(details, str):
details = {"message": details}
if not isinstance(details, dict):
details = {}
except (ValueError, AttributeError, OSError, IncompleteRead):
details = {}
if error.code in {429, 502, 503, 504} and attempt < max_retries:
pause(min(5.0, float(error.headers.get("Retry-After", 0) or 0)) if str(error.headers.get("Retry-After", "")).isdigit() else min(2.0, 0.25 * 2 ** attempt))
continue
raise ApiError(error.code, details.get("code", "public_capsule_error"), details.get("message", "Public capsule request failed."), details.get("request_id") or error.headers.get("X-Request-Id")) from None
except (URLError, TimeoutError, OSError, IncompleteRead):
if attempt >= max_retries:
raise ApiError(0, "network_error", "The public capsule could not be reached.") from None
pause(min(2.0, 0.25 * 2 ** attempt))
continue
if not isinstance(payload, dict) or payload.get("schemaVersion") != 1 or not isinstance(payload.get("snapshot"), dict) or payload.get("integrity") != "valid" or not verify_public_decision_capsule(payload):
raise ApiError(409, "integrity_verification_failed", "Public capsule integrity verification failed.")
return payload
raise RuntimeError("Unreachable retry state")


class Client:
def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v1", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] | None = None, sleeper: Callable[[float], None] = time.sleep):
def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v1", public_base_url: str = "https://tidelight.app", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] | None = None, sleeper: Callable[[float], None] = time.sleep):
if not re.fullmatch(r"tl_(test|live)_[A-Za-z0-9_-]{43}", api_key):
raise ValueError("A valid Tidelight API key is required.")
parsed = urlparse(base_url)
Expand All @@ -76,17 +190,25 @@ def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v
raise ValueError("The API URL must use api.tidelight.app over HTTPS.")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError("The API URL cannot contain credentials, query, or fragment.")
public = urlparse(public_base_url)
local_public = public.scheme == "http" and public.hostname in {"localhost", "127.0.0.1"}
if (public.scheme != "https" and not local_public) or (public.scheme == "https" and not public.hostname) or (public.scheme == "https" and public.port not in (None, 443)) or public.path not in ("", "/") or public.username or public.password or public.query or public.fragment:
raise ValueError("The public capsule URL must use HTTPS or a local development origin.")
if not 0.1 <= timeout <= 120:
raise ValueError("timeout must be 0.1 to 120 seconds.")
if not isinstance(max_retries, int) or not 0 <= max_retries <= 5:
raise ValueError("max_retries must be 0 to 5.")
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._public_base_url = public_base_url.rstrip("/")
self._timeout = timeout
self._max_retries = max_retries
self._opener = opener or build_opener(_NoRedirect()).open
self._sleeper = sleeper

def get_public_decision_capsule(self, token: str) -> dict[str, Any]:
return get_public_decision_capsule(token, public_base_url=self._public_base_url, timeout=self._timeout, max_retries=self._max_retries, opener=self._opener, sleeper=self._sleeper)

def list_tokenized_equities(self, *, limit: int = 20, cursor: str | None = None, underlying_ticker: str | None = None) -> InstrumentPage:
if not isinstance(limit, int) or not 1 <= limit <= 100:
raise ValueError("limit must be 1 to 100.")
Expand Down
Loading
Loading