import { decodeHtmlEntities } from "../../../utils/html-entities"; export interface InsiderTransaction { /** Transaction date, distinct from the enclosing SEC filing date. */ filingDate: Date | null; reportedName: string; title: string; transactionType: string; shares: number | null; pricePerShare: number | null; totalValue: number | null; sharesOwned: number | null; form: string; transactionIndex?: number; securityTitle?: string; isDerivative?: boolean; acquiredDisposed?: string; ownershipType?: string; ownershipNature?: string; reportingOwners?: Array<{ name: string; cik: string; title: string }>; footnoteIds?: string[]; } export interface InsiderFilingDisclosure { form: string; originalFilingDate: string | null; reportingOwners: Array<{ name: string; cik: string; title: string }>; footnotes: Array<{ id: string; text: string }>; remarks: string | null; /** False for a filing with no transaction lines, e.g. one reporting only that the owner left Section 16. */ hasTransactionLines: boolean; } export function isInsiderForm(form: string): boolean { return /^4(?:\/A)?$/i.test(form.trim()); } export function isInsiderAmendment(form: string): boolean { return /^4\/A$/i.test(form.trim()); } function tagContent(xml: string, tag: string): string | undefined { return xml.match(new RegExp(`<(?:[\\w.-]+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w.-]+:)?${tag}\\s*>`, "i"))?.[1]; } function tagText(xml: string, tag: string): string { return decodeHtmlEntities((tagContent(xml, tag) ?? "").replace(/<[^>]*>/g, "").trim()); } function numberValue(xml: string, tag: string): number | null { const raw = tagText(tagContent(xml, tag) ?? "", "value"); if (!raw) return null; const value = Number(raw.replace(/,/g, "")); return Number.isFinite(value) ? value : null; } function isFlagSet(xml: string, tag: string): boolean { return /^(?:1|true)$/i.test(tagText(xml, tag)); } /** The officer title, else the relationship boxes checked on the form (directors have no officer title). */ function ownerTitle(owner: string): string { const officerTitle = tagText(owner, "officerTitle"); if (officerTitle) return officerTitle; return [ isFlagSet(owner, "isDirector") ? "Director" : "", isFlagSet(owner, "isOfficer") ? "Officer" : "", isFlagSet(owner, "isTenPercentOwner") ? "10% Owner" : "", isFlagSet(owner, "isOther") ? tagText(owner, "otherText") || "Other" : "", ].filter(Boolean).join(", "); } function reportingOwners(xml: string): InsiderFilingDisclosure["reportingOwners"] { return [...xml.matchAll(/<(?:[\w.-]+:)?reportingOwner(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?reportingOwner\s*>/gi)] .map((match) => ({ name: tagText(match[1]!, "rptOwnerName"), cik: tagText(match[1]!, "rptOwnerCik"), title: ownerTitle(match[1]!) })); } function disclosureText(xml: string): string { return decodeHtmlEntities(xml.replace(//g, "$1").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()); } export function parseForm4Disclosure(xml: string): InsiderFilingDisclosure | null { if (!/<(?:[\w.-]+:)?ownershipDocument(?:\s|>)/i.test(xml)) return null; const original = tagText(xml, "dateOfOriginalSubmission"); const date = /^\d{4}-\d{2}-\d{2}$/.test(original) ? new Date(`${original}T00:00:00Z`) : null; return { form: tagText(xml, "documentType") || "4", originalFilingDate: date && Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === original ? original : null, reportingOwners: reportingOwners(xml), footnotes: [...xml.matchAll(/<(?:[\w.-]+:)?footnote\s[^>]*\bid\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?footnote\s*>/gi)] .map((match) => ({ id: match[1]!, text: disclosureText(match[2]!) })), remarks: disclosureText(tagContent(xml, "remarks") ?? "") || null, hasTransactionLines: /<(?:[\w.-]+:)?(?:nonDerivative|derivative)Transaction(?:\s|>)/i.test(xml), }; } export function parseForm4Xml(xml: string): InsiderTransaction[] { const owners = reportingOwners(xml).filter((owner) => owner.name); if (!owners.length) return []; const blocks = [...xml.matchAll(/<(?:[\w.-]+:)?(nonDerivativeTransaction|derivativeTransaction)(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?\1\s*>/gi)]; return blocks.map((match, transactionIndex) => { const block = match[2]!; const amounts = tagContent(block, "transactionAmounts") ?? ""; const shares = numberValue(amounts, "transactionShares"); const price = numberValue(amounts, "transactionPricePerShare"); const dateText = tagText(tagContent(block, "transactionDate") ?? "", "value"); const date = /^\d{4}-\d{2}-\d{2}$/.test(dateText) ? new Date(`${dateText}T00:00:00Z`) : null; return { filingDate: date && Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === dateText ? date : null, reportedName: owners.map(({ name }) => name).join("; "), title: [...new Set(owners.map(({ title }) => title).filter(Boolean))].join("; "), reportingOwners: owners, transactionType: tagText(tagContent(block, "transactionCoding") ?? "", "transactionCode"), securityTitle: tagText(tagContent(block, "securityTitle") ?? "", "value"), isDerivative: match[1]!.toLowerCase() === "derivativetransaction", transactionIndex, shares, pricePerShare: price, totalValue: shares != null && price != null ? shares * price : null, sharesOwned: numberValue(tagContent(block, "postTransactionAmounts") ?? "", "sharesOwnedFollowingTransaction"), acquiredDisposed: tagText(tagContent(amounts, "transactionAcquiredDisposedCode") ?? "", "value"), ownershipType: tagText(tagContent(tagContent(block, "ownershipNature") ?? "", "directOrIndirectOwnership") ?? "", "value"), ownershipNature: tagText(tagContent(tagContent(block, "ownershipNature") ?? "", "natureOfOwnership") ?? "", "value"), form: tagText(xml, "documentType") || "4", footnoteIds: [...new Set([...block.matchAll(/<(?:[\w.-]+:)?footnoteId\s[^>]*\bid\s*=\s*["']([^"']+)["']/gi)].map((note) => note[1]!))], }; }); } export function transactionTypeLabel(type: InsiderTransaction["transactionType"]): string { const labels: Record = { P: "BUY", S: "SELL", A: "AWARD", D: "DISPOSE", M: "EXERCISE", C: "CONVERT", F: "TAX/EXERCISE PAYMENT", G: "GIFT", X: "EXERCISE", O: "OUT-OF-MONEY EXERCISE", E: "EXPIRATION", H: "CANCELLATION", I: "DISCRETIONARY", J: "OTHER", K: "SWAP", L: "SMALL ACQUISITION", U: "TENDER", W: "INHERITANCE", Z: "TRUST TRANSFER", }; return labels[type] ?? (type || "—"); }