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:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user