From de574f0dd5bc6981b92bba036048c26e80d54192 Mon Sep 17 00:00:00 2001 From: Hyperion <89154626+daniel-scrivner@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:14:12 -0600 Subject: [PATCH 1/2] feat(situations): add public SDK workflows --- package.json | 2 +- src/generated-contracts/index.ts | 1 + src/generated-contracts/openapi.ts | 178 ++++++++ src/generated-contracts/situations.ts | 614 ++++++++++++++++++++++++++ src/index.ts | 276 +++++++++++- test/agent-helpers.test.ts | 283 ++++++++++++ test/retry.test.ts | 1 + 7 files changed, 1344 insertions(+), 11 deletions(-) create mode 100644 src/generated-contracts/situations.ts diff --git a/package.json b/package.json index 5485565..908e7c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@secapi/sdk-js", - "version": "1.1.0", + "version": "1.2.0", "description": "JavaScript SDK for SEC API filing search, financial statements, ownership data, and filing sections.", "private": false, "type": "module", diff --git a/src/generated-contracts/index.ts b/src/generated-contracts/index.ts index d659888..dd9da99 100644 --- a/src/generated-contracts/index.ts +++ b/src/generated-contracts/index.ts @@ -15,3 +15,4 @@ export * from "./agent-prompts.js" export * from "./model-rates.js" export * from "./database-url.js" export * from "./legal.js" +export * from "./situations.js" diff --git a/src/generated-contracts/openapi.ts b/src/generated-contracts/openapi.ts index 95c6930..e4afca9 100644 --- a/src/generated-contracts/openapi.ts +++ b/src/generated-contracts/openapi.ts @@ -1798,6 +1798,27 @@ const dilutionListParameters = [ }, ] as const +// Scoped manual sync from the public API OpenAPI. This SDK repository does not +// contain the source contract generator, so only the public SEC-derived +// Special Situations paths are mirrored here. +const situationsListParameters = [ + { name: "types", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated situation_type values." }, + { name: "subtypes", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated subtype values." }, + { name: "statuses", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated lifecycle statuses." }, + { name: "tickers", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated tickers." }, + { name: "sectors", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated sectors." }, + { name: "market_cap", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated market-cap buckets (nano|micro|small|mid|large|mega)." }, + { name: "country", in: "query", required: false, schema: { type: "string" }, description: "ISO 3166-1 alpha-2 country filter." }, + { name: "announced_from", in: "query", required: false, schema: { type: "string" }, description: "Lower bound (ISO date) on announced date." }, + { name: "announced_to", in: "query", required: false, schema: { type: "string" }, description: "Upper bound (ISO date) on announced date." }, + { name: "updated_from", in: "query", required: false, schema: { type: "string" }, description: "Lower bound (ISO datetime) on last update." }, + { name: "forms", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated EDGAR form types." }, + { name: "enrich", in: "query", required: false, schema: { type: "string", enum: ["true", "false"], default: "true" } }, + { name: "cursor", in: "query", required: false, schema: { type: "integer", minimum: 0, maximum: 9007199254740991, default: 0 } }, + { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 25 } }, + { name: "response_mode", in: "query", required: false, schema: { type: "string", enum: ["compact", "standard", "verbose", "agent"] } }, +] as const + const statementPeriodQueryParameter = { name: "period", in: "query", @@ -4303,6 +4324,163 @@ export const openApiDocument = { "/mcp": { post: { summary: "Hosted MCP transport endpoint" }, }, + // ----- Public SEC-derived Special Situations ----- + "/v1/situations": { + get: { + tags: ["Special Situations"], + summary: "List durable SEC-derived special situations with lifecycle, terms, and market context", + parameters: situationsListParameters, + ...jsonResponseOneOf(["SituationList", "SituationStrippedList"]), + }, + }, + "/v1/situations/issues": { + get: { + tags: ["Special Situations"], + summary: "List immutable, numbered weekly Special Situations Digest issues", + parameters: [{ name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 24 } }], + ...jsonResponse("SituationWeeklyIssueList"), + }, + }, + "/v1/situations/issues/{issue}": { + get: { + tags: ["Special Situations"], + summary: "Retrieve one immutable weekly Special Situations Digest issue by number or slug", + parameters: [{ name: "issue", in: "path", required: true, schema: { type: "string" } }], + ...jsonResponse("SituationWeeklyIssue"), + }, + }, + "/v1/situations/feed": { + get: { + tags: ["Special Situations"], + summary: "Reverse-chronological feed of SEC-derived situation events", + parameters: [ + { name: "types", in: "query", required: false, schema: { type: "string" } }, + { name: "categories", in: "query", required: false, schema: { type: "string" } }, + { name: "tickers", in: "query", required: false, schema: { type: "string" } }, + { name: "since", in: "query", required: false, schema: { type: "string" } }, + { name: "cursor", in: "query", required: false, schema: { type: "integer", minimum: 0, maximum: 9007199254740991, default: 0 } }, + { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 25 } }, + ], + ...jsonResponse("SituationFeedItemList"), + }, + }, + "/v1/situations/feed.rss": { + get: { + tags: ["Special Situations"], + summary: "Paid authenticated RSS projection of SEC-derived situation events", + parameters: [ + { name: "types", in: "query", required: false, schema: { type: "string" } }, + { name: "categories", in: "query", required: false, schema: { type: "string" } }, + { name: "tickers", in: "query", required: false, schema: { type: "string" } }, + ], + responses: { "200": { description: "RSS situation feed", content: { "application/rss+xml": { schema: { type: "string" } } } } }, + }, + }, + "/v1/situations/calendar": { + get: { + tags: ["Special Situations"], + summary: "Upcoming SEC-derived situation key dates", + parameters: [ + { name: "types", in: "query", required: false, schema: { type: "string" } }, + { name: "date_types", in: "query", required: false, schema: { type: "string" } }, + { name: "tickers", in: "query", required: false, schema: { type: "string" } }, + { name: "days", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 365, default: 90 } }, + { name: "cursor", in: "query", required: false, schema: { type: "integer", minimum: 0, maximum: 9007199254740991, default: 0 } }, + { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 25 } }, + ], + ...jsonResponse("SituationCalendarEntryList"), + }, + }, + "/v1/situations/stats": { + get: { + tags: ["Special Situations"], + summary: "Situation counts by type, status, sector, and market-cap bucket", + parameters: [{ name: "window", in: "query", required: false, schema: { type: "string" } }], + ...jsonResponse("SituationStats"), + }, + }, + "/v1/situations/performance": { + get: { + tags: ["Special Situations"], + summary: "Closed-situation outcome cohorts", + parameters: [ + { name: "types", in: "query", required: false, schema: { type: "string" } }, + { name: "window", in: "query", required: false, schema: { type: "string" } }, + { name: "group_by", in: "query", required: false, schema: { type: "string", enum: ["type", "subtype"] } }, + ], + ...jsonResponse("SituationPerformance"), + }, + }, + "/v1/situations/by-form/{form}": { + get: { + tags: ["Special Situations"], + summary: "List situations opened or advanced by an EDGAR form type", + parameters: [ + { name: "form", in: "path", required: true, schema: { type: "string" } }, + { name: "subtypes", in: "query", required: false, schema: { type: "string" } }, + { name: "statuses", in: "query", required: false, schema: { type: "string" } }, + { name: "tickers", in: "query", required: false, schema: { type: "string" } }, + { name: "sectors", in: "query", required: false, schema: { type: "string" } }, + { name: "market_cap", in: "query", required: false, schema: { type: "string" } }, + { name: "country", in: "query", required: false, schema: { type: "string" } }, + { name: "announced_from", in: "query", required: false, schema: { type: "string" } }, + { name: "announced_to", in: "query", required: false, schema: { type: "string" } }, + { name: "updated_from", in: "query", required: false, schema: { type: "string" } }, + { name: "enrich", in: "query", required: false, schema: { type: "string", enum: ["true", "false"], default: "true" } }, + { name: "cursor", in: "query", required: false, schema: { type: "integer", minimum: 0, maximum: 9007199254740991, default: 0 } }, + { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 25 } }, + { name: "response_mode", in: "query", required: false, schema: { type: "string", enum: ["compact", "standard", "verbose", "agent"] } }, + ], + ...jsonResponseOneOf(["SituationList", "SituationStrippedList"]), + }, + }, + "/v1/situations/{situation_id}/filings": { + get: { + tags: ["Special Situations"], + summary: "Retrieve a situation filing timeline", + parameters: [ + { name: "situation_id", in: "path", required: true, schema: { type: "string" } }, + { name: "cursor", in: "query", required: false, schema: { type: "integer", minimum: 0, maximum: 9007199254740991, default: 0 } }, + { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 25 } }, + ], + ...jsonResponse("SituationEventList"), + }, + }, + "/v1/situations/{situation_id}/summary": { + get: { + tags: ["Special Situations"], + summary: "Retrieve a compact situation summary", + parameters: [{ name: "situation_id", in: "path", required: true, schema: { type: "string" } }], + ...jsonResponse("SituationSummary"), + }, + }, + "/v1/situations/{situation_id}/underwriting-pack": { + get: { + tags: ["Special Situations"], + summary: "Retrieve a deterministic, source-cited underwriting pack", + parameters: [{ name: "situation_id", in: "path", required: true, schema: { type: "string" } }], + ...jsonResponse("SituationUnderwritingPack"), + }, + }, + "/v1/situations/{situation_id}/export": { + get: { + tags: ["Special Situations"], + summary: "Render a source-cited Markdown Copy-for-LLM brief", + parameters: [{ name: "situation_id", in: "path", required: true, schema: { type: "string" } }], + responses: { "200": { description: "Markdown situation brief", content: { "text/markdown": { schema: { type: "string" } } } } }, + }, + }, + "/v1/situations/{situation_id}": { + get: { + tags: ["Special Situations"], + summary: "Retrieve a single situation with its filing timeline", + parameters: [ + { name: "situation_id", in: "path", required: true, schema: { type: "string" } }, + { name: "enrich", in: "query", required: false, schema: { type: "string", enum: ["true", "false"], default: "true" } }, + ], + ...jsonResponseOneOf(["SituationDetail", "SituationStripped"]), + }, + }, // ----- Dilution domain (OMNI-3071 stubs; handlers land in OMNI-3089) ----- "/v1/dilution/events": { get: { diff --git a/src/generated-contracts/situations.ts b/src/generated-contracts/situations.ts new file mode 100644 index 0000000..1b20d1f --- /dev/null +++ b/src/generated-contracts/situations.ts @@ -0,0 +1,614 @@ +import { z } from "zod" +import { + degradedStateSchema, + freshnessMetadataSchema, + materializationMetadataSchema, + methodologyMetadataSchema, + revisionMetadataSchema, + sourceRightsMetadataSchema, +} from "./foundation.js" +import { provenanceSchema } from "./schemas.js" +import { dilutionVerificationSchema } from "./dilution.js" + +export const SITUATION_TYPES = [ + "merger", + "tender_offer", + "going_private", + "spin_off", + "divestiture", + "activist_campaign", + "restructuring", + "bankruptcy", + "liquidation", + "strategic_review", + "capital_return", + "capital_raise", + "spac", + "delisting", + "relisting", + "litigation", + "management_change", + "domicile_change", + "demutualization", + "other", +] as const + +export const situationTypeSchema = z.enum(SITUATION_TYPES) +export type SituationType = z.infer + +export const SITUATION_SUBTYPES = [ + "definitive", + "preliminary", + "unsolicited", + "rumor_response", + "scheme_of_arrangement", + "spac_merger", + "self_tender", + "third_party", + "exchange_offer", + "management_buyout", + "sponsor_buyout", + "squeeze_out", + "spin_off", + "split_off", + "carve_out_ipo", + "asset_sale", + "joint_venture", + "carve_out", + "stake_disclosure", + "proxy_contest", + "cooperation_agreement", + "settlement", + "debt_for_equity_swap", + "out_of_court", + "operational", + "chapter_11", + "chapter_7", + "chapter_15", + "administration", + "prepackaged", + "emergence", + "plan_of_liquidation", + "dissolution", + "formal_alternatives", + "sale_process", + "buyback_authorization", + "special_dividend", + "recapitalization", + "rights_offering", + "public_offering", + "private_placement", + "pipe", + "atm_program", + "ipo", + "extension", + "trust_liquidation", + "forced", + "voluntary", + "uplisting", + "otc_relisting", + "won", + "lost", + "settled", + "ceo", + "cfo", + "chair", + "board", + "redomiciliation", +] as const + +export const situationSubtypeSchema = z.enum(SITUATION_SUBTYPES) +export type SituationSubtype = z.infer + +export const SITUATION_STATUSES = ["rumored", "announced", "pending", "completed", "terminated", "expired"] as const +export const situationStatusSchema = z.enum(SITUATION_STATUSES) +export type SituationStatus = z.infer + +export const MARKET_CAP_BUCKETS = ["nano", "micro", "small", "mid", "large", "mega"] as const +export const marketCapBucketSchema = z.enum(MARKET_CAP_BUCKETS) +export type MarketCapBucket = z.infer + +export const situationConsiderationTypeSchema = z.enum(["cash", "stock", "mixed", "unspecified"]) +export type SituationConsiderationType = z.infer + +export const SITUATION_EVENT_CATEGORIES = [ + "merger_acquisition", + "tender_offer", + "going_private", + "spin_off", + "divestiture", + "activist_activity", + "restructuring", + "bankruptcy", + "liquidation", + "strategic_review", + "capital_return", + "securities_offering", + "delisting_action", + "relisting_action", + "legal_dispute", + "management_change", + "domicile_change", + "insider_transaction", + "earnings_report", + "earnings_call", + "guidance_update", + "investor_conference", + "expansion", + "contract_award", + "product_launch", + "regulatory_inspection", + "operational_disruption", + "labor_issue", + "accounting_change", + "auditor_change", + "financial_distress", + "payment_default", + "credit_rating", + "capital_structure", + "dividend_policy", + "investment", + "ipo", + "name_change", + "other", +] as const + +export const situationEventCategorySchema = z.enum(SITUATION_EVENT_CATEGORIES) +export type SituationEventCategory = z.infer + +export const SITUATION_FORM_DETECTION = { + merger: { forms: ["8-K", "425", "S-4", "F-4", "DEFM14A", "DEFC14A", "DFAN14A", "PREM14A", "SC 13D", "SC 13D/A"], eightKItems: ["1.01", "1.02", "2.01", "8.01"] }, + tender_offer: { forms: ["SC TO-T", "SC TO-I", "SC TO-C", "SC 14D9", "8-K"], eightKItems: ["8.01"] }, + going_private: { forms: ["SC 13E-3", "PREM14A", "DEFM14A", "SC TO-I", "8-K"], eightKItems: ["1.01", "8.01"] }, + spin_off: { forms: ["10-12B", "8-K", "S-1"], eightKItems: ["1.01", "8.01"] }, + divestiture: { forms: ["8-K"], eightKItems: ["1.01", "2.01", "8.01"] }, + activist_campaign: { forms: ["SC 13D", "SC 13D/A", "DFAN14A", "DEFC14A", "PREC14A", "PX14A6G"], eightKItems: ["5.02", "8.01"] }, + restructuring: { forms: ["8-K", "T-3", "S-4"], eightKItems: ["1.01", "1.02", "2.03", "3.02", "8.01"] }, + bankruptcy: { forms: ["8-K", "25-NSE", "15-12B", "15-12G"], eightKItems: ["1.03", "3.01", "8.01"] }, + liquidation: { forms: ["8-K", "PREM14A", "DEFM14A"], eightKItems: ["1.03", "8.01"] }, + strategic_review: { forms: ["8-K"], eightKItems: ["8.01"] }, + capital_return: { forms: ["8-K"], eightKItems: ["8.01"] }, + capital_raise: { forms: ["8-K", "S-1", "S-3", "424B*", "F-1", "F-3"], eightKItems: ["1.01", "2.03", "3.02", "8.01"] }, + spac: { forms: ["8-K", "S-1", "424B*", "DEF 14A", "PREM14A"], eightKItems: ["1.01", "3.02", "5.07", "8.01"] }, + delisting: { forms: ["25-NSE", "25", "15-12B", "15-12G", "8-K"], eightKItems: ["3.01"] }, + relisting: { forms: ["8-K", "8-A12B", "8-A12G"], eightKItems: ["3.01", "8.01"] }, + litigation: { forms: ["8-K"], eightKItems: ["1.01", "8.01"] }, + management_change: { forms: ["8-K"], eightKItems: ["5.02"] }, + domicile_change: { forms: ["8-K", "S-4", "F-4", "DEFM14A", "PREM14A"], eightKItems: ["3.03", "5.03", "8.01"] }, + demutualization: { forms: ["8-K", "S-1", "PREM14A", "DEFM14A"], eightKItems: ["1.01", "8.01"] }, + other: { forms: ["8-K", "6-K"], eightKItems: ["8.01"] }, +} as const satisfies Record + +export const SITUATION_LOOKUP_FORMS = [ + "SC 13D", + "SC 13D/A", + "SC TO-T", + "SC TO-I", + "SC TO-C", + "SC 14D9", + "425", + "SC 13E-3", + "DEFM14A", + "PREM14A", + "10-12B", + "25-NSE", +] as const + +export type SituationLookupForm = (typeof SITUATION_LOOKUP_FORMS)[number] + +function canonicalizeForm(form: string): string { + return form.trim().toUpperCase().replace(/\s+/g, " ").replace(/\/A\d*$/, "").replace(/-/g, "") +} + +export function formToSituationTypes(form: string): SituationType[] { + const canon = canonicalizeForm(form) + if (!canon) return [] + const matched: SituationType[] = [] + for (const [type, detection] of Object.entries(SITUATION_FORM_DETECTION) as Array< + [SituationType, (typeof SITUATION_FORM_DETECTION)[SituationType]] + >) { + const hit = detection.forms.some((trigger) => { + const t = canonicalizeForm(trigger) + return t.endsWith("*") ? canon.startsWith(t.slice(0, -1)) : canon === t + }) + if (hit) matched.push(type) + } + return matched +} + +const situationInvestorMetadataShape = { + provenance: provenanceSchema, + freshness: freshnessMetadataSchema.optional(), + materialization: materializationMetadataSchema.optional(), + sourceRights: sourceRightsMetadataSchema, + methodology: methodologyMetadataSchema.optional(), + revision: revisionMetadataSchema.optional(), + degradedState: degradedStateSchema.nullable().optional(), +} as const + +export const situationTermsSchema = z.object({ + counterparty: z.string().nullable().optional(), + counterpartyTicker: z.string().nullable().optional(), + stakePct: z.number().min(0).max(100).nullable().optional(), + dealValueUsd: z.number().nullable().optional(), + pricePerShare: z.number().nullable().optional(), + considerationType: situationConsiderationTypeSchema.nullable().optional(), + premiumPct: z.number().nullable().optional(), +}) + +export type SituationTerms = z.infer + +export const situationKeyDatesSchema = z.object({ + announced: z.string().nullable(), + record: z.string().nullable().optional(), + vote: z.string().nullable().optional(), + expiry: z.string().nullable().optional(), + expectedClose: z.string().nullable().optional(), + completed: z.string().nullable().optional(), + terminated: z.string().nullable().optional(), +}) + +export type SituationKeyDates = z.infer + +export const situationMarketContextSchema = z.object({ + price: z.number().nullable().optional(), + marketCapUsd: z.number().nullable().optional(), + marketCapBucket: marketCapBucketSchema.nullable().optional(), + enterpriseValueUsd: z.number().nullable().optional(), + asOf: z.string().nullable().optional(), +}) + +export type SituationMarketContext = z.infer + +export const situationSchema = z.object({ + object: z.literal("situation"), + id: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + livemode: z.boolean(), + type: situationTypeSchema, + subtype: situationSubtypeSchema.nullable(), + status: situationStatusSchema, + statusUpdatedAt: z.string(), + ticker: z.string().nullable(), + cik: z.string(), + entityName: z.string(), + exchange: z.string().nullable().optional(), + country: z.string().nullable().optional(), + sector: z.string().nullable().optional(), + headline: z.string(), + summaryMd: z.string().nullable().optional(), + businessProfile: z.string().nullable().optional(), + terms: situationTermsSchema, + keyDates: situationKeyDatesSchema, + market: situationMarketContextSchema, + sourceAccessions: z.array(z.string()).default([]), + eventCount: z.number().int().nonnegative(), + latestEventAt: z.string().nullable(), + providerKey: z.string(), + verification: dilutionVerificationSchema, + ...situationInvestorMetadataShape, +}) + +export type Situation = z.infer + +export const situationEventSchema = z.object({ + object: z.literal("situation_event"), + id: z.string(), + createdAt: z.string(), + livemode: z.boolean(), + situationId: z.string(), + occurredAt: z.string(), + accessionNumber: z.string(), + formType: z.string(), + eightKItems: z.array(z.string()).default([]), + category: situationEventCategorySchema.nullable(), + title: z.string(), + summaryMd: z.string().nullable().optional(), + statusBefore: situationStatusSchema.nullable(), + statusAfter: situationStatusSchema.nullable(), + documentUrl: z.string().url().nullable(), + providerKey: z.string(), + verification: dilutionVerificationSchema, +}) + +export type SituationEvent = z.infer + +export const situationDetailSchema = situationSchema.extend({ + events: z.array(situationEventSchema).default([]), +}) + +export type SituationDetail = z.infer + +export const situationListSchema = z.object({ + object: z.literal("list"), + data: z.array(situationSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}) + +export type SituationList = z.infer + +export const situationWeeklyIssueSituationSchema = z.object({ + id: z.string(), + type: situationTypeSchema, + subtype: situationSubtypeSchema.nullable(), + status: situationStatusSchema, + ticker: z.string().nullable(), + cik: z.string(), + entityName: z.string(), + headline: z.string(), + summaryMd: z.string().nullable(), + businessProfile: z.string().nullable(), + terms: situationTermsSchema, + keyDates: situationKeyDatesSchema, + sourceAccessions: z.array(z.string()), + eventCount: z.number().int().nonnegative(), + latestEventAt: z.string().nullable(), +}) + +export const situationWeeklyIssueEventSchema = z.object({ + object: z.literal("situation_event"), + id: z.string(), + createdAt: z.string(), + livemode: z.boolean(), + situationId: z.string(), + occurredAt: z.string(), + accessionNumber: z.string(), + formType: z.string(), + eightKItems: z.array(z.string()).default([]), + category: situationEventCategorySchema.nullable(), + title: z.string(), + summaryMd: z.string().nullable().optional(), + statusBefore: situationStatusSchema.nullable(), + statusAfter: situationStatusSchema.nullable(), + documentUrl: z.string().url().nullable(), +}) + +export const situationWeeklyIssueSchema = z.object({ + object: z.literal("situation_weekly_issue"), + id: z.string(), + issueNumber: z.number().int().positive(), + slug: z.string(), + periodStart: z.string(), + periodEnd: z.string(), + title: z.string(), + summaryMd: z.string().nullable(), + markdown: z.string(), + sourceSituationIds: z.array(z.string()), + sourceEventIds: z.array(z.string()), + sourceEvents: z.array(situationWeeklyIssueEventSchema).default([]), + publishedAt: z.string(), + situations: z.array(situationWeeklyIssueSituationSchema), +}) + +export type SituationWeeklyIssue = z.infer + +export const situationWeeklyIssueListSchema = z.object({ + object: z.literal("list"), + data: z.array(situationWeeklyIssueSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}) + +export type SituationWeeklyIssueList = z.infer + +export const situationStrippedSchema = z.object({ + object: z.literal("situation_stripped"), + id: z.string(), + type: situationTypeSchema, + subtype: situationSubtypeSchema.nullable(), + status: situationStatusSchema, + ticker: z.string().nullable(), + cik: z.string(), + entityName: z.string(), + headline: z.string(), + announced: z.string().nullable(), + formTypes: z.array(z.string()).default([]), + sourceUrl: z.string().nullable(), +}) + +export type SituationStripped = z.infer + +export const situationStrippedListSchema = z.object({ + object: z.literal("list"), + data: z.array(situationStrippedSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}) + +export type SituationStrippedList = z.infer + +export const situationSummarySchema = z.object({ + object: z.literal("situation_summary"), + id: z.string(), + summaryMd: z.string().nullable(), + terms: situationTermsSchema, + latestEvent: situationEventSchema.nullable(), +}) + +export type SituationSummary = z.infer + +export const situationUnderwritingPackSchema = z.object({ + object: z.literal("situation_underwriting_pack"), + id: z.string(), + generatedAt: z.string(), + research: z.object({ + headline: z.string(), + businessProfile: z.string().nullable(), + narrative: z.string().nullable(), + }), + situation: situationDetailSchema, + markdown: z.string(), +}) + +export type SituationUnderwritingPack = z.infer + +export const situationFeedItemSchema = z.object({ + object: z.literal("situation_feed_item"), + event: situationEventSchema, + situation: z.object({ + id: z.string(), + type: situationTypeSchema, + subtype: situationSubtypeSchema.nullable(), + status: situationStatusSchema, + ticker: z.string().nullable(), + entityName: z.string(), + headline: z.string(), + market: situationMarketContextSchema, + }), +}) + +export type SituationFeedItem = z.infer + +export const situationFeedItemListSchema = z.object({ + object: z.literal("list"), + data: z.array(situationFeedItemSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}) + +export type SituationFeedItemList = z.infer + +export const SITUATION_CALENDAR_DATE_TYPES = ["record", "vote", "expiry", "expected_close"] as const +export const situationCalendarDateTypeSchema = z.enum(SITUATION_CALENDAR_DATE_TYPES) +export type SituationCalendarDateType = z.infer + +export const situationCalendarEntrySchema = z.object({ + object: z.literal("situation_calendar_entry"), + date: z.string(), + dateType: situationCalendarDateTypeSchema, + situationId: z.string(), + type: situationTypeSchema, + subtype: situationSubtypeSchema.nullable(), + status: situationStatusSchema, + ticker: z.string().nullable(), + entityName: z.string(), + headline: z.string(), +}) + +export type SituationCalendarEntry = z.infer + +export const situationCalendarEntryListSchema = z.object({ + object: z.literal("list"), + data: z.array(situationCalendarEntrySchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}) + +export type SituationCalendarEntryList = z.infer + +export const situationStatsSchema = z.object({ + object: z.literal("situation_stats"), + asOf: z.string(), + window: z.string().nullable(), + total: z.number().int().nonnegative(), + byType: z.record(z.string(), z.number().int().nonnegative()), + byStatus: z.record(z.string(), z.number().int().nonnegative()), + bySector: z.record(z.string(), z.number().int().nonnegative()), + byMarketCapBucket: z.record(z.string(), z.number().int().nonnegative()), +}) + +export type SituationStats = z.infer + +export const situationPerformanceCohortSchema = z.object({ + type: situationTypeSchema, + subtype: situationSubtypeSchema.nullable(), + closedCount: z.number().int().nonnegative(), + completedCount: z.number().int().nonnegative(), + terminatedCount: z.number().int().nonnegative(), + expiredCount: z.number().int().nonnegative(), + completionRate: z.number().min(0).max(1).nullable(), + medianDaysToClose: z.number().nullable(), + avgPremiumPct: z.number().nullable(), +}) + +export const situationPerformanceSchema = z.object({ + object: z.literal("situation_performance"), + asOf: z.string(), + window: z.string().nullable(), + cohorts: z.array(situationPerformanceCohortSchema).default([]), +}) + +export type SituationPerformance = z.infer + +export const situationEventListSchema = z.object({ + object: z.literal("list"), + data: z.array(situationEventSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}) + +export type SituationEventList = z.infer + +const commaListOf = (item: T) => + z.union([z.array(item), z.string()]).optional() + +export const situationsListQuerySchema = z.object({ + types: commaListOf(situationTypeSchema), + subtypes: commaListOf(situationSubtypeSchema), + statuses: commaListOf(situationStatusSchema), + tickers: commaListOf(z.string()), + forms: commaListOf(z.string()), + sectors: commaListOf(z.string()), + market_cap: commaListOf(marketCapBucketSchema), + country: z.string().optional(), + announced_from: z.string().optional(), + announced_to: z.string().optional(), + updated_from: z.string().optional(), + enrich: z.enum(["true", "false"]).optional(), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).optional(), + response_mode: z.enum(["compact", "standard", "verbose", "agent"]).optional(), +}) + +export type SituationsListQuery = z.infer + +export const situationsByFormQuerySchema = z.object({ + subtypes: commaListOf(situationSubtypeSchema), + statuses: commaListOf(situationStatusSchema), + tickers: commaListOf(z.string()), + sectors: commaListOf(z.string()), + market_cap: commaListOf(marketCapBucketSchema), + country: z.string().optional(), + announced_from: z.string().optional(), + announced_to: z.string().optional(), + updated_from: z.string().optional(), + enrich: z.enum(["true", "false"]).optional(), + cursor: z.union([z.string(), z.number().int().nonnegative()]).optional(), + limit: z.number().int().min(1).max(100).optional(), + response_mode: z.enum(["compact", "standard", "verbose", "agent"]).optional(), +}) + +export type SituationsByFormQuery = z.infer + +export const situationsFeedQuerySchema = z.object({ + types: commaListOf(situationTypeSchema), + categories: commaListOf(situationEventCategorySchema), + tickers: commaListOf(z.string()), + since: z.string().optional(), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).optional(), + response_mode: z.enum(["compact", "standard", "verbose", "agent"]).optional(), +}) + +export type SituationsFeedQuery = z.infer + +export const situationsCalendarQuerySchema = z.object({ + types: commaListOf(situationTypeSchema), + date_types: commaListOf(situationCalendarDateTypeSchema), + tickers: commaListOf(z.string()), + days: z.number().int().min(1).max(365).optional(), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).optional(), +}) + +export type SituationsCalendarQuery = z.infer + +export const situationsPerformanceQuerySchema = z.object({ + types: commaListOf(situationTypeSchema), + window: z.string().optional(), + group_by: z.enum(["type", "subtype"]).optional(), +}) + +export type SituationsPerformanceQuery = z.infer diff --git a/src/index.ts b/src/index.ts index 9c5a998..11cccad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,20 @@ import { modelFactorAnalysisSchema, responseViewSchema, } from "./generated-contracts/index.js" -import type { Entity, Filing, Section } from "./generated-contracts/index.js" +import type { + Entity, + Filing, + MarketCapBucket, + Section, + SituationCalendarDateType, + SituationEventCategory, + SituationStatus, + SituationSubtype, + SituationType, + SituationUnderwritingPack, + SituationWeeklyIssue, + SituationWeeklyIssueList, +} from "./generated-contracts/index.js" import type { z } from "zod" /** @@ -79,7 +92,7 @@ export type ResponseView = z.infer const DEFAULT_BASE_URL = "https://api.secapi.ai" const DEFAULT_API_VERSION = "2026-03-19" -export const SDK_VERSION = "1.1.0" +export const SDK_VERSION = "1.2.0" const POSTHOG_CAPTURE_HOST = "https://us.i.posthog.com" const SAFE_RETRY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]) @@ -403,6 +416,98 @@ export type DashboardAppearanceUpdate = z.infer export type DashboardOrganizationUpdate = z.infer +export type MonitorEmailDelivery = { type: "email"; config: { to: string } } +export type MonitorWebhookFanoutDelivery = { type: "webhook"; config: { organizationEventFanout: true } } + +export type SituationWatchFilters = { + situationIds?: string[] + types?: SituationType[] + subtypes?: SituationSubtype[] + statuses?: SituationStatus[] + tickers?: string[] + sectors?: string[] +} + +type SituationWatchDelivery = + | { email: string; useOrgWebhooks?: never } + | { useOrgWebhooks: true; email?: never } + +export type SituationWatchParams = SituationWatchFilters & SituationWatchDelivery & { + name?: string + startAt?: string +} + +type CommaSeparated = T | T[] + +export type SituationsListParams = { + types?: CommaSeparated + subtypes?: CommaSeparated + statuses?: CommaSeparated + tickers?: CommaSeparated + forms?: CommaSeparated + sectors?: CommaSeparated + market_cap?: CommaSeparated + country?: string + announced_from?: string + announced_to?: string + updated_from?: string + enrich?: "true" | "false" + cursor?: string | number + limit?: number + response_mode?: "compact" | "standard" | "verbose" | "agent" +} + +export type SituationsByFormParams = Omit + +export type SituationsFeedParams = { + types?: CommaSeparated + categories?: CommaSeparated + tickers?: CommaSeparated + since?: string + cursor?: string | number + limit?: number + response_mode?: "compact" | "standard" | "verbose" | "agent" +} + +export type SituationsFeedRssParams = Pick + +export type SituationsCalendarParams = { + types?: CommaSeparated + date_types?: CommaSeparated + tickers?: CommaSeparated + days?: number + cursor?: string | number + limit?: number +} + +export type SituationsPerformanceParams = { + types?: CommaSeparated + window?: string + group_by?: "type" | "subtype" +} + +export type KeywordMonitorCreateBody = { + name: string + query: string + filters?: Record + searchMode?: "keyword" + startAt?: string + webhookUrl?: string + delivery?: MonitorEmailDelivery +} + +export type SituationMonitorCreateBody = { + name: string + query: string + searchMode: "situation" + filters: SituationWatchFilters + startAt?: string + delivery: MonitorEmailDelivery | MonitorWebhookFanoutDelivery + webhookUrl?: never +} + +export type CreateMonitorBody = KeywordMonitorCreateBody | SituationMonitorCreateBody + type ParsedResponse = { payload: unknown requestId?: string @@ -581,6 +686,17 @@ function nestedRecord(value: unknown, key: string): Record | un return isRecord(value) && isRecord(value[key]) ? value[key] as Record : undefined } +function compactSituationWatchFilters(params: SituationWatchFilters): SituationWatchFilters { + return { + ...(params.situationIds?.length ? { situationIds: params.situationIds } : {}), + ...(params.types?.length ? { types: params.types } : {}), + ...(params.subtypes?.length ? { subtypes: params.subtypes } : {}), + ...(params.statuses?.length ? { statuses: params.statuses } : {}), + ...(params.tickers?.length ? { tickers: params.tickers } : {}), + ...(params.sectors?.length ? { sectors: params.sectors } : {}), + } +} + function defaultPageItems(page: unknown): readonly T[] { if (!isRecord(page)) return [] for (const key of ["data", "items", "results", "sections", "filings"]) { @@ -950,6 +1066,24 @@ export class SecApiClient { creditRating: (...args: Parameters) => this.macroCreditRating(...args), } + readonly situations = { + list: (...args: Parameters) => this.listSituations(...args), + issues: (...args: Parameters) => this.situationsIssues(...args), + issue: (...args: Parameters) => this.situationIssue(...args), + byForm: (...args: Parameters) => this.situationsByForm(...args), + get: (...args: Parameters) => this.getSituation(...args), + filings: (...args: Parameters) => this.situationFilings(...args), + summary: (...args: Parameters) => this.situationSummary(...args), + underwrite: (...args: Parameters) => this.underwriteSituation(...args), + export: (...args: Parameters) => this.exportSituation(...args), + watch: (...args: Parameters) => this.watchSituations(...args), + feed: (...args: Parameters) => this.situationsFeed(...args), + feedRss: (...args: Parameters) => this.situationsFeedRss(...args), + calendar: (...args: Parameters) => this.situationsCalendar(...args), + stats: (...args: Parameters) => this.situationsStats(...args), + performance: (...args: Parameters) => this.situationsPerformance(...args), + } + readonly portfolio = { optimize: (...args: Parameters) => this.portfolioOptimize(...args), hedge: (...args: Parameters) => this.portfolioHedge(...args), @@ -1444,14 +1578,7 @@ export class SecApiClient { } async createMonitor( - body: { - name: string - query: string - filters?: Record - searchMode?: "keyword" - webhookUrl?: string - delivery?: { type: "email"; config: { to: string } } - }, + body: CreateMonitorBody, options?: RequestOptions, ) { return this.request("/v1/monitors", { @@ -2014,6 +2141,37 @@ export class SecApiClient { return this.get(`/v1/macro/credit-ratings/${encodeURIComponent(country)}`) } + async situationsFeed( + params: RequestParams = {}, + ) { + return this.get("/v1/situations/feed", params) + } + + /** Return the paid authenticated RSS projection of the Special Situations feed. */ + async situationsFeedRss(params: RequestParams = {}) { + return this.get("/v1/situations/feed.rss", params) + } + + async situationsCalendar( + params: RequestParams = {}, + ) { + return this.get("/v1/situations/calendar", params) + } + + async situationsStats(params: RequestParams<{ window?: string }> = {}) { + return this.get("/v1/situations/stats", params) + } + + async situationsPerformance( + params: RequestParams = {}, + ) { + return this.get("/v1/situations/performance", params) + } + + async situationDetail(situationId: string, params: RequestParams> = {}) { + return this.get(`/v1/situations/${encodeURIComponent(situationId)}`, params) + } + async factorCatalog(params: RequestParams = {}) { return this.get("/v1/factors/catalog", params) } @@ -2513,6 +2671,77 @@ export class SecApiClient { }, undefined, options) } + async listSituations(params: RequestParams = {}) { + return this.get("/v1/situations", params) + } + + async situationsIssues(params: RequestParams<{ limit?: number }> = {}) { + return this.get("/v1/situations/issues", params) + } + + async situationIssue(issue: string | number, options?: RequestOptions) { + return this.request(`/v1/situations/issues/${encodeURIComponent(String(issue))}`, {}, undefined, options) + } + + async situationsByForm(form: string, params: RequestParams = {}) { + return this.get(`/v1/situations/by-form/${encodeURIComponent(form)}`, params) + } + + async getSituation(situationId: string, params: RequestParams<{ enrich?: "true" | "false" }> = {}) { + return this.get(`/v1/situations/${encodeURIComponent(situationId)}`, params) + } + + async situationFilings(situationId: string, params: RequestParams<{ cursor?: number; limit?: number }> = {}) { + return this.get(`/v1/situations/${encodeURIComponent(situationId)}/filings`, params) + } + + async situationSummary(situationId: string, options?: RequestOptions) { + return this.request(`/v1/situations/${encodeURIComponent(situationId)}/summary`, {}, undefined, options) + } + + async exportSituation(situationId: string, options?: RequestOptions) { + return this.request(`/v1/situations/${encodeURIComponent(situationId)}/export`, {}, undefined, options) + } + + async underwriteSituation(situationId: string, options?: RequestOptions) { + return this.request(`/v1/situations/${encodeURIComponent(situationId)}/underwriting-pack`, {}, undefined, options) + } + + async watchSituations(params: SituationWatchParams, options?: RequestOptions) { + const filters = compactSituationWatchFilters(params) + if (Object.keys(filters).length === 0) { + throw new SecApiError({ + message: "situations.watch requires at least one filter: situationIds, types, subtypes, statuses, tickers, or sectors.", + status: 0, + code: "client_situation_watch_filter_required", + }) + } + + const email = typeof params.email === "string" ? params.email.trim() : undefined + const hasEmail = Boolean(email) + const hasWebhookFanout = params.useOrgWebhooks === true + if (hasEmail === hasWebhookFanout) { + throw new SecApiError({ + message: "situations.watch requires exactly one delivery destination: email or useOrgWebhooks.", + status: 0, + code: hasEmail ? "client_situation_watch_delivery_conflict" : "client_situation_watch_delivery_required", + }) + } + + const delivery: MonitorEmailDelivery | MonitorWebhookFanoutDelivery = hasEmail + ? { type: "email", config: { to: email! } } + : { type: "webhook", config: { organizationEventFanout: true } } + + return this.createMonitor({ + name: params.name ?? "Special Situations watch", + query: "situations.watch", + searchMode: "situation", + filters, + ...(params.startAt ? { startAt: params.startAt } : {}), + delivery, + }, options) + } + async mcpInfo(options?: RequestOptions) { return this.request("/mcp", {}, undefined, options) } @@ -2815,4 +3044,31 @@ export type { DilutionReverseSplit, DilutionShareFloatHistory, DilutionCoverage, + MarketCapBucket, + Situation, + SituationDetail, + SituationEvent, + SituationEventList, + SituationFeedItem, + SituationFeedItemList, + SituationCalendarDateType, + SituationCalendarEntry, + SituationCalendarEntryList, + SituationStats, + SituationPerformance, + SituationStatus, + SituationSubtype, + SituationSummary, + SituationType, + SituationUnderwritingPack, + SituationWeeklyIssue, + SituationWeeklyIssueList, + SituationList, + SituationStripped, + SituationStrippedList, + SituationsByFormQuery, + SituationsCalendarQuery, + SituationsFeedQuery, + SituationsListQuery, + SituationsPerformanceQuery, } from "./generated-contracts/index.js" diff --git a/test/agent-helpers.test.ts b/test/agent-helpers.test.ts index bdab6fc..a9f78af 100644 --- a/test/agent-helpers.test.ts +++ b/test/agent-helpers.test.ts @@ -8,6 +8,7 @@ import { SecApiValidationError, type OmniDatastreamClientOptions, } from "../src/index.js" +import { openApiDocument, situationsByFormQuerySchema, situationsListQuerySchema } from "../src/generated-contracts/index.js" function jsonResponse(body: unknown = { ok: true }) { return new Response(JSON.stringify(body), { @@ -17,6 +18,251 @@ function jsonResponse(body: unknown = { ok: true }) { } describe("SecApiClient agent helpers", () => { + test("groups the complete paid situations read workflow", async () => { + const paths: string[] = [] + const client = new SecApiClient({ + apiKey: "test_key", + telemetry: false, + fetch: async (url) => { + const path = new URL(String(url)).pathname + paths.push(path) + if (path.endsWith("/export")) { + return new Response("# Example Corp", { headers: { "content-type": "text/markdown; charset=utf-8" } }) + } + return jsonResponse({ ok: true }) + }, + }) + + await client.situations.feed() + await client.situations.calendar() + await client.situations.stats() + await client.situations.performance() + await client.situations.export("sit_123") + + expect(paths).toEqual([ + "/v1/situations/feed", + "/v1/situations/calendar", + "/v1/situations/stats", + "/v1/situations/performance", + "/v1/situations/sit_123/export", + ]) + }) + + test("groups every Special Situations archive and underwriting operation", async () => { + const paths: string[] = [] + const client = new SecApiClient({ + apiKey: "test_key", + telemetry: false, + fetch: async (url) => { + const path = new URL(String(url)).pathname + paths.push(path) + if (path.endsWith("/export")) { + return new Response("# Example Corp", { headers: { "content-type": "text/markdown; charset=utf-8" } }) + } + return jsonResponse({ ok: true }) + }, + }) + + await client.situations.list({ types: "merger" }) + await client.situations.issues({ limit: 3 }) + await client.situations.issue(22) + await client.situations.byForm("SC 13D") + await client.situations.get("sit_123") + await client.situations.filings("sit_123") + await client.situations.summary("sit_123") + await client.situations.underwrite("sit_123") + await client.situations.export("sit_123") + await client.situations.feed() + await client.situations.calendar() + await client.situations.stats() + await client.situations.performance() + + expect(paths).toEqual([ + "/v1/situations", + "/v1/situations/issues", + "/v1/situations/issues/22", + "/v1/situations/by-form/SC%2013D", + "/v1/situations/sit_123", + "/v1/situations/sit_123/filings", + "/v1/situations/sit_123/summary", + "/v1/situations/sit_123/underwriting-pack", + "/v1/situations/sit_123/export", + "/v1/situations/feed", + "/v1/situations/calendar", + "/v1/situations/stats", + "/v1/situations/performance", + ]) + }) + + test("serializes public situations filters and the RSS feed consistently", async () => { + const seen: string[] = [] + const client = new SecApiClient({ + telemetry: false, + fetch: async (url) => { + seen.push(String(url)) + if (String(url).includes("feed.rss")) { + return new Response("", { headers: { "content-type": "application/rss+xml; charset=utf-8" } }) + } + return jsonResponse() + }, + }) + + await client.situations.list({ + subtypes: ["definitive"], + market_cap: ["large", "mega"], + announced_from: "2026-01-01", + announced_to: "2026-06-30", + updated_from: "2026-07-01T00:00:00.000Z", + }) + await client.situations.byForm("SC 13D", { + subtypes: ["definitive"], + statuses: ["pending"], + tickers: ["AAPL", "MSFT"], + sectors: ["Technology"], + market_cap: ["large", "mega"], + country: "US", + announced_from: "2026-01-01", + announced_to: "2026-06-30", + updated_from: "2026-07-01T00:00:00.000Z", + }) + const rss = await client.situations.feedRss({ types: ["merger"], tickers: ["AAPL", "MSFT"] }) + + expect(rss).toBe("") + expect(seen).toEqual([ + "https://api.secapi.ai/v1/situations?subtypes=definitive&market_cap=large%2Cmega&announced_from=2026-01-01&announced_to=2026-06-30&updated_from=2026-07-01T00%3A00%3A00.000Z", + "https://api.secapi.ai/v1/situations/by-form/SC%2013D?subtypes=definitive&statuses=pending&tickers=AAPL%2CMSFT§ors=Technology&market_cap=large%2Cmega&country=US&announced_from=2026-01-01&announced_to=2026-06-30&updated_from=2026-07-01T00%3A00%3A00.000Z", + "https://api.secapi.ai/v1/situations/feed.rss?types=merger&tickers=AAPL%2CMSFT", + ]) + }) + + test("keeps the generated public Situations contract aligned with SDK routes", () => { + const situationPaths = Object.keys(openApiDocument.paths).filter((path) => path.includes("situations")) + + expect(situationPaths).toEqual([ + "/v1/situations", + "/v1/situations/issues", + "/v1/situations/issues/{issue}", + "/v1/situations/feed", + "/v1/situations/feed.rss", + "/v1/situations/calendar", + "/v1/situations/stats", + "/v1/situations/performance", + "/v1/situations/by-form/{form}", + "/v1/situations/{situation_id}/filings", + "/v1/situations/{situation_id}/summary", + "/v1/situations/{situation_id}/underwriting-pack", + "/v1/situations/{situation_id}/export", + "/v1/situations/{situation_id}", + ]) + expect(openApiDocument.paths["/v1/situations"].get.parameters.map((parameter) => parameter.name)).not.toContain("since") + expect(openApiDocument.paths["/v1/situations/by-form/{form}"].get.parameters.map((parameter) => parameter.name)).toEqual([ + "form", "subtypes", "statuses", "tickers", "sectors", "market_cap", "country", "announced_from", "announced_to", "updated_from", "enrich", "cursor", "limit", "response_mode", + ]) + expect(situationsListQuerySchema.shape).not.toHaveProperty("since") + expect(situationsByFormQuerySchema.parse({ subtypes: ["definitive"], market_cap: ["large"] })).toEqual({ subtypes: ["definitive"], market_cap: ["large"] }) + }) + + test("situations watch creates a public situation monitor with email delivery", async () => { + const seen: Array<{ url: string; method?: string; body?: unknown }> = [] + const client = new SecApiClient({ + telemetry: false, + fetch: async (url, init) => { + seen.push({ + url: String(url), + method: init?.method, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }) + return jsonResponse({ ok: true }) + }, + }) + + await client.situations.watch({ + name: "Acquisition watch", + types: ["merger", "tender_offer"], + tickers: ["AAPL", "MSFT"], + startAt: "2026-07-10T16:00:00.000Z", + email: " you@example.com ", + }) + + expect(seen).toEqual([ + { + url: "https://api.secapi.ai/v1/monitors", + method: "POST", + body: { + name: "Acquisition watch", + query: "situations.watch", + searchMode: "situation", + filters: { types: ["merger", "tender_offer"], tickers: ["AAPL", "MSFT"] }, + startAt: "2026-07-10T16:00:00.000Z", + delivery: { type: "email", config: { to: "you@example.com" } }, + }, + }, + ]) + }) + + test("situations watch creates a public situation monitor with organization webhook fanout", async () => { + const seen: Array<{ url: string; method?: string; body?: unknown }> = [] + const client = new SecApiClient({ + telemetry: false, + fetch: async (url, init) => { + seen.push({ + url: String(url), + method: init?.method, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }) + return jsonResponse({ ok: true }) + }, + }) + + await client.situations.watch({ + situationIds: ["sit_0123456789abcdef0123"], + statuses: ["announced"], + sectors: ["Technology"], + useOrgWebhooks: true, + }) + + expect(seen).toEqual([ + { + url: "https://api.secapi.ai/v1/monitors", + method: "POST", + body: { + name: "Special Situations watch", + query: "situations.watch", + searchMode: "situation", + filters: { + situationIds: ["sit_0123456789abcdef0123"], + statuses: ["announced"], + sectors: ["Technology"], + }, + delivery: { type: "webhook", config: { organizationEventFanout: true } }, + }, + }, + ]) + }) + + test("situations watch rejects missing filters and invalid delivery choices before fetch", async () => { + const client = new SecApiClient({ + telemetry: false, + fetch: async () => { + throw new Error("fetch should not run for invalid situations.watch options") + }, + }) + + await expect(client.situations.watch({ email: "you@example.com" } as never)).rejects.toMatchObject({ + code: "client_situation_watch_filter_required", + }) + await expect(client.situations.watch({ types: ["merger"] } as never)).rejects.toMatchObject({ + code: "client_situation_watch_delivery_required", + }) + await expect(client.situations.watch({ + types: ["merger"], + email: "you@example.com", + useOrgWebhooks: true, + } as never)).rejects.toMatchObject({ + code: "client_situation_watch_delivery_conflict", + }) + }) + test("loads auth and base URL from environment when constructor options are omitted", async () => { const previous = { apiKey: process.env.SECAPI_API_KEY, @@ -852,6 +1098,13 @@ describe("SecApiClient agent helpers", () => { query: "latest 8-K filings", filters: { forms: ["8-K", "8-K/A"], tickers: ["AAPL", "AMZN"] }, }) + await client.createMonitor({ + name: "Exact situation monitor", + query: "situations.watch", + searchMode: "situation", + filters: { situationIds: ["sit_0123456789abcdef0123"] }, + delivery: { type: "webhook", config: { organizationEventFanout: true } }, + }) await client.monitorMatches("mon/with spaces", { limit: 5 }) expect(seen[0]).toEqual({ @@ -873,6 +1126,17 @@ describe("SecApiClient agent helpers", () => { }, }) expect(seen[2]).toEqual({ + url: "https://api.secapi.ai/v1/monitors", + method: "POST", + body: { + name: "Exact situation monitor", + query: "situations.watch", + searchMode: "situation", + filters: { situationIds: ["sit_0123456789abcdef0123"] }, + delivery: { type: "webhook", config: { organizationEventFanout: true } }, + }, + }) + expect(seen[3]).toEqual({ url: "https://api.secapi.ai/v1/monitors/mon%2Fwith%20spaces/matches?limit=5", method: undefined, body: undefined, @@ -954,6 +1218,25 @@ describe("SecApiClient agent helpers", () => { ]) }) + test("situations export escapes opaque ids and is available from the namespace alias", async () => { + const seen: string[] = [] + const client = new SecApiClient({ + telemetry: false, + fetch: async (url) => { + seen.push(String(url)) + return new Response("# Situation brief", { + status: 200, + headers: { "content-type": "text/markdown; charset=utf-8" }, + }) + }, + }) + + const markdown = await client.situations.export("sit/with spaces") + + expect(markdown).toBe("# Situation brief") + expect(seen).toEqual(["https://api.secapi.ai/v1/situations/sit%2Fwith%20spaces/export"]) + }) + test("filing helpers escape opaque path ids", async () => { const seenUrls: string[] = [] const client = new SecApiClient({ diff --git a/test/retry.test.ts b/test/retry.test.ts index 5882d34..7c88e26 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -122,6 +122,7 @@ function dashboardSettingsPayload(overrides: Record = {}) { describe("SecApiClient retry behavior", () => { test("SDK_VERSION matches package metadata", async () => { + expect(SDK_VERSION).toBe("1.2.0") expect(SDK_VERSION).toBe(await packageVersion()) }) From 810e67479dc6df872b1792ae5382d464533d3cd8 Mon Sep 17 00:00:00 2001 From: Hyperion <89154626+daniel-scrivner@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:29:46 -0600 Subject: [PATCH 2/2] fix(situations): align SDK contract with production --- src/generated-contracts/openapi.ts | 26 ++++++++++++++++++++++++++ src/generated-contracts/situations.ts | 20 -------------------- test/agent-helpers.test.ts | 18 +++++++++++++++++- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/generated-contracts/openapi.ts b/src/generated-contracts/openapi.ts index e4afca9..1e03908 100644 --- a/src/generated-contracts/openapi.ts +++ b/src/generated-contracts/openapi.ts @@ -1286,6 +1286,18 @@ const jsonResponseOneOf = (names: string[], description = "Successful response") }, }) +// Mirrors the deployed public Special Situations operation posture. Meter +// classes remain runtime billing behavior, rather than a speculative OpenAPI +// extension: production does not expose them in the document. +const situationsOperationMetadata = (routeId: "situations.collection" | "situations.issues" | "situations.issue" | "situations.member") => ({ + security: [{ ApiKeyAuth: [] }, { BearerAuth: [] }], + "x-secapi-route-id": routeId, + "x-secapi-access": "authenticated", + "x-secapi-kind": "api", + "x-secapi-docs": "public", + "x-secapi-smoke": "local", +}) + const jsonStatusResponse = (status: string, name: string, description = "Successful response") => ({ responses: { [status]: { @@ -4328,6 +4340,7 @@ export const openApiDocument = { "/v1/situations": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.collection"), summary: "List durable SEC-derived special situations with lifecycle, terms, and market context", parameters: situationsListParameters, ...jsonResponseOneOf(["SituationList", "SituationStrippedList"]), @@ -4336,6 +4349,7 @@ export const openApiDocument = { "/v1/situations/issues": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.issues"), summary: "List immutable, numbered weekly Special Situations Digest issues", parameters: [{ name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 100, default: 24 } }], ...jsonResponse("SituationWeeklyIssueList"), @@ -4344,6 +4358,7 @@ export const openApiDocument = { "/v1/situations/issues/{issue}": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.issue"), summary: "Retrieve one immutable weekly Special Situations Digest issue by number or slug", parameters: [{ name: "issue", in: "path", required: true, schema: { type: "string" } }], ...jsonResponse("SituationWeeklyIssue"), @@ -4352,6 +4367,7 @@ export const openApiDocument = { "/v1/situations/feed": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Reverse-chronological feed of SEC-derived situation events", parameters: [ { name: "types", in: "query", required: false, schema: { type: "string" } }, @@ -4367,6 +4383,7 @@ export const openApiDocument = { "/v1/situations/feed.rss": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Paid authenticated RSS projection of SEC-derived situation events", parameters: [ { name: "types", in: "query", required: false, schema: { type: "string" } }, @@ -4379,6 +4396,7 @@ export const openApiDocument = { "/v1/situations/calendar": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Upcoming SEC-derived situation key dates", parameters: [ { name: "types", in: "query", required: false, schema: { type: "string" } }, @@ -4394,6 +4412,7 @@ export const openApiDocument = { "/v1/situations/stats": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Situation counts by type, status, sector, and market-cap bucket", parameters: [{ name: "window", in: "query", required: false, schema: { type: "string" } }], ...jsonResponse("SituationStats"), @@ -4402,6 +4421,7 @@ export const openApiDocument = { "/v1/situations/performance": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Closed-situation outcome cohorts", parameters: [ { name: "types", in: "query", required: false, schema: { type: "string" } }, @@ -4414,6 +4434,7 @@ export const openApiDocument = { "/v1/situations/by-form/{form}": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "List situations opened or advanced by an EDGAR form type", parameters: [ { name: "form", in: "path", required: true, schema: { type: "string" } }, @@ -4437,6 +4458,7 @@ export const openApiDocument = { "/v1/situations/{situation_id}/filings": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Retrieve a situation filing timeline", parameters: [ { name: "situation_id", in: "path", required: true, schema: { type: "string" } }, @@ -4449,6 +4471,7 @@ export const openApiDocument = { "/v1/situations/{situation_id}/summary": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Retrieve a compact situation summary", parameters: [{ name: "situation_id", in: "path", required: true, schema: { type: "string" } }], ...jsonResponse("SituationSummary"), @@ -4457,6 +4480,7 @@ export const openApiDocument = { "/v1/situations/{situation_id}/underwriting-pack": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Retrieve a deterministic, source-cited underwriting pack", parameters: [{ name: "situation_id", in: "path", required: true, schema: { type: "string" } }], ...jsonResponse("SituationUnderwritingPack"), @@ -4465,6 +4489,7 @@ export const openApiDocument = { "/v1/situations/{situation_id}/export": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Render a source-cited Markdown Copy-for-LLM brief", parameters: [{ name: "situation_id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Markdown situation brief", content: { "text/markdown": { schema: { type: "string" } } } } }, @@ -4473,6 +4498,7 @@ export const openApiDocument = { "/v1/situations/{situation_id}": { get: { tags: ["Special Situations"], + ...situationsOperationMetadata("situations.member"), summary: "Retrieve a single situation with its filing timeline", parameters: [ { name: "situation_id", in: "path", required: true, schema: { type: "string" } }, diff --git a/src/generated-contracts/situations.ts b/src/generated-contracts/situations.ts index 1b20d1f..2818651 100644 --- a/src/generated-contracts/situations.ts +++ b/src/generated-contracts/situations.ts @@ -1,13 +1,4 @@ import { z } from "zod" -import { - degradedStateSchema, - freshnessMetadataSchema, - materializationMetadataSchema, - methodologyMetadataSchema, - revisionMetadataSchema, - sourceRightsMetadataSchema, -} from "./foundation.js" -import { provenanceSchema } from "./schemas.js" import { dilutionVerificationSchema } from "./dilution.js" export const SITUATION_TYPES = [ @@ -216,16 +207,6 @@ export function formToSituationTypes(form: string): SituationType[] { return matched } -const situationInvestorMetadataShape = { - provenance: provenanceSchema, - freshness: freshnessMetadataSchema.optional(), - materialization: materializationMetadataSchema.optional(), - sourceRights: sourceRightsMetadataSchema, - methodology: methodologyMetadataSchema.optional(), - revision: revisionMetadataSchema.optional(), - degradedState: degradedStateSchema.nullable().optional(), -} as const - export const situationTermsSchema = z.object({ counterparty: z.string().nullable().optional(), counterpartyTicker: z.string().nullable().optional(), @@ -287,7 +268,6 @@ export const situationSchema = z.object({ latestEventAt: z.string().nullable(), providerKey: z.string(), verification: dilutionVerificationSchema, - ...situationInvestorMetadataShape, }) export type Situation = z.infer diff --git a/test/agent-helpers.test.ts b/test/agent-helpers.test.ts index a9f78af..3f24294 100644 --- a/test/agent-helpers.test.ts +++ b/test/agent-helpers.test.ts @@ -8,7 +8,7 @@ import { SecApiValidationError, type OmniDatastreamClientOptions, } from "../src/index.js" -import { openApiDocument, situationsByFormQuerySchema, situationsListQuerySchema } from "../src/generated-contracts/index.js" +import { openApiDocument, situationSchema, situationsByFormQuerySchema, situationsListQuerySchema } from "../src/generated-contracts/index.js" function jsonResponse(body: unknown = { ok: true }) { return new Response(JSON.stringify(body), { @@ -160,6 +160,22 @@ describe("SecApiClient agent helpers", () => { ]) expect(situationsListQuerySchema.shape).not.toHaveProperty("since") expect(situationsByFormQuerySchema.parse({ subtypes: ["definitive"], market_cap: ["large"] })).toEqual({ subtypes: ["definitive"], market_cap: ["large"] }) + + const situationOperations = situationPaths.map((path) => openApiDocument.paths[path].get) + for (const operation of situationOperations) { + expect(operation.security).toEqual([{ ApiKeyAuth: [] }, { BearerAuth: [] }]) + expect(operation["x-secapi-access"]).toBe("authenticated") + expect(operation["x-secapi-kind"]).toBe("api") + expect(operation["x-secapi-docs"]).toBe("public") + expect(operation["x-secapi-smoke"]).toBe("local") + } + expect(openApiDocument.paths["/v1/situations"].get["x-secapi-route-id"]).toBe("situations.collection") + expect(openApiDocument.paths["/v1/situations/issues"].get["x-secapi-route-id"]).toBe("situations.issues") + expect(openApiDocument.paths["/v1/situations/issues/{issue}"].get["x-secapi-route-id"]).toBe("situations.issue") + expect(openApiDocument.paths["/v1/situations/feed"].get["x-secapi-route-id"]).toBe("situations.member") + expect(situationSchema.shape).toHaveProperty("providerKey") + expect(situationSchema.shape).not.toHaveProperty("sourceRights") + expect(situationSchema.shape).not.toHaveProperty("provenance") }) test("situations watch creates a public situation monitor with email delivery", async () => {