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