Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,25 @@ sec issuer deal <cik> [--format json]
> operate on the company tier. Family-tier coverage/purge wiring is deferred (see the
> status doc's deferred cleanups).

### SPAC consolidated report

A CIK-keyed `spac` row consolidates the SPAC lifecycle for a quick report:
status, three-era names/SIC/tickers (`spac_*` / `post_merger_*` / `current_*`),
amounts (`ipo_proceeds`, `trust_amount`, `pipe_amount`, `total_redemption_amount`),
and rolled-up key dates. It is **derived** from two append-only tables — `spac_deal`
(one row per business-combination attempt) and `spac_event` (the dated timeline) —
so replays are idempotent; an `as_of` guard protects filing-sourced scalar fields
from out-of-order writes, and `spac_history` + `ChangeLog` version the row.

Today only the IPO half is populated (S-1/DRS → `registration`, priced 424B1/424B4
→ `ipo`); de-SPAC events (8-K items, S-4/proxy, redemptions, PIPE, de-registration)
are defined-but-deferred slots.

```bash
sec spac report <cik> [--format json] # consolidated report
sec spac history <cik> [--format json] # state-change history
```

### Reg A / Reg CF / funding portals

All 12 Form C submission types (including post-offering C-U / C-AR / C-TR),
Expand Down
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { addCanonicalCommands } from "../cli/groups/canonical";
import { addExtractorCommands } from "../cli/groups/extractor";
import { registerSponsorFamilyCommands } from "./sponsorFamily";
import { registerUnderwriterFamilyCommands } from "./underwriterFamily";
import { registerSpacCommands } from "./spac";
import { DefaultDI } from "../config/DefaultDI";
import { EnvToDI } from "../config/EnvToDI";
import { SEC_DRY_RUN } from "../config/tokens";
Expand Down Expand Up @@ -70,5 +71,6 @@ export const AddCommands = (program: Command): void => {
addCanonicalCommands(program);
registerSponsorFamilyCommands(program);
registerUnderwriterFamilyCommands(program);
registerSpacCommands(program);
addExtractorCommands(program);
};
34 changes: 34 additions & 0 deletions src/commands/spac.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { beforeEach, describe, expect, it } from "bun:test";
import { resetDependencyInjectionsForTesting } from "../config/TestingDI";
import { setupAllDatabases } from "../config/setupAllDatabases";
import { assembleSpacReport } from "./spac";
import { SpacReportWriter } from "../storage/spac/SpacReportWriter";

describe("assembleSpacReport", () => {
beforeEach(async () => {
resetDependencyInjectionsForTesting();
await setupAllDatabases();
});

it("assembles row + events for a populated SPAC", async () => {
await new SpacReportWriter().recordRegistration({
cik: 99,
accession_number: "reg",
filing_date: "2020-12-01",
form: "S-1",
primary_document: null,
spac_name: "Test SPAC",
spac_sic: 6770,
});
const report = await assembleSpacReport(99);
expect(report.spac?.spac_name).toBe("Test SPAC");
expect(report.events.length).toBe(1);
expect(report.sponsorCount).toBe(0);
});
});
113 changes: 113 additions & 0 deletions src/commands/spac.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { Command } from "commander";
import { globalServiceRegistry } from "workglow";
import { SpacRepo } from "../storage/spac/SpacRepo";
import { SPAC_SPONSOR_LINK_REPOSITORY_TOKEN } from "../storage/canonical/SpacSponsorLinkSchema";
import { UNDERWRITER_LINK_REPOSITORY_TOKEN } from "../storage/canonical/UnderwriterLinkSchema";

export interface SpacReport {
readonly cik: number;
readonly spac: Awaited<ReturnType<SpacRepo["getSpac"]>>;
readonly deals: Awaited<ReturnType<SpacRepo["getDeals"]>>;
readonly events: Awaited<ReturnType<SpacRepo["getEvents"]>>;
readonly sponsorCount: number;
readonly underwriterCount: number;
}

/**
* Assemble the consolidated report from the spac row + deals + events + linked lists.
* Sponsor and underwriter counts are obtained by querying the link tables by issuer_cik,
* since neither SpacSponsorLinkRepo nor UnderwriterLinkRepo exposes a listByIssuer method.
*/
export async function assembleSpacReport(cik: number, repo: SpacRepo = new SpacRepo()): Promise<SpacReport> {
const [spac, deals, events] = await Promise.all([
repo.getSpac(cik),
repo.getDeals(cik),
repo.getEvents(cik),
]);

const sponsorStorage = globalServiceRegistry.get(SPAC_SPONSOR_LINK_REPOSITORY_TOKEN);
const underwriterStorage = globalServiceRegistry.get(UNDERWRITER_LINK_REPOSITORY_TOKEN);

const [sponsorRows, underwriterRows] = await Promise.all([
sponsorStorage.query({ issuer_cik: cik }).then((r) => r ?? []),
underwriterStorage.query({ issuer_cik: cik }).then((r) => r ?? []),
]);

return {
cik,
spac,
deals,
events,
sponsorCount: sponsorRows.length,
underwriterCount: underwriterRows.length,
};
}

/** Parse a CLI CIK argument, returning null (after printing an error) when it is not a non-negative integer. */
function parseCikArg(cikArg: string): number | null {
const cik = Number(cikArg);
if (!Number.isInteger(cik) || cik < 0) {
console.error(`Invalid CIK: ${cikArg}`);
process.exitCode = 1;
return null;
}
return cik;
}

export function registerSpacCommands(program: Command): void {
// The sponsorFamily module may have already created the `spac` command (for
// `by-family`); reuse it so Commander doesn't see a duplicate subcommand.
let spacCmd = program.commands.find((c) => c.name() === "spac");
if (!spacCmd) {
spacCmd = program.command("spac").description("SPAC consolidated report and history");
}

spacCmd
.command("report <cik>")
.description("Consolidated SPAC report for a CIK")
.option("--format <format>", "output format: text | json", "text")
.action(async (cikArg: string, opts: { format: string }) => {
const cik = parseCikArg(cikArg);
if (cik === null) return;
const report = await assembleSpacReport(cik);
if (opts.format === "json") {
console.log(JSON.stringify(report, null, 2));
return;
}
if (!report.spac) {
console.log(`No SPAC record for CIK ${cik}`);
return;
}
const s = report.spac;
console.log(`SPAC ${cik}: ${s.spac_name ?? "(unknown)"} [${s.status}]`);
console.log(` registration: ${s.registration_date ?? "-"} ipo: ${s.ipo_date ?? "-"}`);
console.log(` ipo proceeds: ${s.ipo_proceeds ?? "-"} trust: ${s.trust_amount ?? "-"}`);
console.log(` deals: ${report.deals.length} events: ${report.events.length}`);
console.log(` sponsors: ${report.sponsorCount} underwriters: ${report.underwriterCount}`);
});

spacCmd
.command("history <cik>")
.description("State-change history for a SPAC")
.option("--format <format>", "output format: text | json", "text")
.action(async (cikArg: string, opts: { format: string }) => {
const cik = parseCikArg(cikArg);
if (cik === null) return;
const history = await new SpacRepo().getHistory(cik);
if (opts.format === "json") {
console.log(JSON.stringify(history, null, 2));
return;
}
for (const h of history) {
console.log(
`${h.valid_from} [${h.status ?? "-"}] via ${h.change_source}${h.valid_to ? "" : " (current)"}`
);
}
});
}
41 changes: 41 additions & 0 deletions src/config/DefaultDI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,26 @@ import {
Form8KEventPrimaryKeyNames,
Form8KEventSchema,
} from "../storage/form-8k-event/Form8KEventSchema";
import {
SPAC_REPOSITORY_TOKEN,
SpacPrimaryKeyNames,
SpacSchema,
} from "../storage/spac/SpacSchema";
import {
SPAC_DEAL_REPOSITORY_TOKEN,
SpacDealPrimaryKeyNames,
SpacDealSchema,
} from "../storage/spac/SpacDealSchema";
import {
SPAC_EVENT_REPOSITORY_TOKEN,
SpacEventPrimaryKeyNames,
SpacEventSchema,
} from "../storage/spac/SpacEventSchema";
import {
SPAC_HISTORY_REPOSITORY_TOKEN,
SpacHistoryPrimaryKeyNames,
SpacHistorySchema,
} from "../storage/spac/SpacHistorySchema";
import { createStorage } from "./createStorage";

export const DefaultDI = () => {
Expand Down Expand Up @@ -625,6 +645,27 @@ export const DefaultDI = () => {
])
);

// ------------------------------ SPAC ------------------------------------------
globalServiceRegistry.registerInstance(
SPAC_REPOSITORY_TOKEN,
createStorage("spac", SpacSchema, SpacPrimaryKeyNames, [["status"], ["current_cik"]])
);
globalServiceRegistry.registerInstance(
SPAC_DEAL_REPOSITORY_TOKEN,
createStorage("spac_deal", SpacDealSchema, SpacDealPrimaryKeyNames, [["cik"], ["outcome"]])
);
globalServiceRegistry.registerInstance(
SPAC_EVENT_REPOSITORY_TOKEN,
createStorage("spac_event", SpacEventSchema, SpacEventPrimaryKeyNames, [
["cik"],
["event_type"],
])
);
globalServiceRegistry.registerInstance(
SPAC_HISTORY_REPOSITORY_TOKEN,
createStorage("spac_history", SpacHistorySchema, SpacHistoryPrimaryKeyNames, [["cik"]])
);

// ----- Observation / Canonical / Resolver -----
globalServiceRegistry.registerInstance(
PERSON_OBSERVATION_REPOSITORY_TOKEN,
Expand Down
41 changes: 41 additions & 0 deletions src/config/TestingDI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,26 @@ import {
Form8KEventPrimaryKeyNames,
Form8KEventSchema,
} from "../storage/form-8k-event/Form8KEventSchema";
import {
SPAC_REPOSITORY_TOKEN,
SpacPrimaryKeyNames,
SpacSchema,
} from "../storage/spac/SpacSchema";
import {
SPAC_DEAL_REPOSITORY_TOKEN,
SpacDealPrimaryKeyNames,
SpacDealSchema,
} from "../storage/spac/SpacDealSchema";
import {
SPAC_EVENT_REPOSITORY_TOKEN,
SpacEventPrimaryKeyNames,
SpacEventSchema,
} from "../storage/spac/SpacEventSchema";
import {
SPAC_HISTORY_REPOSITORY_TOKEN,
SpacHistoryPrimaryKeyNames,
SpacHistorySchema,
} from "../storage/spac/SpacHistorySchema";
import {
CANONICAL_COMPANY_REPOSITORY_TOKEN,
CanonicalCompanyPrimaryKeyNames,
Expand Down Expand Up @@ -453,6 +473,27 @@ export function resetDependencyInjectionsForTesting() {
])
);

// SPAC repositories
globalServiceRegistry.registerInstance(
SPAC_REPOSITORY_TOKEN,
new InMemoryTabularStorage(SpacSchema, SpacPrimaryKeyNames, [["status"], ["current_cik"]])
);
globalServiceRegistry.registerInstance(
SPAC_DEAL_REPOSITORY_TOKEN,
new InMemoryTabularStorage(SpacDealSchema, SpacDealPrimaryKeyNames, [["cik"], ["outcome"]])
);
globalServiceRegistry.registerInstance(
SPAC_EVENT_REPOSITORY_TOKEN,
new InMemoryTabularStorage(SpacEventSchema, SpacEventPrimaryKeyNames, [
["cik"],
["event_type"],
])
);
globalServiceRegistry.registerInstance(
SPAC_HISTORY_REPOSITORY_TOKEN,
new InMemoryTabularStorage(SpacHistorySchema, SpacHistoryPrimaryKeyNames, [["cik"]])
);

// Initialize Crowdfunding repositories
globalServiceRegistry.registerInstance(
CROWDFUNDING_REPOSITORY_TOKEN,
Expand Down
8 changes: 8 additions & 0 deletions src/config/setupAllDatabases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ import { REGA_FINANCIAL_DATA_REPOSITORY_TOKEN } from "../storage/reg-a/RegAFinan
import { REGA_OFFERING_HISTORY_REPOSITORY_TOKEN } from "../storage/reg-a/RegAOfferingHistorySchema";
import { REGA_OFFERING_REPOSITORY_TOKEN } from "../storage/reg-a/RegAOfferingSchema";
import { REGA_SERVICE_PROVIDER_REPOSITORY_TOKEN } from "../storage/reg-a/RegAServiceProviderSchema";
import { SPAC_REPOSITORY_TOKEN } from "../storage/spac/SpacSchema";
import { SPAC_DEAL_REPOSITORY_TOKEN } from "../storage/spac/SpacDealSchema";
import { SPAC_EVENT_REPOSITORY_TOKEN } from "../storage/spac/SpacEventSchema";
import { SPAC_HISTORY_REPOSITORY_TOKEN } from "../storage/spac/SpacHistorySchema";
import {
CANONICAL_COMPANY_ALIAS_REPOSITORY_TOKEN,
CANONICAL_PERSON_ALIAS_REPOSITORY_TOKEN,
Expand Down Expand Up @@ -137,6 +141,10 @@ export async function setupAllDatabases(): Promise<void> {
await globalServiceRegistry.get(REGA_SERVICE_PROVIDER_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(REGA_FINANCIAL_DATA_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(REGA_EQUITY_CLASS_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(SPAC_DEAL_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(SPAC_EVENT_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(SPAC_HISTORY_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(CIK_LAST_UPDATE_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(PROCESSED_FACTS_REPOSITORY_TOKEN).setupDatabase();
await globalServiceRegistry.get(PROCESSED_SUBMISSIONS_REPOSITORY_TOKEN).setupDatabase();
Expand Down
Loading