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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
104 changes: 100 additions & 4 deletions secapi_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
64 changes: 64 additions & 0 deletions tests/test_agent_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions tests/test_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down