diff --git a/README.md b/README.md index 75c3440..cb107ae 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Bootstrap async Rust client for SEC API factor data, filings, statements, owners - 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 signal utilities +- Special Situations list/detail/feed/calendar/stats/performance, underwriting-pack JSON, Copy-for-LLM markdown exports, and weekly issue archive reads - MCP info discovery and hosted tool calls ## Example @@ -136,9 +137,82 @@ let history = client ``` Start with `client.entities()`, `client.filings()`, `client.sections()`, -`client.search()`, and `client.factors()` when exploring. The grouped services -borrow the client and delegate to the flat methods, so auth, retries, timeouts, -and custom HTTP clients behave exactly the same. +`client.search()`, `client.factors()`, and `client.situations()` when exploring. +The grouped services borrow the client and delegate to the flat methods, so +auth, retries, timeouts, and custom HTTP clients behave exactly the same. + +## Special Situations + +The public Special Situations surface exposes SEC-derived situations only: +durable public-company events such as M&A, tender offers, going-private +transactions, spin-offs, activist campaigns, restructurings, and bankruptcy +processes. The methods below map only to documented public endpoints. + +```rust +use sec_api_sdk_rust::SecApiClient; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = SecApiClient::new(std::env::var("SECAPI_API_KEY").ok()); + + let situations = client + .situations() + .list(&[("types", "ma,tender_offer"), ("limit", "10"), ("response_mode", "agent")]) + .await?; + + let first_id = situations["data"][0]["id"].as_str().unwrap_or_default(); + let detail = client.situations().get(first_id, &[("enrich", "false")]).await?; + let filings = client.situations().filings(first_id, &[("limit", "25")]).await?; + let summary = client.situations().summary(first_id, &[]).await?; + let underwriting = client.situations().underwriting_pack(first_id).await?; + let markdown = client.situations().copy_for_llm(first_id, &[]).await?; + + println!( + "{} {} {} {} {}", + detail["headline"], + filings["data"].as_array().map_or(0, Vec::len), + summary["summaryMd"], + underwriting["id"], + markdown + ); + + Ok(()) +} +``` + +Other grouped helpers map directly to the public REST routes: + +```rust +let feed = client.situations().feed(&[("types", "ma"), ("limit", "25")]).await?; +let calendar = client.situations().calendar(&[("days", "90")]).await?; +let stats = client.situations().stats(&[("window", "30d")]).await?; +let performance = client.situations().performance(&[("group_by", "subtype")]).await?; +let by_form = client.situations().by_form("SC TO-T", &[("limit", "10")]).await?; +let underwriting = client.situations().underwriting_pack("sit_123").await?; +let flat_underwriting = client.situation_underwriting_pack("sit_123").await?; +``` + +`underwriting_pack(...)` and `situation_underwriting_pack(...)` call +`GET /v1/situations/{id}/underwriting-pack` with no query parameters and return +the raw JSON response as `serde_json::Value`. The pack is limited to public +Special Situations data derived from SEC sources; it does not expose internal or +TIKR data. + +Weekly issue archive helpers are also available: + +```rust +let issues = client.situations().issues(&[("limit", "12")]).await?; +let issue = client.situations().issue("22", &[]).await?; +``` + +Archive issue endpoints (`GET /v1/situations/issues` and +`GET /v1/situations/issues/{issue}`) and the underwriting-pack endpoint +(`GET /v1/situations/{id}/underwriting-pack`) intentionally depend on +datastream PR +[#1363](https://github.com/autonomous-computer/omni-datastream/pull/1363). +Until that PR is merged and deployed to the API origin you call, these helpers +may return the API's normal not-found or unavailable response even though the +SDK methods compile. ## Auto-pagination diff --git a/src/lib.rs b/src/lib.rs index 90884b0..0e89ebc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -742,10 +742,18 @@ impl SecApiClient { FactorService { client: self } } + pub fn situations(&self) -> SituationService<'_> { + SituationService { client: self } + } + fn headers(&self) -> Result { + self.headers_with_accept(HeaderValue::from_static("application/json")) + } + + fn headers_with_accept(&self, accept: HeaderValue) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers.insert(ACCEPT, accept); headers.insert(USER_AGENT, HeaderValue::from_static(SDK_USER_AGENT)); let version_header = HeaderValue::from_str(&self.api_version).unwrap_or_else(|_| { HeaderValue::from_static(DEFAULT_API_VERSION) @@ -801,6 +809,44 @@ impl SecApiClient { unreachable!("retry loop always returns") } + async fn get_text(&self, path: &str, params: &[(&str, &str)], accept: HeaderValue) -> Result { + let url = format!("{}{}", self.base_url.trim_end_matches('/'), path); + for attempt in 0..=self.retry_config.max_retries { + let response = self + .http + .get(&url) + .headers(self.headers_with_accept(accept.clone())?) + .query(params) + .send() + .await; + match response { + Ok(response) => { + let status = response.status().as_u16(); + if is_retryable_status(status) && attempt < self.retry_config.max_retries { + let retry_after = response.headers().get(RETRY_AFTER).cloned(); + drop(response); + tokio::time::sleep(retry_delay(attempt, self.retry_config, retry_after.as_ref())).await; + continue; + } + let request_id = response_request_id(response.headers()); + let text = response.text().await?; + if (200..300).contains(&status) { + return Ok(text); + } + return Err(api_error(status, text, request_id)); + } + Err(error) => { + if attempt < self.retry_config.max_retries { + tokio::time::sleep(retry_delay(attempt, self.retry_config, None)).await; + continue; + } + return Err(error.into()); + } + } + } + unreachable!("retry loop always returns") + } + pub async fn health(&self) -> Result { self.get("/healthz", &[]).await } @@ -1352,6 +1398,93 @@ impl SecApiClient { self.get("/v1/earnings/transcripts", params).await } + /// List durable, customer-facing SEC Special Situations. + pub async fn list_situations(&self, params: &[(&str, &str)]) -> Result { + self.get("/v1/situations", params).await + } + + /// List immutable weekly Special Situations Digest archive issues. + /// + /// This endpoint is introduced by datastream PR #1363 and requires that PR + /// to be merged and deployed before production calls will succeed. + pub async fn situation_issues(&self, params: &[(&str, &str)]) -> Result { + self.get("/v1/situations/issues", params).await + } + + /// Retrieve one weekly Special Situations Digest issue by issue number or slug. + /// + /// This endpoint is introduced by datastream PR #1363 and requires that PR + /// to be merged and deployed before production calls will succeed. + pub async fn situation_issue(&self, issue: &str, params: &[(&str, &str)]) -> Result { + self.get(&format!("/v1/situations/issues/{}", urlencoding::encode(issue)), params).await + } + + pub async fn get_situation(&self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.get(&format!("/v1/situations/{}", urlencoding::encode(situation_id)), params).await + } + + pub async fn situation_filings(&self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.get( + &format!("/v1/situations/{}/filings", urlencoding::encode(situation_id)), + params, + ).await + } + + pub async fn situation_summary(&self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.get( + &format!("/v1/situations/{}/summary", urlencoding::encode(situation_id)), + params, + ).await + } + + /// Return the public SEC-derived underwriting pack JSON for one Special Situation. + /// + /// This endpoint is introduced by datastream PR #1363 and requires that PR + /// to be merged and deployed before production calls will succeed. + pub async fn situation_underwriting_pack(&self, situation_id: &str) -> Result { + self.get( + &format!("/v1/situations/{}/underwriting-pack", urlencoding::encode(situation_id)), + &[], + ).await + } + + /// Return the Copy-for-LLM markdown export for one Special Situation. + pub async fn situation_export_markdown(&self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.get_text( + &format!("/v1/situations/{}/export", urlencoding::encode(situation_id)), + params, + HeaderValue::from_static("text/markdown"), + ).await + } + + /// Alias for [`SecApiClient::situation_export_markdown`]. + pub async fn situation_copy_for_llm(&self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.situation_export_markdown(situation_id, params).await + } + + pub async fn situations_feed(&self, params: &[(&str, &str)]) -> Result { + self.get("/v1/situations/feed", params).await + } + + pub async fn situations_calendar(&self, params: &[(&str, &str)]) -> Result { + self.get("/v1/situations/calendar", params).await + } + + pub async fn situations_stats(&self, params: &[(&str, &str)]) -> Result { + self.get("/v1/situations/stats", params).await + } + + pub async fn situations_performance(&self, params: &[(&str, &str)]) -> Result { + self.get("/v1/situations/performance", params).await + } + + pub async fn situations_by_form(&self, form: &str, params: &[(&str, &str)]) -> Result { + self.get( + &format!("/v1/situations/by-form/{}", urlencoding::encode(form)), + params, + ).await + } + async fn post_json(&self, path: &str, body: &Value) -> Result { self.post_json_with_params(path, body, &[]).await } @@ -1593,6 +1726,65 @@ impl<'a> FactorService<'a> { } } +#[derive(Clone, Copy)] +pub struct SituationService<'a> { + client: &'a SecApiClient, +} + +impl<'a> SituationService<'a> { + pub async fn list(self, params: &[(&str, &str)]) -> Result { + self.client.list_situations(params).await + } + + pub async fn issues(self, params: &[(&str, &str)]) -> Result { + self.client.situation_issues(params).await + } + + pub async fn issue(self, issue: &str, params: &[(&str, &str)]) -> Result { + self.client.situation_issue(issue, params).await + } + + pub async fn get(self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.client.get_situation(situation_id, params).await + } + + pub async fn filings(self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.client.situation_filings(situation_id, params).await + } + + pub async fn summary(self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.client.situation_summary(situation_id, params).await + } + + pub async fn underwriting_pack(self, situation_id: &str) -> Result { + self.client.situation_underwriting_pack(situation_id).await + } + + pub async fn copy_for_llm(self, situation_id: &str, params: &[(&str, &str)]) -> Result { + self.client.situation_copy_for_llm(situation_id, params).await + } + + pub async fn feed(self, params: &[(&str, &str)]) -> Result { + self.client.situations_feed(params).await + } + + pub async fn calendar(self, params: &[(&str, &str)]) -> Result { + self.client.situations_calendar(params).await + } + + pub async fn stats(self, params: &[(&str, &str)]) -> Result { + self.client.situations_stats(params).await + } + + pub async fn performance(self, params: &[(&str, &str)]) -> Result { + self.client.situations_performance(params).await + } + + pub async fn by_form(self, form: &str, params: &[(&str, &str)]) -> Result { + self.client.situations_by_form(form, params).await + } +} + fn runtime_env(names: &[&str]) -> Option { names.iter().find_map(|name| { std::env::var(name) @@ -2495,6 +2687,115 @@ mod tests { ); } + #[tokio::test] + async fn situations_service_delegates_to_public_special_situations_routes() { + let (base_url, rx, handle) = capture_server(12); + let client = SecApiClient::new(None).with_base_url(base_url); + + client + .situations() + .list(&[("types", "ma,tender_offer"), ("limit", "10"), ("response_mode", "agent")]) + .await + .unwrap(); + client.situations().issues(&[("limit", "12")]).await.unwrap(); + client + .situations() + .issue("special-situations-digest/22 2026-07-05", &[]) + .await + .unwrap(); + client + .situations() + .get("sit/team alpha", &[("enrich", "false")]) + .await + .unwrap(); + client + .situations() + .filings("sit/team alpha", &[("cursor", "10"), ("limit", "5")]) + .await + .unwrap(); + client + .situations() + .summary("sit/team alpha", &[("view", "agent")]) + .await + .unwrap(); + client + .situations() + .underwriting_pack("sit/team alpha") + .await + .unwrap(); + client + .situations() + .feed(&[("types", "ma"), ("since", "2026-07-01"), ("limit", "3")]) + .await + .unwrap(); + client + .situations() + .calendar(&[("date_types", "vote,expiry"), ("days", "30")]) + .await + .unwrap(); + client.situations().stats(&[("window", "30d")]).await.unwrap(); + client + .situations() + .performance(&[("types", "ma"), ("group_by", "subtype")]) + .await + .unwrap(); + client + .situations() + .by_form("SC TO-T/DEFM14A", &[("tickers", "AAPL,MSFT"), ("enrich", "false")]) + .await + .unwrap(); + + let targets: Vec = (0..12) + .map(|_| rx.recv_timeout(Duration::from_secs(2)).expect("request target")) + .collect(); + handle.join().expect("capture server thread"); + + assert_eq!( + targets, + vec![ + "/v1/situations?types=ma%2Ctender_offer&limit=10&response_mode=agent", + "/v1/situations/issues?limit=12", + "/v1/situations/issues/special-situations-digest%2F22%202026-07-05", + "/v1/situations/sit%2Fteam%20alpha?enrich=false", + "/v1/situations/sit%2Fteam%20alpha/filings?cursor=10&limit=5", + "/v1/situations/sit%2Fteam%20alpha/summary?view=agent", + "/v1/situations/sit%2Fteam%20alpha/underwriting-pack", + "/v1/situations/feed?types=ma&since=2026-07-01&limit=3", + "/v1/situations/calendar?date_types=vote%2Cexpiry&days=30", + "/v1/situations/stats?window=30d", + "/v1/situations/performance?types=ma&group_by=subtype", + "/v1/situations/by-form/SC%20TO-T%2FDEFM14A?tickers=AAPL%2CMSFT&enrich=false", + ] + ); + } + + #[tokio::test] + async fn situation_copy_for_llm_returns_raw_markdown_text() { + let body = "# Situation\n\n- Source filing: 8-K"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/markdown; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let (base_url, rx, handle) = raw_response_sequence_server(vec![response]); + let client = SecApiClient::new(None) + .with_base_url(base_url) + .without_retries(); + + let markdown = client + .situations() + .copy_for_llm("sit/team alpha", &[("format", "markdown")]) + .await + .unwrap(); + + let raw_request = rx.recv_timeout(Duration::from_secs(2)).expect("raw request"); + handle.join().expect("raw capture server thread"); + + assert_eq!(markdown, body); + assert!(raw_request.starts_with("GET /v1/situations/sit%2Fteam%20alpha/export?format=markdown HTTP/1.1")); + assert!(raw_request.contains("accept: text/markdown")); + } + #[tokio::test] async fn paginate_filings_follows_next_cursor() { let first = json_response(r#"{"object":"list","data":[{"accessionNumber":"0001"},{"accessionNumber":"0002"}],"nextCursor":"cur_2"}"#);