Skip to content

Commit 1c658cd

Browse files
committed
feat: add 8-K filing support with parsing, storage, and event tracking
Implement full 8-K current report support: - Form_8_K.schema.ts: TypeBox schema for structured XML 8-K submissions - Form_8_K.ts: parse() handles both XML and HTML primary documents - Form_8_K.storage.ts: processForm8K extracts and stores event items, merging data from filing metadata and XML form data, plus signature processing - Form8KEventSchema/Repo: new storage layer for normalized 8-K event items (one row per item per filing) with queries by CIK, accession, and item code - ProcessAccessionDocFormTask: routes 8-K/8-K/A to processForm8K - DI registration in DefaultDI and TestingDI - 17 tests covering parsing, storage, amendments, signatures, and edge cases https://claude.ai/code/session_01SKG4qTyjPAtmuSipiEiAio
1 parent c461792 commit 1c658cd

14 files changed

Lines changed: 884 additions & 0 deletions

src/config/DefaultDI.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ import {
149149
RegAEquityClassPrimaryKeyNames,
150150
RegAEquityClassSchema,
151151
} from "../storage/reg-a/RegAEquityClassSchema";
152+
import {
153+
FORM_8K_EVENT_REPOSITORY_TOKEN,
154+
Form8KEventPrimaryKeyNames,
155+
Form8KEventSchema,
156+
} from "../storage/form-8k-event/Form8KEventSchema";
152157
import {
153158
CIK_LAST_UPDATE_REPOSITORY_TOKEN,
154159
CikLastUpdatePrimaryKeyNames,
@@ -490,4 +495,13 @@ export const DefaultDI = () => {
490495
["cik", "file_number"],
491496
])
492497
);
498+
499+
// ------------------------------ Form 8-K Events --------------------------------
500+
globalServiceRegistry.registerInstance(
501+
FORM_8K_EVENT_REPOSITORY_TOKEN,
502+
createStorage("form_8k_events", Form8KEventSchema, Form8KEventPrimaryKeyNames, [
503+
["cik", "filing_date"],
504+
["item_code"],
505+
])
506+
);
493507
};

src/config/TestingDI.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ import {
172172
CompanyFactsPrimaryKeyNames,
173173
CompanyFactsSchema,
174174
} from "../storage/facts/CompanyFactsSchema";
175+
import {
176+
FORM_8K_EVENT_REPOSITORY_TOKEN,
177+
Form8KEventPrimaryKeyNames,
178+
Form8KEventSchema,
179+
} from "../storage/form-8k-event/Form8KEventSchema";
175180

176181
export function resetDependencyInjectionsForTesting() {
177182
// Initialize Company repositories
@@ -432,4 +437,13 @@ export function resetDependencyInjectionsForTesting() {
432437
["entity_type", "entity_id"],
433438
])
434439
);
440+
441+
// Initialize Form 8-K Event repository
442+
globalServiceRegistry.registerInstance(
443+
FORM_8K_EVENT_REPOSITORY_TOKEN,
444+
new InMemoryTabularStorage(Form8KEventSchema, Form8KEventPrimaryKeyNames, [
445+
["cik", "filing_date"],
446+
["item_code"],
447+
])
448+
);
435449
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { Type, Static } from "typebox";
8+
import { ENTITY_NAME_TYPE, SCHEMA_VERSION_TYPE, CIK_TYPE } from "../FormSchemaUtil";
9+
10+
export const SubTypeList = Type.Union([Type.Literal("8-K"), Type.Literal("8-K/A")], {
11+
description: "Submission Type Form",
12+
});
13+
14+
const SIGNATURE_TYPE = Type.Object({
15+
signatureName: Type.String({ minLength: 1, maxLength: 150 }),
16+
signatureTitle: Type.Optional(Type.String({ maxLength: 150 })),
17+
signatureDate: Type.Optional(Type.String()),
18+
});
19+
20+
export type Form8KSignature = Static<typeof SIGNATURE_TYPE>;
21+
22+
const SIGNATURE_BLOCK_TYPE = Type.Object({
23+
signature: Type.Union([SIGNATURE_TYPE, Type.Array(SIGNATURE_TYPE)]),
24+
});
25+
26+
const FILER_INFO_TYPE = Type.Object({
27+
filerCik: Type.Optional(CIK_TYPE),
28+
filerCcc: Type.Optional(Type.String({ maxLength: 8 })),
29+
});
30+
31+
const HEADER_DATA_TYPE = Type.Object({
32+
filerInfo: Type.Optional(FILER_INFO_TYPE),
33+
});
34+
35+
const FORM_DATA_TYPE = Type.Object({
36+
items: Type.Optional(
37+
Type.Object({
38+
item: Type.Union([Type.String(), Type.Array(Type.String())]),
39+
})
40+
),
41+
periodOfReport: Type.Optional(Type.String()),
42+
signatureBlock: Type.Optional(SIGNATURE_BLOCK_TYPE),
43+
});
44+
45+
/**
46+
* Schema for 8-K filings submitted as structured XML through EDGAR.
47+
*/
48+
export const Form8KSchema = Type.Object({
49+
schemaVersion: Type.Optional(SCHEMA_VERSION_TYPE),
50+
submissionType: Type.Optional(SubTypeList),
51+
headerData: Type.Optional(HEADER_DATA_TYPE),
52+
formData: Type.Optional(FORM_DATA_TYPE),
53+
});
54+
55+
export type Form8K = Static<typeof Form8KSchema>;
56+
57+
export const Form8KSubmissionSchema = Type.Object({
58+
edgarSubmission: Form8KSchema,
59+
});
60+
61+
export type Form8KSubmission = Static<typeof Form8KSubmissionSchema>;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { Form8KEventRepo } from "../../../storage/form-8k-event/Form8KEventRepo";
8+
import { Form8KEvent } from "../../../storage/form-8k-event/Form8KEventSchema";
9+
import { PersonRepo } from "../../../storage/person/PersonRepo";
10+
import { CompanyRepo } from "../../../storage/company/CompanyRepo";
11+
import { hasCompanyEnding } from "../../../storage/company/CompanyNormalization";
12+
import { Form8K, Form8KSignature } from "./Form_8_K.schema";
13+
import { Form_8_K_ITEMS } from "./Form_8_K";
14+
15+
const RELATION_TYPE_SIGNATURE = "form-8k:signature";
16+
17+
/**
18+
* Extracts item codes from the filing metadata `items` field.
19+
* The items field is a comma-separated string of item codes (e.g., "2.02,9.01").
20+
* Also merges any items found in the parsed XML form data.
21+
*/
22+
function extractItemCodes(filingItems: string | undefined | null, form8K: Form8K): string[] {
23+
const itemSet = new Set<string>();
24+
25+
// Items from the filing index metadata (comma or semicolon separated)
26+
if (filingItems) {
27+
for (const raw of filingItems.split(/[,;]/)) {
28+
const item = raw.trim();
29+
if (item) {
30+
itemSet.add(item);
31+
}
32+
}
33+
}
34+
35+
// Items from parsed XML form data (if available)
36+
if (form8K.formData?.items?.item) {
37+
const xmlItems = form8K.formData.items.item;
38+
const itemArray = Array.isArray(xmlItems) ? xmlItems : [xmlItems];
39+
for (const item of itemArray) {
40+
const trimmed = item.trim();
41+
if (trimmed) {
42+
itemSet.add(trimmed);
43+
}
44+
}
45+
}
46+
47+
return [...itemSet].sort();
48+
}
49+
50+
async function processSignature(
51+
cik: number,
52+
signature: Form8KSignature
53+
): Promise<void> {
54+
const companyRepo = new CompanyRepo();
55+
const personRepo = new PersonRepo();
56+
57+
const signerName = signature.signatureName;
58+
if (!signerName) return;
59+
60+
const signatureTitle = signature.signatureTitle;
61+
const cleanTitles = [signatureTitle || "Signer"].filter(Boolean);
62+
63+
if (hasCompanyEnding(signerName)) {
64+
const company = await companyRepo.saveCompany(signerName);
65+
await companyRepo.saveRelatedEntity(
66+
company.company_hash_id,
67+
RELATION_TYPE_SIGNATURE,
68+
cik,
69+
cleanTitles
70+
);
71+
} else {
72+
const savedPerson = await personRepo.savePerson({ name: signerName });
73+
await personRepo.saveRelatedEntity(
74+
savedPerson.person_hash_id,
75+
RELATION_TYPE_SIGNATURE,
76+
cik,
77+
cleanTitles
78+
);
79+
}
80+
}
81+
82+
export async function processForm8K({
83+
cik,
84+
accession_number,
85+
filing_date,
86+
form,
87+
items,
88+
report_date,
89+
form8K,
90+
}: {
91+
cik: number;
92+
accession_number: string;
93+
filing_date: string;
94+
form: string;
95+
items: string | undefined | null;
96+
report_date: string | undefined | null;
97+
form8K: Form8K;
98+
}): Promise<void> {
99+
const eventRepo = new Form8KEventRepo();
100+
const isAmendment = form === "8-K/A";
101+
102+
// Use period of report from XML if available, fallback to filing metadata
103+
const effectiveReportDate = form8K.formData?.periodOfReport ?? report_date ?? null;
104+
105+
// Extract and store individual 8-K event items
106+
const itemCodes = extractItemCodes(items, form8K);
107+
108+
for (const itemCode of itemCodes) {
109+
const event: Form8KEvent = {
110+
cik,
111+
accession_number,
112+
item_code: itemCode,
113+
item_description: Form_8_K_ITEMS[itemCode] ?? null,
114+
filing_date,
115+
report_date: effectiveReportDate,
116+
is_amendment: isAmendment,
117+
};
118+
await eventRepo.saveEvent(event);
119+
}
120+
121+
// Process signatures from XML form data (if available)
122+
if (form8K.formData?.signatureBlock?.signature) {
123+
const signatures = form8K.formData.signatureBlock.signature;
124+
const signatureArray = Array.isArray(signatures) ? signatures : [signatures];
125+
126+
for (const signature of signatureArray) {
127+
try {
128+
await processSignature(cik, signature);
129+
} catch (error) {
130+
console.warn(`Failed to process 8-K signature:`, signature, error);
131+
}
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)