diff --git a/README.md b/README.md index 685bb5e..e86d282 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,47 @@ calendar = client.market_calendar(market="XNYS", duration=3) vol = client.volatility_signal(ticker="AAPL") ``` +### Special Situations + +The public Special Situations namespace exposes SEC-derived customer-facing data only. It does not expose internal datasets or provider-private fields. + +```python +# Browse durable situations and inspect the filing timeline. +situations = client.situations.list( + types=["merger", "tender_offer"], + statuses=["announced", "pending"], + tickers=["AAPL", "MSFT"], + limit=10, +) + +detail = client.situations.get("sit_abc123") +filings = client.situations.filings("sit_abc123", limit=25) +summary = client.situations.summary("sit_abc123") +markdown = client.situations.export("sit_abc123") +pack = client.situations.underwrite("sit_abc123") + +# Feed, calendar, analytics, and form-triggered discovery. +feed = client.situations.feed(types=["merger"], since="2026-07-01T00:00:00Z") +calendar = client.situations.calendar(date_types=["vote", "expiry"], days=30) +stats = client.situations.stats(window="30d") +performance = client.situations.performance(types=["merger"], group_by="subtype") +form_matches = client.situations.by_form("SC 13D", limit=25) + +# Paid immutable weekly issue archive. +issues = client.situations.issues(limit=4) +issue = client.situations.issue("special-situations-digest-22-2026-07-05") +``` + +The immutable issue archive endpoints, `GET /v1/situations/issues` and +`GET /v1/situations/issues/{issue}`, plus the underwriting-pack endpoint, +`GET /v1/situations/{id}/underwriting-pack`, depend on pending datastream PR +[#1363](https://github.com/autonomous-computer/omni-datastream/pull/1363) +deployment. They are documented here so SDK users can write against the public +contract, but production availability depends on that backend deployment. The +underwriting pack is limited to public Special Situations data and does not +include internal research notes, TIKR/provider-private data, or other +non-customer datasets. + ### Offerings and M&A ```python @@ -381,6 +422,7 @@ The Python SDK covers the full REST surface including: - Factor catalog, returns, history, valuations, exposures, decomposition, pairs, and custom discovery - Portfolio factor attribution, hedging, optimization, stress testing, and model factor analysis - Offerings, market calendar, and volatility signals +- Special Situations feed, calendar, stats, performance, and paid immutable issue archive - Ownership, insiders, and compensation data - Institutional holdings (13F) - Enforcement actions and M&A events diff --git a/pyproject.toml b/pyproject.toml index 1a9cb23..e3a2bb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "secapi-client" -version = "1.0.1" +version = "1.1.0" description = "Python SDK for SEC API" requires-python = ">=3.11" readme = "README.md" diff --git a/secapi_client/client.py b/secapi_client/client.py index 6f02136..76c54d4 100644 --- a/secapi_client/client.py +++ b/secapi_client/client.py @@ -17,7 +17,7 @@ #: essentials+citation-pointers shape on supported endpoints. ResponseView = Literal["default", "compact", "agent"] -SDK_VERSION = "1.0.1" +SDK_VERSION = "1.1.0" SDK_USER_AGENT = f"secapi-python/{SDK_VERSION}" POSTHOG_CAPTURE_HOST = "https://us.i.posthog.com" DEFAULT_TIMEOUT_SECONDS = 30.0 @@ -262,6 +262,22 @@ def _positive_int_or_none(value: int | None) -> int | None: return max(0, int(value)) +def _comma_param(value: Any) -> Any: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple, set)): + return ",".join(str(item) for item in value if item is not None and str(item) != "") + return value + + +def _situation_params(params: dict[str, Any]) -> dict[str, Any]: + # Retry and telemetry are client controls consumed by _request, never API query parameters. + return { + key: value if key in {"retry", "telemetry"} else _comma_param(value) + for key, value in params.items() + } + + class SecApiClient: def __init__( self, @@ -339,6 +355,22 @@ def __init__( bulk_download="factor_bulk_download", custom="factor_custom", ) + self.situations = _ClientNamespace( + self, + list="list_situations", + issues="situation_issues", + issue="situation_issue", + get="get_situation", + filings="situation_filings", + summary="situation_summary", + export="export_situation", + feed="situation_feed", + calendar="situation_calendar", + stats="situation_stats", + performance="situation_performance", + underwrite="situation_underwriting_pack", + by_form="situations_by_form", + ) def _headers(self) -> dict[str, str]: headers = { @@ -489,7 +521,8 @@ def _request( *, retry: bool | dict[str, Any] | None = None, telemetry: bool | dict[str, Any] | None = None, - ) -> dict[str, Any]: + parse_json: bool = True, + ) -> Any: params, param_options = self._request_options_from_params(params) if retry is None: retry = param_options.get("retry") @@ -543,8 +576,8 @@ def _request( if not raw.strip(): if circuit_eligible: self._circuit_breaker.record_success() - return {} - data = json.loads(raw) + return {} if parse_json else "" + data = json.loads(raw) if parse_json else raw if circuit_eligible: self._circuit_breaker.record_success() return data @@ -1118,6 +1151,69 @@ def market_estimates(self, **params: Any) -> dict[str, Any]: def market_earnings_calendar(self, **params: Any) -> dict[str, Any]: return self._request("GET", "/v1/market/earnings-calendar", params=params) + def list_situations(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/situations", params=_situation_params(params)) + + def situation_issues(self, *, limit: int | None = None, **params: Any) -> dict[str, Any]: + return self._request( + "GET", + "/v1/situations/issues", + params=_situation_params({"limit": limit, **params}), + ) + + def situation_issue(self, issue: str | int, **params: Any) -> dict[str, Any]: + return self._request( + "GET", + f"/v1/situations/issues/{quote(str(issue), safe='')}", + params=_situation_params(params), + ) + + def get_situation(self, situation_id: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/situations/{quote(situation_id, safe='')}", params=_situation_params(params)) + + def situation_filings(self, situation_id: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/situations/{quote(situation_id, safe='')}/filings", params=_situation_params(params)) + + def situation_summary(self, situation_id: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/situations/{quote(situation_id, safe='')}/summary", params=_situation_params(params)) + + def export_situation(self, situation_id: str, **params: Any) -> str: + return self._request( + "GET", + f"/v1/situations/{quote(situation_id, safe='')}/export", + params=_situation_params(params), + parse_json=False, + ) + + def situation_feed(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/situations/feed", params=_situation_params(params)) + + def situation_calendar(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/situations/calendar", params=_situation_params(params)) + + def situation_stats(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/situations/stats", params=_situation_params(params)) + + def situation_performance(self, **params: Any) -> dict[str, Any]: + return self._request("GET", "/v1/situations/performance", params=_situation_params(params)) + + def situation_underwriting_pack( + self, + situation_id: str, + *, + retry: bool | dict[str, Any] | None = None, + telemetry: bool | dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._request( + "GET", + f"/v1/situations/{quote(situation_id, safe='')}/underwriting-pack", + retry=retry, + telemetry=telemetry, + ) + + def situations_by_form(self, form: str, **params: Any) -> dict[str, Any]: + return self._request("GET", f"/v1/situations/by-form/{quote(form, safe='')}", params=_situation_params(params)) + def news_stories(self, **params: Any) -> dict[str, Any]: return self._request("GET", "/v1/news/stories", params=params) diff --git a/tests/test_agent_helpers.py b/tests/test_agent_helpers.py index 4dc58b6..f5614f1 100644 --- a/tests/test_agent_helpers.py +++ b/tests/test_agent_helpers.py @@ -22,6 +22,11 @@ def __exit__(self, *_args): def read(self): return json.dumps(self.body).encode("utf-8") + +class FakeTextResponse(FakeResponse): + def read(self): + return str(self.body).encode("utf-8") + class AgentHelperTests(unittest.TestCase): def test_loads_auth_and_base_url_from_environment_when_constructor_options_are_omitted(self): previous = { @@ -286,6 +291,65 @@ def opener(request, timeout=None): ], ) + def test_situations_namespace_reaches_public_customer_routes(self): + seen_urls = [] + client = SecApiClient(retry=False, telemetry=False) + + def opener(request, timeout=None): + seen_urls.append(request.full_url) + if request.full_url.endswith("/export?view=agent"): + return FakeTextResponse(body="# Special situation") + return FakeResponse(body={"ok": True}) + + client._urlopen = opener + + client.situations.list( + types=["merger", "tender_offer"], + statuses=("announced", "pending"), + tickers=["AAPL", "MSFT"], + market_cap="large", + announced_from="2026-07-01", + enrich=False, + limit=10, + ) + client.situations.issues(limit=2) + client.situations.issue("special-situations-digest-22-2026-07-05") + client.situations.get("sit/with spaces", view="agent") + client.situations.filings("sit/with spaces", cursor=20, limit=10) + client.situations.summary("sit/with spaces", response_mode="compact") + markdown = client.situations.export("sit/with spaces", view="agent") + client.situations.feed(types=["merger", "spac"], tickers=["AAPL", "MSFT"], since="2026-07-01T00:00:00Z") + client.situations.calendar(date_types=["vote", "expiry"], days=30, limit=5) + client.situations.stats(window="30d") + client.situations.performance(types=["merger", "tender_offer"], group_by="subtype", window="1y") + client.situations.underwrite("sit/with spaces") + client.situation_underwriting_pack("sit/with spaces") + client.situations.by_form("SC 13D", statuses=["announced", "pending"], limit=25) + + self.assertEqual(markdown, "# Special situation") + self.assertEqual( + seen_urls, + [ + ( + "https://api.secapi.ai/v1/situations?types=merger%2Ctender_offer&statuses=announced%2Cpending" + "&tickers=AAPL%2CMSFT&market_cap=large&announced_from=2026-07-01&enrich=false&limit=10" + ), + "https://api.secapi.ai/v1/situations/issues?limit=2", + "https://api.secapi.ai/v1/situations/issues/special-situations-digest-22-2026-07-05", + "https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces?view=agent", + "https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces/filings?cursor=20&limit=10", + "https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces/summary?response_mode=compact", + "https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces/export?view=agent", + "https://api.secapi.ai/v1/situations/feed?types=merger%2Cspac&tickers=AAPL%2CMSFT&since=2026-07-01T00%3A00%3A00Z", + "https://api.secapi.ai/v1/situations/calendar?date_types=vote%2Cexpiry&days=30&limit=5", + "https://api.secapi.ai/v1/situations/stats?window=30d", + "https://api.secapi.ai/v1/situations/performance?types=merger%2Ctender_offer&group_by=subtype&window=1y", + "https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces/underwriting-pack", + "https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces/underwriting-pack", + "https://api.secapi.ai/v1/situations/by-form/SC%2013D?statuses=announced%2Cpending&limit=25", + ], + ) + def test_grouped_namespaces_resolve_flat_methods_at_call_time(self): client = SecApiClient(retry=False, telemetry=False) diff --git a/tests/test_retry.py b/tests/test_retry.py index 345acb0..39b3cd4 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -282,6 +282,52 @@ def opener(_request, timeout="not-passed"): self.assertEqual(attempts, 1) self.assertEqual(timeouts, [30.0]) + def test_situations_namespace_preserves_per_call_retry_and_telemetry_controls(self): + attempts = 0 + retry, _delays = retry_harness() + client = SecApiClient(retry=retry, telemetry=False) + + def opener(request, timeout=None): + nonlocal attempts + attempts += 1 + self.assertNotIn("retry=", request.full_url) + self.assertNotIn("telemetry=", request.full_url) + raise http_error(503, {"message": "unavailable"}) + + client._urlopen = opener + + with self.assertRaises(SecApiError): + client.situations.issues(limit=2, retry=False, telemetry=False) + with self.assertRaises(SecApiError): + client.situations.underwrite( + "sit/with spaces", + retry=False, + telemetry=False, + ) + self.assertEqual(attempts, 2) + + seen_urls = [] + + def successful_opener(request, timeout=None): + seen_urls.append(request.full_url) + return FakeResponse(body={"ok": True}) + + client._urlopen = successful_opener + self.assertEqual( + client.situation_underwriting_pack( + "sit/with spaces", + retry=False, + telemetry=False, + ), + {"ok": True}, + ) + self.assertEqual( + seen_urls, + [ + "https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces/underwriting-pack", + ], + ) + def test_get_trace_escapes_trace_id_path_segment(self): seen_urls = [] client = SecApiClient(retry=False, telemetry=False)