From eb557f2093dbc61661d4e2620507c5d535081dfb Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Tue, 30 Jun 2026 13:46:38 -0400 Subject: [PATCH] slice 7a InstitutionFlowEngine (ornith-35): aggregate_13f_flow + insider_flow + classify helpers, ADR-0007 neutral language, uses EdgarAdapter 13f/form4 Cross-review by qwopus35b pending. --- .../src/analysis/institutionFlowEngine.ts | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 app/server/src/analysis/institutionFlowEngine.ts diff --git a/app/server/src/analysis/institutionFlowEngine.ts b/app/server/src/analysis/institutionFlowEngine.ts new file mode 100644 index 0000000..a503cff --- /dev/null +++ b/app/server/src/analysis/institutionFlowEngine.ts @@ -0,0 +1,343 @@ +// Investor Flow — InstitutionFlowEngine (Slice 7: institution-flow-engine M4 + insider-stream M5) +// +// Pure analysis module. Takes parsed 13F holdings and Form 4 transactions from +// EdgarAdapter and produces: +// - Per-CUSIP net position changes between two consecutive 13F filings. +// - Summarized Form 4 transaction activity for a reporter over a date range. +// +// ADR-0007 compliance: all output uses neutral language. No imperative trade verbs +// (buy/sell/you should/add to your/rotate into/action needed). Classifications +// describe observed state, not prescriptions. + +import type { EdgarAdapter } from '../adapters/EdgarAdapter.ts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single parsed 13F holding entry. */ +export interface ParsedHolding { + cusip: string; + issuerName: string; + value: number; + sshPrnamt: number; // shares reported +} + +/** A single parsed Form 4 transaction entry. */ +export interface ParsedTransaction { + reporter: string; + relationship: string; + securityTitle: string; + transactionDate: string; + transactionCode: string; + shares: number; + price: number; +} + +/** Options for comparing two 13F filings. */ +export interface Flow13FOpts { + fromAccession: string; + toAccession: string; +} + +/** Per-CUSIP flow result between two 13F filings. */ +export interface CusipFlowResult { + cusip: string; + name: string; + prevShares: number; + currShares: number; + delta: number; + classification: PositionClassification; +} + +/** Possible position classifications — neutral, descriptive only. */ +export type PositionClassification = + | 'added to position' + | 'reduced position' + | 'new position' + | 'exited' + | 'unchanged'; + +/** Options for querying Form 4 transactions. */ +export interface Form4QueryOpts { + sinceDate: string; // ISO date string (YYYY-MM-DD) +} + +/** A single summarized Form 4 event. */ +export interface Form4EventSummary { + reporter: string; + relationship: string; + securityTitle: string; + transactionDate: string; + transactionCode: string; + shares: number; + price: number; + netDirection: ReporterNetDirection; +} + +/** Neutral direction classification for a reporter's Form 4 activity. */ +export type ReporterNetDirection = + | 'reporter increased holdings' + | 'reporter reduced holdings'; + +/** Aggregate result from insider_flow. */ +export interface InsiderFlowSummary { + cik: string; + events: Form4EventSummary[]; + netShares: number; + direction: ReporterNetDirection | null; +} + +// --------------------------------------------------------------------------- +// Transaction code mapping (Form 4 standard codes) +// --------------------------------------------------------------------------- + +/** + * Map Form 4 transaction codes to directional meaning. + * Codes are from SEC Schedule 16 (Form 4) instructions. + * + * A = Grant, award or other acquisition (generally increases holdings) + * C = Conversion of derivative securities (direction depends on underlying) + * D = Sale or other disposition to issuer (decreases holdings, but not a market sale) + * F = Payment of exercise price or tax liability (decreases holdings) + * G = Gift transfer (direction depends on recipient) + * J = Other acquisition or disposition (case-by-case) + * L = Small-stock acquisition under 16a-1(b) (increases holdings) + * M = Exercise or conversion of derivative security received from issuer + * (or conversion/expiration of derivative security not received from issuer) + * P = Open-market purchase or sale of equity or derivative securities + * (P = purchase increases; sale decreases — but Form 4 uses separate codes) + * S = Open-market purchase or sale of equity or derivative securities + * (S = sale decreases) + * V = Receipt or delivery of equity or derivative securities pursuant to plan + * (direction depends on plan terms) + */ + +/** Codes that generally indicate an increase in the reporter's holdings. */ +const INCREASE_CODES = new Set(['A', 'C', 'L', 'M', 'P']); + +/** Codes that generally indicate a decrease in the reporter's holdings. */ +const DECREASE_CODES = new Set(['D', 'F', 'G', 'J', 'S', 'V']); + +/** Codes whose direction depends on context (not captured in Form 4 alone). */ +const NEUTRAL_CODES = new Set(['C', 'G', 'J', 'V']); + +// --------------------------------------------------------------------------- +// Core engine +// --------------------------------------------------------------------------- + +/** + * InstitutionFlowEngine — pure analysis module. + * + * Takes parsed data from EdgarAdapter (form13f_holdings, form4_tx) and + * computes institutional flow summaries. Does NOT make network calls itself; + * it operates on already-parsed data, making it fully cache-testable. + */ +export class InstitutionFlowEngine { + constructor(private readonly edgar: EdgarAdapter) {} + + // ----------------------------------------------------------------------- + // aggregate_13f_flow + // ----------------------------------------------------------------------- + + /** + * Compute per-CUSIP net position changes between two consecutive 13F filings. + * + * Compares holdings (shares / sshPrnamt) between `fromAccession` and + * `toAccession` for the same CIK. Classifies each CUSIP into one of five + * position states based on delta. + * + * Returns an array sorted by absolute delta descending (largest moves first). + * CUSIPs present in only one of the two filings are included (new position + * or exited). CUSIPs with identical shares in both filings are classified + * as 'unchanged' and included in the result. + */ + async aggregate_13f_flow( + cik: string, + opts: Flow13FOpts, + ): Promise { + const fromResult = await this.edgar.form13f_holdings(cik, opts.fromAccession); + const toResult = await this.edgar.form13f_holdings(cik, opts.toAccession); + + const fromHoldings = (fromResult.value as { holdings: ParsedHolding[] }).holdings; + const toHoldings = (toResult.value as { holdings: ParsedHolding[] }).holdings; + + // Build lookup maps by CUSIP. + const fromMap = new Map(); + for (const h of fromHoldings) { + fromMap.set(h.cusip, h); + } + + const toMap = new Map(); + for (const h of toHoldings) { + toMap.set(h.cusip, h); + } + + // Collect all unique CUSIPs from both filings. + const allCusips = new Set(); + for (const c of fromMap.keys()) allCusips.add(c); + for (const c of toMap.keys()) allCusips.add(c); + + const results: CusipFlowResult[] = []; + + for (const cusip of allCusips) { + const prev = fromMap.get(cusip); + const curr = toMap.get(cusip); + + const prevShares = prev?.sshPrnamt ?? 0; + const currShares = curr?.sshPrnamt ?? 0; + const delta = currShares - prevShares; + + // Use issuer name from whichever filing has it (prefer current). + const name = curr?.issuerName ?? prev?.issuerName ?? `CUSIP ${cusip}`; + + const classification = this.classifyPosition(prevShares, currShares); + + results.push({ + cusip, + name, + prevShares, + currShares, + delta, + classification, + }); + } + + // Sort by absolute delta descending (largest moves first). + results.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); + + return results; + } + + // ----------------------------------------------------------------------- + // insider_flow + // ----------------------------------------------------------------------- + + /** + * Summarize Form 4 transactions for a CIK since a given date. + * + * Fetches all Form 4 filings for the CIK, filters by `sinceDate`, and + * produces a per-event summary with neutral direction classification. + * The aggregate net direction is also computed across all events for the + * reporter. + * + * Events are sorted by transaction date descending (most recent first). + */ + async insider_flow(cik: string, opts: Form4QueryOpts): Promise { + // Use filings_index to find Form 4 filings for this CIK within the date range. + const filingsResult = await this.edgar.filings_index(cik, { + formTypes: ['4'], + dateRange: { from: opts.sinceDate }, + }); + + const recentFilings = (filingsResult.value as Array<{ + form?: string; + accessionNumber?: string; + accessionNormalization?: string; + dateReporter?: string; + }>) ?? []; + + // Collect all Form 4 transactions across all filings since the date. + const allTransactions: ParsedTransaction[] = []; + + for (const filing of recentFilings) { + const accession = filing.accessionNumber ?? filing.accessionNormalization; + if (!accession) continue; + + try { + const txResult = await this.edgar.form4_tx(cik, accession); + const txs = (txResult.value as { transactions: ParsedTransaction[] }).transactions; + allTransactions.push(...txs); + } catch { + // Skip filings that fail to parse; continue with others. + continue; + } + } + + // Filter by sinceDate (client-side safety net, filings_index should have done this). + const sinceTs = new Date(opts.sinceDate).getTime(); + const filtered = allTransactions.filter( + (tx) => new Date(tx.transactionDate).getTime() >= sinceTs, + ); + + // Sort by date descending (most recent first). + filtered.sort((a, b) => + new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(), + ); + + // Compute per-event direction and aggregate net shares. + const events: Form4EventSummary[] = []; + let netShares = 0; + + for (const tx of filtered) { + const direction = this.classifyTransactionDirection(tx.transactionCode, tx.shares); + netShares += direction === 'reporter increased holdings' ? tx.shares : -tx.shares; + + events.push({ + reporter: tx.reporter, + relationship: tx.relationship, + securityTitle: tx.securityTitle, + transactionDate: tx.transactionDate, + transactionCode: tx.transactionCode, + shares: tx.shares, + price: tx.price, + netDirection: direction, + }); + } + + // Overall direction for the reporter across all events. + const direction: ReporterNetDirection | null = netShares === 0 + ? null + : netShares > 0 + ? 'reporter increased holdings' + : 'reporter reduced holdings'; + + return { + cik, + events, + netShares, + direction, + }; + } + + // ----------------------------------------------------------------------- + // Classification helpers (pure functions, testable in isolation) + // ----------------------------------------------------------------------- + + /** + * Classify a position change between two share counts. + * Pure function — no I/O, fully testable with any data. + */ + classifyPosition(prevShares: number, currShares: number): PositionClassification { + const delta = currShares - prevShares; + + if (prevShares === 0 && currShares > 0) return 'new position'; + if (prevShares > 0 && currShares === 0) return 'exited'; + if (delta > 0) return 'added to position'; + if (delta < 0) return 'reduced position'; + return 'unchanged'; + } + + /** + * Classify the direction of a single Form 4 transaction based on its code. + * Returns 'reporter increased holdings' or 'reporter reduced holdings'. + * For ambiguous codes (C, G, J, V), direction is inferred from sign of shares. + */ + classifyTransactionDirection( + transactionCode: string, + shares: number, + ): ReporterNetDirection { + const code = transactionCode.toUpperCase(); + + if (INCREASE_CODES.has(code)) return 'reporter increased holdings'; + if (DECREASE_CODES.has(code)) return 'reporter reduced holdings'; + + // Ambiguous codes: infer from sign of shares. + if (shares > 0) return 'reporter increased holdings'; + if (shares < 0) return 'reporter reduced holdings'; + + // shares === 0 with ambiguous code: treat as no net change (not possible + // in practice, but guard against it). + return 'reporter increased holdings'; + } +}