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.
This commit is contained in:
Investor Flow Build
2026-06-30 13:46:38 -04:00
parent c2476ab137
commit eb557f2093
@@ -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<CusipFlowResult[]> {
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<string, ParsedHolding>();
for (const h of fromHoldings) {
fromMap.set(h.cusip, h);
}
const toMap = new Map<string, ParsedHolding>();
for (const h of toHoldings) {
toMap.set(h.cusip, h);
}
// Collect all unique CUSIPs from both filings.
const allCusips = new Set<string>();
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<InsiderFlowSummary> {
// 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';
}
}