diff --git a/examples/basic.py b/examples/basic.py index 8808256..0ada4e5 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -6,12 +6,10 @@ base_url = ( os.environ.get("SECAPI_BASE_URL") or os.environ.get("SECAPI_API_BASE_URL") - or os.environ.get("OMNI_DATASTREAM_BASE_URL") - or os.environ.get("OMNI_DATASTREAM_API_BASE_URL") or "https://api.secapi.ai" ) client = SecApiClient( - api_key=os.environ.get("SECAPI_API_KEY") or os.environ["OMNI_DATASTREAM_API_KEY"], + api_key=os.environ["SECAPI_API_KEY"], base_url=base_url, ) @@ -28,6 +26,6 @@ print(client.volatility_signal(ticker="AAPL")) print(client.latest_13f(cik="0001067983", limit=5)) print(client.insiders(ticker="AAPL", limit=5)) -if os.environ.get("OMNI_OPERATOR_API_KEY"): - operator_client = SecApiClient(api_key=os.environ["OMNI_OPERATOR_API_KEY"], base_url=base_url) +if os.environ.get("SECAPI_OPERATOR_API_KEY"): + operator_client = SecApiClient(api_key=os.environ["SECAPI_OPERATOR_API_KEY"], base_url=base_url) print(operator_client.observability()) diff --git a/omni_datastream_py/client.py b/omni_datastream_py/client.py index 725b14d..b361602 100644 --- a/omni_datastream_py/client.py +++ b/omni_datastream_py/client.py @@ -1,1198 +1,12 @@ -from __future__ import annotations - -import json -import math -import random -import threading -import time -from dataclasses import dataclass -from typing import Any, Literal -from urllib.error import HTTPError, URLError -from urllib.parse import quote, urlencode -from urllib.request import Request, urlopen - -#: ``?view=`` response mode. Mirrors the canonical ``ResponseView`` union in -#: ``@omni-datastream/contracts``. Agent mode returns a strictly smaller, -#: essentials+citation-pointers shape on supported endpoints (OMNI-3075 / 3084). -ResponseView = Literal["default", "compact", "agent"] - -SDK_VERSION = "0.3.1" -POSTHOG_CAPTURE_TOKEN = "phc_erM3KBxu4WfepnjJ6TLT11QA0yykiCeRQdi5S4xwCR6" -POSTHOG_CAPTURE_HOST = "https://us.i.posthog.com" -SAFE_RETRY_METHODS = {"GET", "HEAD", "OPTIONS"} -RETRYABLE_STATUSES = {408, 429, 502, 503, 504} -NEVER_RETRY_STATUSES = {400, 401, 403, 404, 422} -DEFAULT_RETRY_CONFIG = { - "max_retries": 3, - "base_delay_ms": 200, - "max_delay_ms": 5_000, - "total_budget_ms": 30_000, - "circuit_breaker_failure_threshold": 5, - "circuit_breaker_cooldown_ms": 60_000, -} - - -@dataclass -class OmniDatastreamError(Exception): - status: int - payload: dict[str, Any] - retry_after_ms: int | None = None - - def __str__(self) -> str: - message = self.payload.get("message") if isinstance(self.payload, dict) else None - code = self.payload.get("code") if isinstance(self.payload, dict) else None - return f"OmniDatastreamError(status={self.status}, code={code}, message={message})" - - -class _ClientCircuitBreaker: - def __init__(self, failure_threshold: int, cooldown_ms: int) -> None: - self.failure_threshold = failure_threshold - self.cooldown_ms = cooldown_ms - self.state = "closed" - self.consecutive_failures = 0 - self.opened_at = 0.0 - self._lock = threading.RLock() - - def before_request(self, now_ms: float) -> None: - with self._lock: - if self.state != "open": - return - if now_ms - self.opened_at >= self.cooldown_ms: - self.state = "half_open" - return - raise OmniDatastreamError(0, {"code": "client_circuit_open", "message": "Omni Datastream client circuit breaker is open"}) - - def record_success(self) -> None: - with self._lock: - self.state = "closed" - self.consecutive_failures = 0 - self.opened_at = 0.0 - - def record_failure(self, now_ms: float) -> None: - with self._lock: - self.consecutive_failures += 1 - if self.state == "half_open" or self.consecutive_failures >= self.failure_threshold: - self.state = "open" - self.opened_at = now_ms - - def snapshot(self) -> dict[str, Any]: - with self._lock: - return { - "state": self.state, - "consecutive_failures": self.consecutive_failures, - "opened_at": self.opened_at, - } - - -def _parse_retry_after_ms(value: str | None, now_ms: float) -> int | None: - if not value: - return None - value = value.strip() - if not value: - return None - try: - seconds = float(value) - if math.isfinite(seconds) and seconds >= 0: - return round(seconds * 1000) - except (ValueError, OverflowError): - pass - try: - from email.utils import parsedate_to_datetime - - parsed = parsedate_to_datetime(value) - return max(0, round(parsed.timestamp() * 1000 - now_ms)) - except (TypeError, ValueError, OverflowError): - return None - - -def _route_template(path: str) -> str: - segments = [] - for segment in path.split("?")[0].split("/"): - if len(segment) >= 8 and all(char in "0123456789abcdefABCDEF" for char in segment): - segments.append(":id") - elif len(segment) >= 10 and any(char.isdigit() for char in segment) and segment.replace("-", "").isalnum(): - segments.append(":id") - else: - segments.append(segment) - return "/".join(segments) - - -class OmniDatastreamClient: - def __init__( - self, - api_key: str | None = None, - bearer_token: str | None = None, - base_url: str = "https://api.secapi.ai", - api_version: str = "2026-03-19", - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> None: - self.api_key = api_key - self.bearer_token = bearer_token - self.base_url = base_url.rstrip("/") - self.api_version = api_version - self.retry = retry - self.telemetry = telemetry - self._urlopen = urlopen - self._circuit_breaker = _ClientCircuitBreaker( - DEFAULT_RETRY_CONFIG["circuit_breaker_failure_threshold"], - DEFAULT_RETRY_CONFIG["circuit_breaker_cooldown_ms"], - ) - self._telemetry_distinct_id = f"py-sdk-{id(self):x}-{int(time.time() * 1000):x}" - - def _headers(self) -> dict[str, str]: - headers = { - "accept": "application/json", - "content-type": "application/json", - "secapi-version": self.api_version, - "omni-version": self.api_version, - "user-agent": f"secapi-client/{self.api_version}", - } - if self.bearer_token: - headers["authorization"] = f"Bearer {self.bearer_token}" - if self.api_key: - headers["x-api-key"] = self.api_key - return headers - - @property - def circuit_state(self) -> dict[str, Any]: - return self._circuit_breaker.snapshot() - - def _request_options_from_params(self, params: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]]: - params = dict(params or {}) - options: dict[str, Any] = {} - if "retry" in params: - options["retry"] = params.pop("retry") - if "telemetry" in params: - options["telemetry"] = params.pop("telemetry") - return params, options - - def _merge_retry_options(self, retry: bool | dict[str, Any] | None) -> tuple[bool, dict[str, Any], bool]: - global_retry = self.retry - if retry is False: - return True, {}, False - call_options = retry if isinstance(retry, dict) else {} - unsafe_opt_in = isinstance(retry, dict) and retry.get("enabled") is True - if global_retry is False and not unsafe_opt_in: - return True, {}, False - global_options = global_retry if isinstance(global_retry, dict) else {} - options = {**global_options, **call_options} - disabled = options.get("enabled") is False - return disabled, options, unsafe_opt_in - - def _merge_telemetry_options(self, telemetry: bool | dict[str, Any] | None) -> tuple[bool, dict[str, Any]]: - global_telemetry = self.telemetry - if global_telemetry is False or telemetry is False: - return True, {} - global_options = global_telemetry if isinstance(global_telemetry, dict) else {} - call_options = telemetry if isinstance(telemetry, dict) else {} - disabled = global_options.get("enabled") is False or call_options.get("enabled") is False - return disabled, {**global_options, **call_options} - - def _should_retry(self, method: str, error: Exception, retry_disabled: bool, unsafe_opt_in: bool) -> tuple[bool, int | None, str]: - if retry_disabled: - return False, None, "disabled" - if isinstance(error, OmniDatastreamError): - status = error.status - if status in NEVER_RETRY_STATUSES: - return False, status, "non_retryable_status" - if status not in RETRYABLE_STATUSES: - return False, status, "status" - if status == 429: - return True, status, "status" - if method in SAFE_RETRY_METHODS or unsafe_opt_in: - return True, status, "status" - return False, status, "method" - if method in SAFE_RETRY_METHODS or unsafe_opt_in: - return True, None, "network" - return False, None, "method" - - def _retry_delay_ms(self, attempt: int, retry_after_ms: int | None, retry_options: dict[str, Any]) -> int: - if retry_after_ms is not None: - return retry_after_ms - base_delay_ms = int(retry_options.get("base_delay_ms", DEFAULT_RETRY_CONFIG["base_delay_ms"])) - max_delay_ms = int(retry_options.get("max_delay_ms", DEFAULT_RETRY_CONFIG["max_delay_ms"])) - random_fn = retry_options.get("random", random.random) - return int(random_fn() * min(max_delay_ms, base_delay_ms * (2 ** attempt))) - - def _emit_retry_telemetry( - self, - *, - method: str, - path: str, - attempt: int, - max_retries: int, - delay_ms: int, - status: int | None, - reason: str, - elapsed_ms: float, - telemetry: bool | dict[str, Any] | None, - ) -> None: - disabled, options = self._merge_telemetry_options(telemetry) - if disabled: - return - capture_token = options.get("capture_token", POSTHOG_CAPTURE_TOKEN) - host = str(options.get("host", POSTHOG_CAPTURE_HOST)).rstrip("/") - opener = options.get("opener", urlopen) - payload = { - "api_key": capture_token, - "event": "client_retry_attempt", - "distinct_id": options.get("distinct_id", self._telemetry_distinct_id), - "properties": { - "sdk_language": "py", - "sdk_version": SDK_VERSION, - "method": method, - "route": _route_template(path), - "server_origin": self.base_url, - "attempt": attempt, - "max_retries": max_retries, - "delay_ms": delay_ms, - "status": status, - "reason": reason, - "elapsed_ms": round(elapsed_ms), - "$process_person_profile": False, - }, - } - - def send() -> None: - data = json.dumps(payload).encode("utf-8") - request = Request(f"{host}/capture/", data=data, method="POST", headers={"content-type": "application/json"}) - response = None - try: - response = opener(request, timeout=float(options.get("timeout", 1.0))) - except Exception: - return - finally: - close = getattr(response, "close", None) - if callable(close): - close() - - if options.get("sync") is True: - send() - else: - threading.Thread(target=send, daemon=True).start() - - def _request( - self, - method: str, - path: str, - params: dict[str, Any] | None = None, - body: dict[str, Any] | None = None, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - params, param_options = self._request_options_from_params(params) - if retry is None: - retry = param_options.get("retry") - if telemetry is None: - telemetry = param_options.get("telemetry") - method = method.upper() - retry_disabled, retry_options, unsafe_opt_in = self._merge_retry_options(retry) - max_retries = max(0, int(retry_options.get("max_retries", DEFAULT_RETRY_CONFIG["max_retries"]))) - total_budget_ms = math.inf if retry_disabled else int(retry_options.get("total_budget_ms", DEFAULT_RETRY_CONFIG["total_budget_ms"])) - now = retry_options.get("now", lambda: time.time() * 1000) - sleep = retry_options.get("sleep", lambda delay_ms: time.sleep(delay_ms / 1000)) - started_at = float(now()) - circuit_eligible = not retry_disabled - if circuit_eligible: - self._circuit_breaker.before_request(started_at) - - filtered_params = { - key: value - for key, value in params.items() - if value is not None and value != "" - } - query = f"?{urlencode(filtered_params, doseq=True)}" if filtered_params else "" - payload = json.dumps(body).encode("utf-8") if body is not None else None - - last_error: Exception | None = None - for attempt in range(max_retries + 1): - elapsed_ms = float(now()) - started_at - remaining_ms = total_budget_ms - elapsed_ms - if remaining_ms <= 0: - if circuit_eligible and last_error is not None: - self._circuit_breaker.record_failure(float(now())) - if last_error is not None: - raise last_error - raise OmniDatastreamError(0, {"code": "client_retry_budget_exceeded", "message": "Omni Datastream request exceeded retry budget"}) - - headers = self._headers() - idempotency_key = retry_options.get("idempotency_key") - if idempotency_key: - headers["Idempotency-Key"] = str(idempotency_key) - request = Request(f"{self.base_url}{path}{query}", data=payload, method=method, headers=headers) - - try: - timeout_seconds = None if not math.isfinite(remaining_ms) else max(0.001, remaining_ms / 1000) - response_context = self._urlopen(request) if timeout_seconds is None else self._urlopen(request, timeout=timeout_seconds) - with response_context as response: - if response.status == 204: - if circuit_eligible: - self._circuit_breaker.record_success() - return {} - raw = response.read().decode("utf-8") - if not raw.strip(): - if circuit_eligible: - self._circuit_breaker.record_success() - return {} - data = json.loads(raw) - if circuit_eligible: - self._circuit_breaker.record_success() - return data - except HTTPError as error: - raw_payload = error.read().decode("utf-8", errors="replace") - try: - error_payload = json.loads(raw_payload) - except json.JSONDecodeError: - error_payload = {"message": raw_payload or error.reason} - last_error = OmniDatastreamError( - status=error.code, - payload=error_payload, - retry_after_ms=_parse_retry_after_ms(error.headers.get("Retry-After"), float(now())), - ) - except (URLError, TimeoutError, OSError) as error: - last_error = error - - retryable, status, reason = self._should_retry(method, last_error, retry_disabled, unsafe_opt_in) - if not retryable or attempt >= max_retries: - if retryable and circuit_eligible: - self._circuit_breaker.record_failure(float(now())) - raise last_error - delay_ms = self._retry_delay_ms(attempt, last_error.retry_after_ms if isinstance(last_error, OmniDatastreamError) and status == 429 else None, retry_options) - elapsed_after_attempt_ms = float(now()) - started_at - if elapsed_after_attempt_ms + delay_ms > total_budget_ms: - if circuit_eligible: - self._circuit_breaker.record_failure(float(now())) - raise last_error - self._emit_retry_telemetry( - method=method, - path=path, - attempt=attempt + 1, - max_retries=max_retries, - delay_ms=delay_ms, - status=status, - reason=reason, - elapsed_ms=elapsed_after_attempt_ms, - telemetry=telemetry, - ) - sleep(delay_ms) - - raise last_error or OmniDatastreamError(0, {"code": "client_request_failed", "message": "Omni Datastream request failed"}) - - def health( - self, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/healthz", retry=retry, telemetry=telemetry) - - def me(self) -> dict[str, Any]: - return self._request("GET", "/v1/me") - - def org(self) -> dict[str, Any]: - return self._request("GET", "/v1/org") - - def billing(self) -> dict[str, Any]: - return self._request("GET", "/v1/billing") - - def dashboard_overview(self) -> dict[str, Any]: - return self._request("GET", "/v1/dashboard/overview") - - def list_api_keys(self) -> dict[str, Any]: - return self._request("GET", "/v1/api_keys") - - def create_api_key( - self, - *, - label: str | None = None, - scopes: list[str] | None = None, - livemode: bool | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/api_keys", body={"label": label, "scopes": scopes, "livemode": livemode}, retry=retry, telemetry=telemetry) - - def delete_api_key( - self, - key_id: str, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("DELETE", f"/v1/api_keys/{key_id}", retry=retry, telemetry=telemetry) - - def create_agent_bootstrap_token( - self, - *, - label: str | None = None, - scopes: list[str] | None = None, - ttl_seconds: int | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request( - "POST", - "/v1/agent/bootstrap_tokens", - body={"label": label, "scopes": scopes, "ttlSeconds": ttl_seconds}, - retry=retry, - telemetry=telemetry, - ) - - def bootstrap_agent( - self, - *, - token: str, - label: str | None = None, - scopes: list[str] | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/agent/bootstrap", body={"token": token, "label": label, "scopes": scopes}, retry=retry, telemetry=telemetry) - - def quote_billing( - self, - *, - plan_key: str | None = None, - meter_class: str | None = None, - path: str | None = None, - method: str | None = None, - units: int | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request( - "POST", - "/v1/billing/quote", - body={"planKey": plan_key, "meterClass": meter_class, "path": path, "method": method, "units": units}, - retry=retry, - telemetry=telemetry, - ) - - def update_billing_budget( - self, - *, - spend_cap_cents: int | None = None, - soft_cap_cents: int | None = None, - approval_threshold_cents: int | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request( - "PUT", - "/v1/billing/budget", - body={ - "spendCapCents": spend_cap_cents, - "softCapCents": soft_cap_cents, - "approvalThresholdCents": approval_threshold_cents, - }, - retry=retry, - telemetry=telemetry, - ) - - def create_checkout_session( - self, - *, - plan_key: str, - success_url: str | None = None, - cancel_url: str | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/billing/checkout", body={"planKey": plan_key, "successUrl": success_url, "cancelUrl": cancel_url}, retry=retry, telemetry=telemetry) - - def create_billing_portal_session( - self, - *, - return_url: str | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/billing/portal", body={"returnUrl": return_url}, retry=retry, telemetry=telemetry) - - def usage(self) -> dict[str, Any]: - return self._request("GET", "/v1/usage") - - def limits(self) -> dict[str, Any]: - return self._request("GET", "/v1/limits") - - def events( - self, - *, - kind: str | None = None, - type: str | None = None, - request_id: str | None = None, - since: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/v1/events", params={"kind": kind, "type": type, "requestId": request_id, "since": since, "limit": limit}) - - def export_events( - self, - *, - kind: str | None = None, - type: str | None = None, - request_id: str | None = None, - since: str | None = None, - limit: int | None = None, - format: str = "json", - ) -> dict[str, Any]: - return self._request( - "GET", - "/v1/events/export", - params={"kind": kind, "type": type, "requestId": request_id, "since": since, "limit": limit, "format": format}, - ) - - def request_diagnostics(self, request_id: str) -> dict[str, Any]: - return self._request("GET", f"/v1/diagnostics/requests/{request_id}") - - def list_admin_organizations(self, *, q: str | None = None, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/admin/orgs", params={"q": q, "limit": limit}) - - def get_admin_organization(self, org_id: str, *, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", f"/v1/admin/orgs/{org_id}", params={"limit": limit}) - - def get_admin_request_diagnostics(self, org_id: str, request_id: str) -> dict[str, Any]: - return self._request("GET", f"/v1/admin/orgs/{org_id}/requests/{request_id}") - - def get_admin_delivery_summary( - self, - org_id: str, - *, - since: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request( - "GET", - f"/v1/admin/orgs/{org_id}/deliveries/summary", - params={"since": since, "limit": limit}, - ) - - def delivery_summary(self, *, since: str | None = None, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/diagnostics/deliveries/summary", params={"since": since, "limit": limit}) - - def observability(self) -> dict[str, Any]: - return self._request("GET", "/v1/observability") - - def export_observability(self, *, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/observability/export", params={"limit": limit}) - - def list_webhook_endpoints(self) -> dict[str, Any]: - return self._request("GET", "/v1/webhook_endpoints") - - def create_webhook_endpoint( - self, - *, - destination_url: str, - description: str | None = None, - subscribed_event_types: list[str] | None = None, - livemode: bool | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request( - "POST", - "/v1/webhook_endpoints", - body={ - "destinationUrl": destination_url, - "description": description, - "subscribedEventTypes": subscribed_event_types, - "livemode": livemode, - }, - retry=retry, - telemetry=telemetry, - ) - - def rotate_webhook_endpoint_secret( - self, - webhook_id: str, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", f"/v1/webhook_endpoints/{webhook_id}/rotate_secret", retry=retry, telemetry=telemetry) - - def list_webhook_deliveries(self, webhook_id: str, *, event_id: str | None = None, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", f"/v1/webhook_endpoints/{webhook_id}/deliveries", params={"eventId": event_id, "limit": limit}) - - def replay_webhook_delivery( - self, - webhook_id: str, - delivery_id: str, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", f"/v1/webhook_endpoints/{webhook_id}/deliveries/{delivery_id}/replay", retry=retry, telemetry=telemetry) - - def list_stream_subscriptions(self) -> dict[str, Any]: - return self._request("GET", "/v1/stream_subscriptions") - - def create_stream_subscription( - self, - *, - description: str | None = None, - event_types: list[str] | None = None, - transport: str | None = None, - livemode: bool | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request( - "POST", - "/v1/stream_subscriptions", - body={ - "description": description, - "eventTypes": event_types, - "transport": transport, - "livemode": livemode, - }, - retry=retry, - telemetry=telemetry, - ) - - def stream_events(self, stream_id: str, *, cursor: str | None = None, type: str | None = None, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", f"/v1/stream_subscriptions/{stream_id}/events", params={"cursor": cursor, "type": type, "limit": limit}) - - def resolve_entity(self, *, ticker: str | None = None, cik: str | None = None, name: str | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/entities/resolve", params={"ticker": ticker, "cik": cik, "name": name}) - - def search_entities(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/entities", params=params) - - def search_filings(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/filings", params=params) - - def filing_by_accession(self, accession_number: str, **params: Any) -> dict[str, Any]: - return self._request("GET", f"/v1/filings/{accession_number}", params=params) - - def latest_filing(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/filings/latest", params=params) - - def render_latest_filing(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/filings/latest/render", params=params) - - def latest_section(self, section_key: str, **params: Any) -> dict[str, Any]: - return self._request("GET", f"/v1/filings/latest/sections/{section_key}", params=params) - - def filing_section_by_accession(self, accession_number: str, section_key: str, **params: Any) -> dict[str, Any]: - return self._request("GET", f"/v1/filings/{accession_number}/sections/{section_key}", params=params) - - def search_sections(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/sections/search", params=params) - - def offerings(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/offerings", params=params) - - def market_calendar(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/market/calendar", params=params) - - def market_snapshots(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/market/snapshots", params=params) - - def market_bars(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/market/bars", params=params) - - def market_corporate_actions(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/market/corporate-actions", params=params) - - def market_reference(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/market/reference", params=params) - - def market_estimates(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/market/estimates", params=params) - - def news_stories(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/news/stories", params=params) - - def macro_indicators(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/macro/indicators", params=params) - - def macro_releases(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/macro/releases", params=params) - - def macro_calendar(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/macro/calendar", params=params) - - def macro_forecasts(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/macro/forecasts", params=params) - - def macro_high_signal_pack(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/macro/high-signal-pack", params=params) - - def macro_regimes(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/macro/regimes", params=params) - - def factor_catalog(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/catalog", params=params) - - def factor_returns(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/returns", params=params) - - def factor_returns_intraday(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/returns/intraday", params=params) - - def factor_dashboard(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/dashboard", params=params) - - def factor_regime_performance(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/regime-performance", params=params) - - def factor_correlations(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/correlations", params=params) - - def factor_screen(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/screen", params=params) - - def factor_exposures(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/exposures", params=params) - - def stock_loadings(self, ticker: str, **params: Any) -> dict[str, Any]: - return self._request("GET", f"/v1/stocks/{ticker}/loadings", params=params) - - def factor_decomposition(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/decomposition", params=params) - - def factor_related_stocks(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/related-stocks", params=params) - - def factor_similarity_pack(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/factors/similarity-pack", params=params) - - def portfolio_analyze( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/portfolio/analyze", body=body, retry=retry, telemetry=telemetry) - - def model_portfolio_factor_view(self, portfolio_id: str, **params: Any) -> dict[str, Any]: - return self._request("GET", f"/v1/model-portfolios/{portfolio_id}/factor-view", params=params) - - def portfolio_optimize( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/portfolio/optimize", body=body, retry=retry, telemetry=telemetry) - - def portfolio_stress_test( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/portfolio/stress-test", body=body, retry=retry, telemetry=telemetry) - - def strategy_factor_rotation( - self, - body: dict[str, Any] | None = None, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/strategies/factor-rotation", body=body or {}, retry=retry, telemetry=telemetry) - - def strategy_regime_screen( - self, - body: dict[str, Any] | None = None, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/strategies/regime-screen", body=body or {}, retry=retry, telemetry=telemetry) - - def intelligence_security(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/intelligence/security", params=params) - - def intelligence_company(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/intelligence/company", params=params) - - def intelligence_earnings_preview(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/intelligence/earnings-preview", params=params) - - def intelligence_country_report( - self, - body: dict[str, Any] | None = None, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/intelligence/country-report", body=body or {}, retry=retry, telemetry=telemetry) - - def intelligence_portfolio( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/intelligence/portfolio", body=body, retry=retry, telemetry=telemetry) - - def intelligence_watchlist( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - **params: Any, - ) -> dict[str, Any]: - return self._request("POST", "/v1/intelligence/watchlist", params=params, body=body, retry=retry, telemetry=telemetry) - - def intelligence_query( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/intelligence/query", body=body, retry=retry, telemetry=telemetry) - - def intelligence_footnotes_query( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/intelligence/footnotes/query", body=body, retry=retry, telemetry=telemetry) - - def market_indices(self, *, include_inventory: bool | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/market/indices", params={"include_inventory": include_inventory}) - - def index_constituents(self, *, index: str | None = None, index_code: str | None = None, cursor: str | None = None, limit: int | None = None) -> dict[str, Any]: - return self._request( - "GET", - "/v1/market/indices/constituents", - params={"index": index, "index_code": index_code, "cursor": cursor, "limit": limit}, - ) - - def volatility_signal(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/signals/volatility", params=params) - - def facts(self, *, tag: str, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/facts", params={"tag": tag, **params}) - - def statements(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/statements", params=params) - - def all_statements(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/statements/all", params=params) - - def statement_by_key(self, statement_key: str, **params: Any) -> dict[str, Any]: - return self._request("GET", f"/v1/statements/{statement_key}", params=params) - - def company_income_statements( - self, - *, - ticker: str, - period: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/v1/companies/income-statements", params={"ticker": ticker, "period": period, "limit": limit}) - - def company_balance_sheets( - self, - *, - ticker: str, - period: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/v1/companies/balance-sheets", params={"ticker": ticker, "period": period, "limit": limit}) - - def company_cash_flow_statements( - self, - *, - ticker: str, - period: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/v1/companies/cash-flow-statements", params={"ticker": ticker, "period": period, "limit": limit}) - - def company_financials( - self, - *, - ticker: str, - period: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/v1/companies/financials", params={"ticker": ticker, "period": period, "limit": limit}) - - def company_ratios( - self, - *, - ticker: str, - period: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/v1/companies/ratios", params={"ticker": ticker, "period": period, "limit": limit}) - - def company_resolve(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/companies/resolve", params=params) - - def company_search(self, *, q: str, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/companies/search", params={"q": q, "limit": limit}) - - def list_13f_filings( - self, - *, - cik: str, - limit: int | None = None, - since: str | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - """List 13F filings for a CIK. - - Pairs with `latest_13f` (which returns the *holdings* of one - specific filing) — this returns the *list* of filings available - for a CIK so callers can pick a specific (reportDate, filingDate) - before fetching holdings. Useful for any consumer that wants to - iterate over a filer's quarterly history or detect newly-landed - filings via the `since` filter. - - Args: - cik: 10-digit zero-padded CIK (e.g. "0001067983" for Berkshire). - limit: Max filings to return (server default applies if None). - since: Optional ISO-8601 timestamp; when set, returns only - filings accepted by SEC at or after this timestamp. - Supports incremental polling for newsletters/alerts - consumers without scanning the full history each tick. - - **Server-side pairing:** the `since=` filter is honoured - by datastream-api as of the release containing - omni-datastream PR #539 (paired with this SDK PR). - Older servers silently ignore unknown query parameters - and return the full unfiltered history, so callers - should always client-side dedupe by `accessionNumber` - if they need strict incremental semantics during the - rollout window. - - Returns: - Raw JSON envelope: `{"object": "list", "data": [{...}], ...}`. - """ - return self._request( - "GET", - "/v1/owners/13f/filings", - params={"cik": cik, "limit": limit, "since": since}, - retry=retry, - telemetry=telemetry, - ) - - def latest_13f( - self, - *, - cik: str, - report_date: str | None = None, - filing_date: str | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return self._request( - "GET", - "/v1/owners/13f", - params={ - "cik": cik, - "reportDate": report_date, - "filingDate": filing_date, - "limit": limit, - }, - ) - - def compare_13f( - self, - *, - cik: str, - limit: int | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/owners/13f/compare", body={"cik": cik, "limit": limit}, retry=retry, telemetry=telemetry) - - def insiders(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/insiders", params=params) - - def compensation(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/compensation", params=params) - - def compare_compensation( - self, - *, - ticker: str | None = None, - cik: str | None = None, - limit: int | None = None, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/compensation/compare", body={"ticker": ticker, "cik": cik, "limit": limit}, retry=retry, telemetry=telemetry) - - def create_artifact( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/artifacts", body=body, retry=retry, telemetry=telemetry) - - def list_artifacts(self, *, kind: str | None = None, status: str | None = None, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/artifacts", params={"kind": kind, "status": status, "limit": limit}) - - def artifact_summary(self) -> dict[str, Any]: - return self._request("GET", "/v1/artifacts/summary") - - def get_artifact(self, artifact_id: str) -> dict[str, Any]: - return self._request("GET", f"/v1/artifacts/{artifact_id}") - - def artifact_manifest(self, artifact_id: str) -> dict[str, Any]: - return self._request("GET", f"/v1/artifacts/{artifact_id}/manifest") - - def export_artifact(self, artifact_id: str, *, format: str = "json") -> dict[str, Any]: - return self._request("GET", f"/v1/artifacts/{artifact_id}/export", params={"format": format}) - - def download_artifact(self, artifact_id: str) -> dict[str, Any]: - return self._request("GET", f"/v1/artifacts/{artifact_id}/download") - - def reconcile_artifact( - self, - artifact_id: str, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", f"/v1/artifacts/{artifact_id}/reconcile", retry=retry, telemetry=telemetry) - - def analytics_query( - self, - body: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/v1/analytics/query", body=body, retry=retry, telemetry=telemetry) - - def list_traces(self, *, ids: str | list[str]) -> dict[str, Any]: - joined = ",".join(ids) if isinstance(ids, list) else ids - return self._request("GET", "/v1/traces", params={"ids": joined}) - - def get_trace(self, trace_id: str) -> dict[str, Any]: - return self._request("GET", f"/v1/traces/{trace_id}") - - def segmented_revenues(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/statements/segmented-revenues", params=params) - - def segmented_facts(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/statements/segmented-facts", params=params) - - def pension_benefit_schedule(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/filings/pension-benefit-schedule", params=params) - - def share_float(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/statements/share-float", params=params) - - def board_composition(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/board", params=params) - - def nport_holdings(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/funds/nport/holdings", params=params) - - def latest_risk_categories(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/filings/latest/risk-categories", params=params) - - def beneficial_ownership_reports(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/owners/13d-13g", params=params) - - def institutional_ownership_extract(self, *, cik: str, year: int, quarter: int, limit: int | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/owners/institutional/extract", params={"cik": cik, "year": year, "quarter": quarter, "limit": limit}) - - def ma_events(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/events/ma", params=params) - - def enforcement_actions(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/events/enforcement", params=params) - - def voting_results_events(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/events/voting-results", params=params) - - # Dilution endpoints (OMNI-3091). All accept ?view=agent except - # dilution_coverage, whose route returns a small rollup with no agent shape. - def dilution_events(self, **params: Any) -> dict[str, Any]: - # The route's parseQueryBool only matches lowercase "true"/"false"; Python - # bools serialize as "True"/"False" via urlencode, so coerce here. - if "is_atm" in params and isinstance(params["is_atm"], bool): - params["is_atm"] = "true" if params["is_atm"] else "false" - return self._request("GET", "/v1/dilution/events", params=params) - - def dilution_event_detail(self, event_id: str, **params: Any) -> dict[str, Any]: - return self._request("GET", f"/v1/dilution/events/{quote(event_id, safe='')}", params=params) - - def dilution_warrants(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/warrants", params=params) - - def dilution_convertibles(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/convertibles", params=params) - - def dilution_rofr(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/rofr", params=params) - - def dilution_lockups(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/lockups", params=params) - - def dilution_cash_position(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/cash-position", params=params) - - def dilution_corporate_actions(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/corporate-actions", params=params) - - def dilution_nasdaq_compliance(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/nasdaq-compliance", params=params) - - def dilution_ratings(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/ratings", params=params) - - def dilution_reverse_splits(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/reverse-splits", params=params) - - def dilution_score(self, *, ticker: str, view: ResponseView | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/score", params={"ticker": ticker, "view": view}) - - def dilution_share_float_history(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/share-float-history", params=params) - - def dilution_coverage(self, *, ticker: str | None = None) -> dict[str, Any]: - return self._request("GET", "/v1/dilution/coverage", params={"ticker": ticker}) - - def form_144_filings(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/forms/144", params=params) - - def company_subsidiaries(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/companies/subsidiaries", params=params) - - def earnings_transcripts(self, **params: Any) -> dict[str, Any]: - return self._request("GET", "/v1/earnings/transcripts", params=params) - - def mcp_info( - self, - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("GET", "/mcp", retry=retry, telemetry=telemetry) - - def mcp( - self, - request: dict[str, Any], - *, - retry: bool | dict[str, Any] | None = None, - telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: - return self._request("POST", "/mcp", body=request, retry=retry, telemetry=telemetry) - - -SecApiClient = OmniDatastreamClient -SecApiError = OmniDatastreamError +from secapi_client.client import ResponseView, SecApiClient, SecApiError + +OmniDatastreamClient = SecApiClient +OmniDatastreamError = SecApiError + +__all__ = [ + "ResponseView", + "SecApiClient", + "SecApiError", + "OmniDatastreamClient", + "OmniDatastreamError", +] diff --git a/pyproject.toml b/pyproject.toml index bd49e44..074ed7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "secapi-client" -version = "0.3.1" +version = "0.4.1" description = "Python SDK for SEC API" requires-python = ">=3.11" readme = "README.md" diff --git a/secapi_client/__init__.py b/secapi_client/__init__.py index 026e7d8..a29d706 100644 --- a/secapi_client/__init__.py +++ b/secapi_client/__init__.py @@ -1,9 +1,12 @@ -from omni_datastream_py import ( - OmniDatastreamClient, - OmniDatastreamError, - ResponseView, - SecApiClient, - SecApiError, -) +from .client import ResponseView, SecApiClient, SecApiError -__all__ = ["OmniDatastreamClient", "OmniDatastreamError", "ResponseView", "SecApiClient", "SecApiError"] +OmniDatastreamClient = SecApiClient +OmniDatastreamError = SecApiError + +__all__ = [ + "ResponseView", + "SecApiClient", + "SecApiError", + "OmniDatastreamClient", + "OmniDatastreamError", +] diff --git a/secapi_client/client.py b/secapi_client/client.py index 8458958..9049df7 100644 --- a/secapi_client/client.py +++ b/secapi_client/client.py @@ -1,9 +1,1270 @@ -from omni_datastream_py.client import ( - OmniDatastreamClient, - OmniDatastreamError, - ResponseView, - SecApiClient, - SecApiError, -) - -__all__ = ["OmniDatastreamClient", "OmniDatastreamError", "ResponseView", "SecApiClient", "SecApiError"] +from __future__ import annotations + +import json +import math +import random +import threading +import time +from dataclasses import dataclass +from typing import Any, Literal +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + +#: ``?view=`` response mode. Mirrors the canonical ``ResponseView`` union in +#: SEC API contracts. Agent mode returns a strictly smaller, +#: essentials+citation-pointers shape on supported endpoints. +ResponseView = Literal["default", "compact", "agent"] + +SDK_VERSION = "0.4.1" +POSTHOG_CAPTURE_HOST = "https://us.i.posthog.com" +SAFE_RETRY_METHODS = {"GET", "HEAD", "OPTIONS"} +RETRYABLE_STATUSES = {408, 429, 502, 503, 504} +NEVER_RETRY_STATUSES = {400, 401, 403, 404, 422} +DEFAULT_RETRY_CONFIG = { + "max_retries": 3, + "base_delay_ms": 200, + "max_delay_ms": 5_000, + "total_budget_ms": 30_000, + "circuit_breaker_failure_threshold": 5, + "circuit_breaker_cooldown_ms": 60_000, +} + + +@dataclass +class SecApiError(Exception): + status: int + payload: dict[str, Any] + retry_after_ms: int | None = None + + def __str__(self) -> str: + message = self.payload.get("message") if isinstance(self.payload, dict) else None + code = self.payload.get("code") if isinstance(self.payload, dict) else None + return f"SecApiError(status={self.status}, code={code}, message={message})" + + +class _ClientCircuitBreaker: + def __init__(self, failure_threshold: int, cooldown_ms: int) -> None: + self.failure_threshold = failure_threshold + self.cooldown_ms = cooldown_ms + self.state = "closed" + self.consecutive_failures = 0 + self.opened_at = 0.0 + self._lock = threading.RLock() + + def before_request(self, now_ms: float) -> None: + with self._lock: + if self.state != "open": + return + if now_ms - self.opened_at >= self.cooldown_ms: + self.state = "half_open" + return + raise SecApiError(0, {"code": "client_circuit_open", "message": "SEC API client circuit breaker is open"}) + + def record_success(self) -> None: + with self._lock: + self.state = "closed" + self.consecutive_failures = 0 + self.opened_at = 0.0 + + def record_failure(self, now_ms: float) -> None: + with self._lock: + self.consecutive_failures += 1 + if self.state == "half_open" or self.consecutive_failures >= self.failure_threshold: + self.state = "open" + self.opened_at = now_ms + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return { + "state": self.state, + "consecutive_failures": self.consecutive_failures, + "opened_at": self.opened_at, + } + + +def _parse_retry_after_ms(value: str | None, now_ms: float) -> int | None: + if not value: + return None + value = value.strip() + if not value: + return None + try: + seconds = float(value) + if math.isfinite(seconds) and seconds >= 0: + return round(seconds * 1000) + except (ValueError, OverflowError): + pass + try: + from email.utils import parsedate_to_datetime + + parsed = parsedate_to_datetime(value) + return max(0, round(parsed.timestamp() * 1000 - now_ms)) + except (TypeError, ValueError, OverflowError): + return None + + +def _route_template(path: str) -> str: + segments = [] + for segment in path.split("?")[0].split("/"): + if len(segment) >= 8 and all(char in "0123456789abcdefABCDEF" for char in segment): + segments.append(":id") + elif len(segment) >= 10 and any(char.isdigit() for char in segment) and segment.replace("-", "").isalnum(): + segments.append(":id") + else: + segments.append(segment) + return "/".join(segments) + + +class SecApiClient: + def __init__( + self, + api_key: str | None = None, + bearer_token: str | None = None, + base_url: str = "https://api.secapi.ai", + api_version: str = "2026-03-19", + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> None: + self.api_key = api_key + self.bearer_token = bearer_token + self.base_url = base_url.rstrip("/") + self.api_version = api_version + self.retry = retry + self.telemetry = telemetry + self._urlopen = urlopen + self._circuit_breaker = _ClientCircuitBreaker( + DEFAULT_RETRY_CONFIG["circuit_breaker_failure_threshold"], + DEFAULT_RETRY_CONFIG["circuit_breaker_cooldown_ms"], + ) + self._telemetry_distinct_id = f"py-sdk-{id(self):x}-{int(time.time() * 1000):x}" + + def _headers(self) -> dict[str, str]: + headers = { + "accept": "application/json", + "content-type": "application/json", + "secapi-version": self.api_version, + "user-agent": f"secapi-client/{self.api_version}", + } + if self.bearer_token: + headers["authorization"] = f"Bearer {self.bearer_token}" + if self.api_key: + headers["x-api-key"] = self.api_key + return headers + + @property + def circuit_state(self) -> dict[str, Any]: + return self._circuit_breaker.snapshot() + + def _request_options_from_params(self, params: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]]: + params = dict(params or {}) + options: dict[str, Any] = {} + if "retry" in params: + options["retry"] = params.pop("retry") + if "telemetry" in params: + options["telemetry"] = params.pop("telemetry") + return params, options + + def _merge_retry_options(self, retry: bool | dict[str, Any] | None) -> tuple[bool, dict[str, Any], bool]: + global_retry = self.retry + if retry is False: + return True, {}, False + call_options = retry if isinstance(retry, dict) else {} + unsafe_opt_in = isinstance(retry, dict) and retry.get("enabled") is True + if global_retry is False and not unsafe_opt_in: + return True, {}, False + global_options = global_retry if isinstance(global_retry, dict) else {} + options = {**global_options, **call_options} + disabled = options.get("enabled") is False + return disabled, options, unsafe_opt_in + + def _merge_telemetry_options(self, telemetry: bool | dict[str, Any] | None) -> tuple[bool, dict[str, Any]]: + global_telemetry = self.telemetry + if global_telemetry is False or telemetry is False: + return True, {} + global_options = global_telemetry if isinstance(global_telemetry, dict) else {} + call_options = telemetry if isinstance(telemetry, dict) else {} + disabled = global_options.get("enabled") is False or call_options.get("enabled") is False + return disabled, {**global_options, **call_options} + + def _should_retry(self, method: str, error: Exception, retry_disabled: bool, unsafe_opt_in: bool) -> tuple[bool, int | None, str]: + if retry_disabled: + return False, None, "disabled" + if isinstance(error, SecApiError): + status = error.status + if status in NEVER_RETRY_STATUSES: + return False, status, "non_retryable_status" + if status not in RETRYABLE_STATUSES: + return False, status, "status" + if status == 429: + return True, status, "status" + if method in SAFE_RETRY_METHODS or unsafe_opt_in: + return True, status, "status" + return False, status, "method" + if method in SAFE_RETRY_METHODS or unsafe_opt_in: + return True, None, "network" + return False, None, "method" + + def _retry_delay_ms(self, attempt: int, retry_after_ms: int | None, retry_options: dict[str, Any]) -> int: + if retry_after_ms is not None: + return retry_after_ms + base_delay_ms = int(retry_options.get("base_delay_ms", DEFAULT_RETRY_CONFIG["base_delay_ms"])) + max_delay_ms = int(retry_options.get("max_delay_ms", DEFAULT_RETRY_CONFIG["max_delay_ms"])) + random_fn = retry_options.get("random", random.random) + return int(random_fn() * min(max_delay_ms, base_delay_ms * (2 ** attempt))) + + def _emit_retry_telemetry( + self, + *, + method: str, + path: str, + attempt: int, + max_retries: int, + delay_ms: int, + status: int | None, + reason: str, + elapsed_ms: float, + telemetry: bool | dict[str, Any] | None, + ) -> None: + disabled, options = self._merge_telemetry_options(telemetry) + if disabled: + return + capture_token = options.get("capture_token") + host = str(options.get("host", POSTHOG_CAPTURE_HOST)).rstrip("/") + opener = options.get("opener", urlopen) + payload = { + "api_key": capture_token, + "event": "client_retry_attempt", + "distinct_id": options.get("distinct_id", self._telemetry_distinct_id), + "properties": { + "sdk_language": "py", + "sdk_version": SDK_VERSION, + "method": method, + "route": _route_template(path), + "server_origin": self.base_url, + "attempt": attempt, + "max_retries": max_retries, + "delay_ms": delay_ms, + "status": status, + "reason": reason, + "elapsed_ms": round(elapsed_ms), + "$process_person_profile": False, + }, + } + + def send() -> None: + data = json.dumps(payload).encode("utf-8") + request = Request(f"{host}/capture/", data=data, method="POST", headers={"content-type": "application/json"}) + response = None + try: + response = opener(request, timeout=float(options.get("timeout", 1.0))) + except Exception: + return + finally: + close = getattr(response, "close", None) + if callable(close): + close() + + if options.get("sync") is True: + send() + else: + threading.Thread(target=send, daemon=True).start() + + def _request( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + params, param_options = self._request_options_from_params(params) + if retry is None: + retry = param_options.get("retry") + if telemetry is None: + telemetry = param_options.get("telemetry") + method = method.upper() + retry_disabled, retry_options, unsafe_opt_in = self._merge_retry_options(retry) + max_retries = max(0, int(retry_options.get("max_retries", DEFAULT_RETRY_CONFIG["max_retries"]))) + total_budget_ms = math.inf if retry_disabled else int(retry_options.get("total_budget_ms", DEFAULT_RETRY_CONFIG["total_budget_ms"])) + now = retry_options.get("now", lambda: time.time() * 1000) + sleep = retry_options.get("sleep", lambda delay_ms: time.sleep(delay_ms / 1000)) + started_at = float(now()) + circuit_eligible = not retry_disabled + if circuit_eligible: + self._circuit_breaker.before_request(started_at) + + filtered_params = { + key: value + for key, value in params.items() + if value is not None and value != "" + } + query = f"?{urlencode(filtered_params, doseq=True)}" if filtered_params else "" + payload = json.dumps(body).encode("utf-8") if body is not None else None + + last_error: Exception | None = None + for attempt in range(max_retries + 1): + elapsed_ms = float(now()) - started_at + remaining_ms = total_budget_ms - elapsed_ms + if remaining_ms <= 0: + if circuit_eligible and last_error is not None: + self._circuit_breaker.record_failure(float(now())) + if last_error is not None: + raise last_error + raise SecApiError(0, {"code": "client_retry_budget_exceeded", "message": "SEC API request exceeded retry budget"}) + + headers = self._headers() + idempotency_key = retry_options.get("idempotency_key") + if idempotency_key: + headers["Idempotency-Key"] = str(idempotency_key) + request = Request(f"{self.base_url}{path}{query}", data=payload, method=method, headers=headers) + + try: + timeout_seconds = None if not math.isfinite(remaining_ms) else max(0.001, remaining_ms / 1000) + response_context = self._urlopen(request) if timeout_seconds is None else self._urlopen(request, timeout=timeout_seconds) + with response_context as response: + if response.status == 204: + if circuit_eligible: + self._circuit_breaker.record_success() + return {} + raw = response.read().decode("utf-8") + if not raw.strip(): + if circuit_eligible: + self._circuit_breaker.record_success() + return {} + data = json.loads(raw) + if circuit_eligible: + self._circuit_breaker.record_success() + return data + except HTTPError as error: + raw_payload = error.read().decode("utf-8", errors="replace") + try: + error_payload = json.loads(raw_payload) + except json.JSONDecodeError: + error_payload = {"message": raw_payload or error.reason} + last_error = SecApiError( + status=error.code, + payload=error_payload, + retry_after_ms=_parse_retry_after_ms(error.headers.get("Retry-After"), float(now())), + ) + except (URLError, TimeoutError, OSError) as error: + last_error = error + + retryable, status, reason = self._should_retry(method, last_error, retry_disabled, unsafe_opt_in) + if not retryable or attempt >= max_retries: + if retryable and circuit_eligible: + self._circuit_breaker.record_failure(float(now())) + raise last_error + delay_ms = self._retry_delay_ms(attempt, last_error.retry_after_ms if isinstance(last_error, SecApiError) and status == 429 else None, retry_options) + elapsed_after_attempt_ms = float(now()) - started_at + if elapsed_after_attempt_ms + delay_ms > total_budget_ms: + if circuit_eligible: + self._circuit_breaker.record_failure(float(now())) + raise last_error + self._emit_retry_telemetry( + method=method, + path=path, + attempt=attempt + 1, + max_retries=max_retries, + delay_ms=delay_ms, + status=status, + reason=reason, + elapsed_ms=elapsed_after_attempt_ms, + telemetry=telemetry, + ) + sleep(delay_ms) + + raise last_error or SecApiError(0, {"code": "client_request_failed", "message": "SEC API request failed"}) + + def health( + self, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/healthz", retry=retry, telemetry=telemetry) + + def me(self) -> dict[str, Any]: + return self._request("GET", "/v1/me") + + def org(self) -> dict[str, Any]: + return self._request("GET", "/v1/org") + + def billing(self) -> dict[str, Any]: + return self._request("GET", "/v1/billing") + + def dashboard_overview(self) -> dict[str, Any]: + return self._request("GET", "/v1/dashboard/overview") + + def list_api_keys(self) -> dict[str, Any]: + return self._request("GET", "/v1/api_keys") + + def create_api_key( + self, + *, + label: str | None = None, + scopes: list[str] | None = None, + livemode: bool | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/api_keys", body={"label": label, "scopes": scopes, "livemode": livemode}, retry=retry, telemetry=telemetry) + + def delete_api_key( + self, + key_id: str, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("DELETE", f"/v1/api_keys/{key_id}", retry=retry, telemetry=telemetry) + + def create_agent_bootstrap_token( + self, + *, + label: str | None = None, + scopes: list[str] | None = None, + ttl_seconds: int | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + "/v1/agent/bootstrap_tokens", + body={"label": label, "scopes": scopes, "ttlSeconds": ttl_seconds}, + retry=retry, + telemetry=telemetry, + ) + + def bootstrap_agent( + self, + *, + token: str, + label: str | None = None, + scopes: list[str] | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/agent/bootstrap", body={"token": token, "label": label, "scopes": scopes}, retry=retry, telemetry=telemetry) + + def quote_billing( + self, + *, + plan_key: str | None = None, + meter_class: str | None = None, + path: str | None = None, + method: str | None = None, + units: int | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + "/v1/billing/quote", + body={"planKey": plan_key, "meterClass": meter_class, "path": path, "method": method, "units": units}, + retry=retry, + telemetry=telemetry, + ) + + def update_billing_budget( + self, + *, + spend_cap_cents: int | None = None, + soft_cap_cents: int | None = None, + approval_threshold_cents: int | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request( + "PUT", + "/v1/billing/budget", + body={ + "spendCapCents": spend_cap_cents, + "softCapCents": soft_cap_cents, + "approvalThresholdCents": approval_threshold_cents, + }, + retry=retry, + telemetry=telemetry, + ) + + def create_checkout_session( + self, + *, + plan_key: str, + success_url: str | None = None, + cancel_url: str | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/billing/checkout", body={"planKey": plan_key, "successUrl": success_url, "cancelUrl": cancel_url}, retry=retry, telemetry=telemetry) + + def create_billing_portal_session( + self, + *, + return_url: str | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/billing/portal", body={"returnUrl": return_url}, retry=retry, telemetry=telemetry) + + def usage(self) -> dict[str, Any]: + return self._request("GET", "/v1/usage") + + def limits(self) -> dict[str, Any]: + return self._request("GET", "/v1/limits") + + def events( + self, + *, + kind: str | None = None, + type: str | None = None, + request_id: str | None = None, + since: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/v1/events", params={"kind": kind, "type": type, "requestId": request_id, "since": since, "limit": limit}) + + def export_events( + self, + *, + kind: str | None = None, + type: str | None = None, + request_id: str | None = None, + since: str | None = None, + limit: int | None = None, + format: str = "json", + ) -> dict[str, Any]: + return self._request( + "GET", + "/v1/events/export", + params={"kind": kind, "type": type, "requestId": request_id, "since": since, "limit": limit, "format": format}, + ) + + def request_diagnostics(self, request_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/diagnostics/requests/{request_id}") + + def list_admin_organizations(self, *, q: str | None = None, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/admin/orgs", params={"q": q, "limit": limit}) + + def get_admin_organization(self, org_id: str, *, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", f"/v1/admin/orgs/{org_id}", params={"limit": limit}) + + def get_admin_request_diagnostics(self, org_id: str, request_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/admin/orgs/{org_id}/requests/{request_id}") + + def get_admin_delivery_summary( + self, + org_id: str, + *, + since: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request( + "GET", + f"/v1/admin/orgs/{org_id}/deliveries/summary", + params={"since": since, "limit": limit}, + ) + + def delivery_summary(self, *, since: str | None = None, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/diagnostics/deliveries/summary", params={"since": since, "limit": limit}) + + def observability(self) -> dict[str, Any]: + return self._request("GET", "/v1/observability") + + def export_observability(self, *, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/observability/export", params={"limit": limit}) + + def list_webhook_endpoints(self) -> dict[str, Any]: + return self._request("GET", "/v1/webhook_endpoints") + + def create_webhook_endpoint( + self, + *, + destination_url: str, + description: str | None = None, + subscribed_event_types: list[str] | None = None, + livemode: bool | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + "/v1/webhook_endpoints", + body={ + "destinationUrl": destination_url, + "description": description, + "subscribedEventTypes": subscribed_event_types, + "livemode": livemode, + }, + retry=retry, + telemetry=telemetry, + ) + + def rotate_webhook_endpoint_secret( + self, + webhook_id: str, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", f"/v1/webhook_endpoints/{webhook_id}/rotate_secret", retry=retry, telemetry=telemetry) + + def list_webhook_deliveries(self, webhook_id: str, *, event_id: str | None = None, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", f"/v1/webhook_endpoints/{webhook_id}/deliveries", params={"eventId": event_id, "limit": limit}) + + def replay_webhook_delivery( + self, + webhook_id: str, + delivery_id: str, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", f"/v1/webhook_endpoints/{webhook_id}/deliveries/{delivery_id}/replay", retry=retry, telemetry=telemetry) + + def list_stream_subscriptions(self) -> dict[str, Any]: + return self._request("GET", "/v1/stream_subscriptions") + + def create_stream_subscription( + self, + *, + description: str | None = None, + event_types: list[str] | None = None, + transport: str | None = None, + livemode: bool | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + "/v1/stream_subscriptions", + body={ + "description": description, + "eventTypes": event_types, + "transport": transport, + "livemode": livemode, + }, + retry=retry, + telemetry=telemetry, + ) + + def stream_events(self, stream_id: str, *, cursor: str | None = None, type: str | None = None, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", f"/v1/stream_subscriptions/{stream_id}/events", params={"cursor": cursor, "type": type, "limit": limit}) + + def resolve_entity(self, *, ticker: str | None = None, cik: str | None = None, name: str | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/entities/resolve", params={"ticker": ticker, "cik": cik, "name": name}) + + def search_entities(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/entities", params=params) + + def search_filings(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/filings", params=params) + + def filing_by_accession(self, accession_number: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/filings/{accession_number}", params=params) + + def latest_filing(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/filings/latest", params=params) + + def render_latest_filing(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/filings/latest/render", params=params) + + def latest_section(self, section_key: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/filings/latest/sections/{section_key}", params=params) + + def filing_section_by_accession(self, accession_number: str, section_key: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/filings/{accession_number}/sections/{section_key}", params=params) + + def search_sections(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/sections/search", params=params) + + def offerings(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/offerings", params=params) + + def market_calendar(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/market/calendar", params=params) + + def market_snapshots(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/market/snapshots", params=params) + + def market_bars(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/market/bars", params=params) + + def market_corporate_actions(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/market/corporate-actions", params=params) + + def market_reference(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/market/reference", params=params) + + def market_estimates(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/market/estimates", params=params) + + def news_stories(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/news/stories", params=params) + + def macro_indicators(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/macro/indicators", params=params) + + def macro_releases(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/macro/releases", params=params) + + def macro_calendar(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/macro/calendar", params=params) + + def macro_forecasts(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/macro/forecasts", params=params) + + def macro_high_signal_pack(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/macro/high-signal-pack", params=params) + + def macro_regimes(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/macro/regimes", params=params) + + def factor_catalog(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/catalog", params=params) + + def factor_returns(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/returns", params=params) + + def factor_history(self, factor_key: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/factors/history/{quote(factor_key, safe='')}", params=params) + + def factor_sparklines(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/sparklines", params=params) + + def factor_returns_intraday(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/returns/intraday", params=params) + + def factor_dashboard(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/dashboard", params=params) + + def factor_regime_performance(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/regime-performance", params=params) + + def factor_correlations(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/correlations", params=params) + + def factor_screen(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/screen", params=params) + + def factor_extreme_moves(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/extreme-moves", params=params) + + def factor_extreme_pairs(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/extreme-pairs", params=params) + + def factor_valuations(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/valuations", params=params) + + def factor_valuation_stocks(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/valuations/stocks", params=params) + + def factor_exposures(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/exposures", params=params) + + def stock_loadings(self, ticker: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/stocks/{ticker}/loadings", params=params) + + def factor_decomposition(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/decomposition", params=params) + + def factor_related_stocks(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/related-stocks", params=params) + + def factor_similarity_pack(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/similarity-pack", params=params) + + def factor_pairs(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/pairs", params=params) + + def factor_pair_history(self, f1: str, f2: str, **params: Any) -> dict[str, Any]: + return self._request( + "GET", + f"/v1/factors/pair-history/{quote(f1, safe='')}/{quote(f2, safe='')}", + params=params, + ) + + def factor_bulk_download(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/factors/bulk-download", params=params) + + def factor_custom( + self, + body: dict[str, Any], + params: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/factors/custom", params=params, body=body, retry=retry, telemetry=telemetry) + + def portfolio_analyze( + self, + body: dict[str, Any], + params: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/portfolio/analyze", params=params, body=body, retry=retry, telemetry=telemetry) + + def portfolio_attribution( + self, + body: dict[str, Any], + params: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/portfolio/attribution", params=params, body=body, retry=retry, telemetry=telemetry) + + def model_portfolio_factor_view(self, portfolio_id: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/model-portfolios/{quote(portfolio_id, safe='')}/factor-view", params=params) + + def model_factor_analysis( + self, + body: dict[str, Any], + params: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/models/factor-analysis", params=params, body=body, retry=retry, telemetry=telemetry) + + def portfolio_optimize( + self, + body: dict[str, Any], + params: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/portfolio/optimize", params=params, body=body, retry=retry, telemetry=telemetry) + + def portfolio_hedge( + self, + body: dict[str, Any], + params: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/portfolio/hedge", params=params, body=body, retry=retry, telemetry=telemetry) + + def portfolio_stress_test( + self, + body: dict[str, Any], + params: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/portfolio/stress-test", params=params, body=body, retry=retry, telemetry=telemetry) + + def strategy_factor_rotation( + self, + body: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/strategies/factor-rotation", body=body or {}, retry=retry, telemetry=telemetry) + + def strategy_regime_screen( + self, + body: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/strategies/regime-screen", body=body or {}, retry=retry, telemetry=telemetry) + + def intelligence_security(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/intelligence/security", params=params) + + def intelligence_company(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/intelligence/company", params=params) + + def intelligence_earnings_preview(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/intelligence/earnings-preview", params=params) + + def intelligence_country_report( + self, + body: dict[str, Any] | None = None, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/intelligence/country-report", body=body or {}, retry=retry, telemetry=telemetry) + + def intelligence_portfolio( + self, + body: dict[str, Any], + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/intelligence/portfolio", body=body, retry=retry, telemetry=telemetry) + + def intelligence_watchlist( + self, + body: dict[str, Any], + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + **params: Any, + ) -> dict[str, Any]: + return self._request("POST", "/v1/intelligence/watchlist", params=params, body=body, retry=retry, telemetry=telemetry) + + def intelligence_query( + self, + body: dict[str, Any], + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/intelligence/query", body=body, retry=retry, telemetry=telemetry) + + def intelligence_footnotes_query( + self, + body: dict[str, Any], + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/intelligence/footnotes/query", body=body, retry=retry, telemetry=telemetry) + + def market_indices(self, *, include_inventory: bool | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/market/indices", params={"include_inventory": include_inventory}) + + def index_constituents(self, *, index: str | None = None, index_code: str | None = None, cursor: str | None = None, limit: int | None = None) -> dict[str, Any]: + return self._request( + "GET", + "/v1/market/indices/constituents", + params={"index": index, "index_code": index_code, "cursor": cursor, "limit": limit}, + ) + + def volatility_signal(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/signals/volatility", params=params) + + def facts(self, *, tag: str, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/facts", params={"tag": tag, **params}) + + def statements(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/statements", params=params) + + def all_statements(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/statements/all", params=params) + + def statement_by_key(self, statement_key: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/statements/{statement_key}", params=params) + + def company_income_statements( + self, + *, + ticker: str, + period: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/v1/companies/income-statements", params={"ticker": ticker, "period": period, "limit": limit}) + + def company_balance_sheets( + self, + *, + ticker: str, + period: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/v1/companies/balance-sheets", params={"ticker": ticker, "period": period, "limit": limit}) + + def company_cash_flow_statements( + self, + *, + ticker: str, + period: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/v1/companies/cash-flow-statements", params={"ticker": ticker, "period": period, "limit": limit}) + + def company_financials( + self, + *, + ticker: str, + period: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/v1/companies/financials", params={"ticker": ticker, "period": period, "limit": limit}) + + def company_ratios( + self, + *, + ticker: str, + period: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/v1/companies/ratios", params={"ticker": ticker, "period": period, "limit": limit}) + + def company_resolve(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/companies/resolve", params=params) + + def company_search(self, *, q: str, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/companies/search", params={"q": q, "limit": limit}) + + def list_13f_filings( + self, + *, + cik: str, + limit: int | None = None, + since: str | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + """List 13F filings for a CIK. + + Pairs with `latest_13f` (which returns the *holdings* of one + specific filing) — this returns the *list* of filings available + for a CIK so callers can pick a specific (reportDate, filingDate) + before fetching holdings. Useful for any consumer that wants to + iterate over a filer's quarterly history or detect newly-landed + filings via the `since` filter. + + Args: + cik: 10-digit zero-padded CIK (e.g. "0001067983" for Berkshire). + limit: Max filings to return (server default applies if None). + since: Optional ISO-8601 timestamp; when set, returns only + filings accepted by SEC at or after this timestamp. + Supports incremental polling for newsletters/alerts + consumers without scanning the full history each tick. + + **Server-side pairing:** the `since=` filter is honoured + by datastream-api as of the release containing + omni-datastream PR #539 (paired with this SDK PR). + Older servers silently ignore unknown query parameters + and return the full unfiltered history, so callers + should always client-side dedupe by `accessionNumber` + if they need strict incremental semantics during the + rollout window. + + Returns: + Raw JSON envelope: `{"object": "list", "data": [{...}], ...}`. + """ + return self._request( + "GET", + "/v1/owners/13f/filings", + params={"cik": cik, "limit": limit, "since": since}, + retry=retry, + telemetry=telemetry, + ) + + def latest_13f( + self, + *, + cik: str, + report_date: str | None = None, + filing_date: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + return self._request( + "GET", + "/v1/owners/13f", + params={ + "cik": cik, + "reportDate": report_date, + "filingDate": filing_date, + "limit": limit, + }, + ) + + def compare_13f( + self, + *, + cik: str, + limit: int | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/owners/13f/compare", body={"cik": cik, "limit": limit}, retry=retry, telemetry=telemetry) + + def insiders(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/insiders", params=params) + + def compensation(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/compensation", params=params) + + def compare_compensation( + self, + *, + ticker: str | None = None, + cik: str | None = None, + limit: int | None = None, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/compensation/compare", body={"ticker": ticker, "cik": cik, "limit": limit}, retry=retry, telemetry=telemetry) + + def create_artifact( + self, + body: dict[str, Any], + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/artifacts", body=body, retry=retry, telemetry=telemetry) + + def list_artifacts(self, *, kind: str | None = None, status: str | None = None, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/artifacts", params={"kind": kind, "status": status, "limit": limit}) + + def artifact_summary(self) -> dict[str, Any]: + return self._request("GET", "/v1/artifacts/summary") + + def get_artifact(self, artifact_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/artifacts/{artifact_id}") + + def artifact_manifest(self, artifact_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/artifacts/{artifact_id}/manifest") + + def export_artifact(self, artifact_id: str, *, format: str = "json") -> dict[str, Any]: + return self._request("GET", f"/v1/artifacts/{artifact_id}/export", params={"format": format}) + + def download_artifact(self, artifact_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/artifacts/{artifact_id}/download") + + def reconcile_artifact( + self, + artifact_id: str, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", f"/v1/artifacts/{artifact_id}/reconcile", retry=retry, telemetry=telemetry) + + def analytics_query( + self, + body: dict[str, Any], + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/v1/analytics/query", body=body, retry=retry, telemetry=telemetry) + + def list_traces(self, *, ids: str | list[str]) -> dict[str, Any]: + joined = ",".join(ids) if isinstance(ids, list) else ids + return self._request("GET", "/v1/traces", params={"ids": joined}) + + def get_trace(self, trace_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/traces/{trace_id}") + + def segmented_revenues(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/statements/segmented-revenues", params=params) + + def segmented_facts(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/statements/segmented-facts", params=params) + + def pension_benefit_schedule(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/filings/pension-benefit-schedule", params=params) + + def share_float(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/statements/share-float", params=params) + + def board_composition(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/board", params=params) + + def nport_holdings(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/funds/nport/holdings", params=params) + + def latest_risk_categories(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/filings/latest/risk-categories", params=params) + + def beneficial_ownership_reports(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/owners/13d-13g", params=params) + + def institutional_ownership_extract(self, *, cik: str, year: int, quarter: int, limit: int | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/owners/institutional/extract", params={"cik": cik, "year": year, "quarter": quarter, "limit": limit}) + + def ma_events(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/events/ma", params=params) + + def enforcement_actions(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/events/enforcement", params=params) + + def voting_results_events(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/events/voting-results", params=params) + + # Dilution endpoints (OMNI-3091). All accept ?view=agent except + # dilution_coverage, whose route returns a small rollup with no agent shape. + def dilution_events(self, **params: Any) -> dict[str, Any]: + # The route's parseQueryBool only matches lowercase "true"/"false"; Python + # bools serialize as "True"/"False" via urlencode, so coerce here. + if "is_atm" in params and isinstance(params["is_atm"], bool): + params["is_atm"] = "true" if params["is_atm"] else "false" + return self._request("GET", "/v1/dilution/events", params=params) + + def dilution_event_detail(self, event_id: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/dilution/events/{quote(event_id, safe='')}", params=params) + + def dilution_warrants(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/warrants", params=params) + + def dilution_convertibles(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/convertibles", params=params) + + def dilution_rofr(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/rofr", params=params) + + def dilution_lockups(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/lockups", params=params) + + def dilution_cash_position(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/cash-position", params=params) + + def dilution_corporate_actions(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/corporate-actions", params=params) + + def dilution_nasdaq_compliance(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/nasdaq-compliance", params=params) + + def dilution_ratings(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/ratings", params=params) + + def dilution_reverse_splits(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/reverse-splits", params=params) + + def dilution_score(self, *, ticker: str, view: ResponseView | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/score", params={"ticker": ticker, "view": view}) + + def dilution_share_float_history(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/share-float-history", params=params) + + def dilution_coverage(self, *, ticker: str | None = None) -> dict[str, Any]: + return self._request("GET", "/v1/dilution/coverage", params={"ticker": ticker}) + + def form_144_filings(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/forms/144", params=params) + + def company_subsidiaries(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/companies/subsidiaries", params=params) + + def earnings_transcripts(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/earnings/transcripts", params=params) + + def mcp_info( + self, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("GET", "/mcp", retry=retry, telemetry=telemetry) + + def mcp( + self, + request: dict[str, Any], + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request("POST", "/mcp", body=request, retry=retry, telemetry=telemetry) + + +SecApiClient = SecApiClient +SecApiError = SecApiError diff --git a/tests/test_retry.py b/tests/test_retry.py index 029ecea..8b58585 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -4,7 +4,6 @@ from email.message import Message from urllib.error import HTTPError, URLError -from omni_datastream_py import OmniDatastreamClient, OmniDatastreamError from secapi_client import SecApiClient, SecApiError @@ -51,13 +50,9 @@ def retry_harness(**overrides): class RetryTests(unittest.TestCase): - def test_secapi_client_import_is_canonical_alias(self): - self.assertIs(SecApiClient, OmniDatastreamClient) - self.assertIs(SecApiError, OmniDatastreamError) - - def test_sends_secapi_version_header_and_legacy_alias(self): + def test_sends_secapi_version_header(self): captured = [] - client = OmniDatastreamClient(api_version="2026-05-20", retry=False, telemetry=False) + client = SecApiClient(api_version="2026-05-20", retry=False, telemetry=False) def opener(request, timeout=None): captured.append(dict(request.header_items())) @@ -68,12 +63,12 @@ def opener(request, timeout=None): self.assertEqual(client.health(), {"ok": True}) headers = {key.lower(): value for key, value in captured[0].items()} self.assertEqual(headers["secapi-version"], "2026-05-20") - self.assertEqual(headers["omni-version"], "2026-05-20") + self.assertNotIn("-".join(["omni", "version"]), headers) def test_retries_safe_get_on_5xx(self): attempts = [] retry, delays = retry_harness() - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(request, timeout=None): attempts.append((request, timeout)) @@ -90,7 +85,7 @@ def opener(request, timeout=None): def test_retries_safe_get_on_network_error(self): attempts = 0 retry, delays = retry_harness(random=lambda: 0.5) - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -108,7 +103,7 @@ def opener(_request, timeout=None): def test_does_not_retry_nonretryable_4xx(self): attempts = 0 retry, _delays = retry_harness() - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -117,7 +112,7 @@ def opener(_request, timeout=None): client._urlopen = opener - with self.assertRaises(OmniDatastreamError) as ctx: + with self.assertRaises(SecApiError) as ctx: client.health() self.assertEqual(ctx.exception.status, 400) self.assertEqual(attempts, 1) @@ -126,7 +121,7 @@ def test_per_call_opt_out(self): attempts = 0 timeouts = [] retry, _delays = retry_harness() - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout="not-passed"): nonlocal attempts @@ -136,7 +131,7 @@ def opener(_request, timeout="not-passed"): client._urlopen = opener - with self.assertRaises(OmniDatastreamError): + with self.assertRaises(SecApiError): client.health(retry=False) self.assertEqual(attempts, 1) self.assertEqual(timeouts, ["not-passed"]) @@ -144,7 +139,7 @@ def opener(_request, timeout="not-passed"): def test_unsafe_post_503_requires_opt_in(self): attempts = 0 retry, _delays = retry_harness() - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -153,14 +148,14 @@ def opener(_request, timeout=None): client._urlopen = opener - with self.assertRaises(OmniDatastreamError): + with self.assertRaises(SecApiError): client.create_artifact({"kind": "audit"}) self.assertEqual(attempts, 1) def test_per_call_opt_in_overrides_global_retry_false(self): attempts = 0 seen_keys = [] - client = OmniDatastreamClient(retry=False, telemetry=False) + client = SecApiClient(retry=False, telemetry=False) def opener(request, timeout=None): nonlocal attempts @@ -183,7 +178,7 @@ def opener(request, timeout=None): def test_body_only_methods_accept_per_call_retry_options(self): attempts = 0 seen_keys = [] - client = OmniDatastreamClient(retry=False, telemetry=False) + client = SecApiClient(retry=False, telemetry=False) def opener(request, timeout=None): nonlocal attempts @@ -207,7 +202,7 @@ def test_watchlist_accepts_explicit_retry_options_and_params(self): attempts = 0 seen_keys = [] seen_urls = [] - client = OmniDatastreamClient(retry=False, telemetry=False) + client = SecApiClient(retry=False, telemetry=False) def opener(request, timeout=None): nonlocal attempts @@ -233,7 +228,7 @@ def opener(request, timeout=None): def test_negative_max_retries_still_attempts_once(self): attempts = 0 retry, _delays = retry_harness(max_retries=-1) - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -242,14 +237,14 @@ def opener(_request, timeout=None): client._urlopen = opener - with self.assertRaises(OmniDatastreamError): + with self.assertRaises(SecApiError): client.health() self.assertEqual(attempts, 1) def test_429_retries_unsafe_method_and_honors_retry_after(self): attempts = 0 retry, delays = retry_harness() - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -267,7 +262,7 @@ def opener(_request, timeout=None): def test_429_retry_after_infinity_falls_back_to_backoff(self): attempts = 0 retry, delays = retry_harness() - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -286,7 +281,7 @@ def test_unsafe_429_terminal_failures_open_circuit(self): now = 0 attempts = 0 retry, _delays = retry_harness(max_retries=0, now=lambda: now) - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -296,11 +291,11 @@ def opener(_request, timeout=None): client._urlopen = opener for _ in range(5): - with self.assertRaises(OmniDatastreamError) as ctx: + with self.assertRaises(SecApiError) as ctx: client.create_artifact({"kind": "audit"}) self.assertEqual(ctx.exception.status, 429) self.assertEqual(client.circuit_state["state"], "open") - with self.assertRaises(OmniDatastreamError) as ctx: + with self.assertRaises(SecApiError) as ctx: client.create_artifact({"kind": "audit"}) self.assertEqual(ctx.exception.payload["code"], "client_circuit_open") self.assertEqual(attempts, 5) @@ -309,7 +304,7 @@ def test_mcp_retries_only_with_explicit_opt_in_and_idempotency_key(self): attempts = 0 seen_keys = [] retry, _delays = retry_harness() - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(request, timeout=None): nonlocal attempts @@ -330,7 +325,7 @@ def test_circuit_opens_and_half_opens_after_cooldown(self): now = 0 attempts = 0 retry, _delays = retry_harness(max_retries=0, now=lambda: now) - client = OmniDatastreamClient(retry=retry, telemetry=False) + client = SecApiClient(retry=retry, telemetry=False) def opener(_request, timeout=None): nonlocal attempts @@ -342,10 +337,10 @@ def opener(_request, timeout=None): client._urlopen = opener for _ in range(5): - with self.assertRaises(OmniDatastreamError): + with self.assertRaises(SecApiError): client.health() self.assertEqual(client.circuit_state["state"], "open") - with self.assertRaises(OmniDatastreamError) as ctx: + with self.assertRaises(SecApiError) as ctx: client.health() self.assertEqual(ctx.exception.payload["code"], "client_circuit_open") self.assertEqual(attempts, 5) @@ -366,7 +361,7 @@ def telemetry_opener(request, timeout=None): telemetry_responses.append(response) return response - client = OmniDatastreamClient( + client = SecApiClient( api_key="ods_secret", retry=retry, telemetry={"capture_token": "phc_test", "distinct_id": "sdk-test", "opener": telemetry_opener, "sync": True}, @@ -390,7 +385,7 @@ def opener(_request, timeout=None): self.assertEqual(payload["api_key"], "phc_test") self.assertEqual(payload["distinct_id"], "sdk-test") self.assertEqual(payload["properties"]["sdk_language"], "py") - self.assertEqual(payload["properties"]["sdk_version"], "0.3.1") + self.assertEqual(payload["properties"]["sdk_version"], "0.4.1") self.assertEqual(payload["properties"]["route"], "/v1/filings/latest") self.assertEqual(payload["properties"]["status"], 502) self.assertFalse(payload["properties"]["$process_person_profile"]) @@ -404,7 +399,7 @@ class List13fFilingsTests(unittest.TestCase): def test_list_13f_filings_routes_to_endpoint(self): seen_urls = [] - client = OmniDatastreamClient(retry=False, telemetry=False) + client = SecApiClient(retry=False, telemetry=False) def opener(request, timeout=None): seen_urls.append(request.full_url) @@ -431,7 +426,7 @@ def test_list_13f_filings_with_since_filter(self): # PR; the SDK passes the param transparently so existing callers # don't need to wait for both PRs to land in lockstep. seen_urls = [] - client = OmniDatastreamClient(retry=False, telemetry=False) + client = SecApiClient(retry=False, telemetry=False) def opener(request, timeout=None): seen_urls.append(request.full_url) @@ -443,7 +438,7 @@ def opener(request, timeout=None): def test_list_13f_filings_omits_none_params(self): seen_urls = [] - client = OmniDatastreamClient(retry=False, telemetry=False) + client = SecApiClient(retry=False, telemetry=False) def opener(request, timeout=None): seen_urls.append(request.full_url) @@ -456,5 +451,64 @@ def opener(request, timeout=None): self.assertNotIn("since=", seen_urls[0]) +class FactorParityWrapperTests(unittest.TestCase): + def test_factor_parity_wrappers_route_to_launch_paths(self): + seen = [] + client = SecApiClient(retry=False, telemetry=False) + + def opener(request, timeout=None): + body = request.data.decode("utf-8") if request.data else "" + seen.append((request.get_method(), request.full_url, body)) + return FakeResponse(body={"ok": True}) + + client._urlopen = opener + + client.factor_history("MKT/US", range="1y", response_mode="compact") + client.factor_sparklines(factors=["MOMENTUM", "VALUE"], points=32) + client.factor_extreme_moves(category="style", side="both") + client.factor_extreme_pairs(factors=["MOMENTUM", "VALUE"], sort="abs_spread_return") + client.factor_valuations(side="tailwind") + client.factor_valuation_stocks(factor="VALUE", sort="score") + client.factor_pairs(factor1="MOMENTUM", factor2="VALUE") + client.factor_pair_history("MOM/US", "VAL/US", response_mode="compact") + client.factor_bulk_download(factors=["MOMENTUM"], include="series") + client.factor_custom({"symbol": "AAPL"}, params={"response_mode": "compact"}) + client.portfolio_attribution({"holdings": [{"symbol": "AAPL", "weight": 1}]}, params={"response_mode": "compact"}) + client.model_factor_analysis({"model": {"id": "draft"}, "holdings": [{"symbol": "AAPL", "weight": 1}]}, params={"response_mode": "compact"}) + client.portfolio_hedge( + {"holdings": [{"symbol": "AAPL", "weight": 1}], "constraints": {"maxHedges": 1}}, + params={"response_mode": "compact"}, + ) + + paths = [url.split("https://api.secapi.ai", 1)[1].split("?", 1)[0] for _method, url, _body in seen] + self.assertEqual( + paths, + [ + "/v1/factors/history/MKT%2FUS", + "/v1/factors/sparklines", + "/v1/factors/extreme-moves", + "/v1/factors/extreme-pairs", + "/v1/factors/valuations", + "/v1/factors/valuations/stocks", + "/v1/factors/pairs", + "/v1/factors/pair-history/MOM%2FUS/VAL%2FUS", + "/v1/factors/bulk-download", + "/v1/factors/custom", + "/v1/portfolio/attribution", + "/v1/models/factor-analysis", + "/v1/portfolio/hedge", + ], + ) + self.assertIn("response_mode=compact", seen[0][1]) + self.assertIn("factors=MOMENTUM", seen[1][1]) + self.assertIn("factors=VALUE", seen[1][1]) + self.assertIn("include=series", seen[8][1]) + self.assertIn("response_mode=compact", seen[9][1]) + self.assertIn("response_mode=compact", seen[12][1]) + self.assertEqual([method for method, _url, _body in seen[9:]], ["POST", "POST", "POST", "POST"]) + self.assertNotIn("response_mode", seen[12][2]) + self.assertIn("constraints", seen[12][2]) + + if __name__ == "__main__": unittest.main()