BID · Console
Baseline · Intelligence · Decision
scripts/sec-verify-banks.ts 7,741 bytes · typescript
/**
 * SEC verification — six banks, last ~1 year of financials.
 *
 * Exercises the post-CIK SEC connectors against live EDGAR for six
 * U.S. banks:
 *
 *   - secCompanyConcept for each bank × {Revenues / RevenuesNetOfInterestExpense,
 *     NoninterestExpense, NetIncomeLoss, Assets} — pulls the time
 *     series and picks rows reported in the past 365 days.
 *
 *   - secXbrlFrames for CY2024 Revenues to show the cross-company
 *     snapshot — every U.S. filer that reported Revenues for FY-2024
 *     ends up in one frame; we narrow to the six banks of interest.
 *
 *   - secInsiderForm4 (list mode) for one bank — last 90 days of
 *     insider filings to sanity-check the ownership connector.
 *
 * Zero Anthropic spend — agents not involved.
 *
 *   npm run sec:verify:banks
 */

import {
  secEdgarCompanies,
  secCompanyConcept,
  secXbrlFrames,
  secInsiderForm4,
  type SecConceptRow,
} from '../src/tools/retrieval/connectors/index.js';

const BANKS = ['JPM', 'BAC', 'GS', 'MS', 'WFC', 'C'] as const;

interface ConceptPick {
  readonly key: string;
  readonly tag: string;
  readonly fallbackTags?: readonly string[];
  readonly unit: string;
  readonly kind: 'duration' | 'instant';
}

const CONCEPTS: readonly ConceptPick[] = [
  { key: 'revenue',  tag: 'Revenues',           fallbackTags: ['RevenuesNetOfInterestExpense'], unit: 'USD', kind: 'duration' },
  { key: 'opex',     tag: 'NoninterestExpense', unit: 'USD', kind: 'duration' },
  { key: 'netIncome',tag: 'NetIncomeLoss',      unit: 'USD', kind: 'duration' },
  { key: 'assets',   tag: 'Assets',             unit: 'USD', kind: 'instant' },
];

const ONE_YEAR_AGO_ISO = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

function fmtUsd(v: number): string {
  if (Math.abs(v) >= 1e12) return `$${(v / 1e12).toFixed(2)}T`;
  if (Math.abs(v) >= 1e9)  return `$${(v / 1e9).toFixed(2)}B`;
  if (Math.abs(v) >= 1e6)  return `$${(v / 1e6).toFixed(1)}M`;
  return `$${v.toLocaleString()}`;
}

/** From an array of concept rows, keep rows whose end date is within
 *  the past `daysBack` days and (for duration concepts) cover ~1Q or
 *  ~1FY. Return them sorted newest first. */
function recentRows(rows: readonly SecConceptRow[], kind: 'duration' | 'instant', daysBack = 400): SecConceptRow[] {
  const cutoff = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
  const out: SecConceptRow[] = [];
  for (const r of rows) {
    if (!r.end || r.end < cutoff) continue;
    if (kind === 'duration') {
      if (!r.start || !r.fp || !r.form) continue;
      if (!(r.form === '10-K' || r.form === '10-Q')) continue;
      if (!(r.fp === 'FY' || r.fp === 'Q1' || r.fp === 'Q2' || r.fp === 'Q3')) continue;
    } else {
      if (!(r.form === '10-K' || r.form === '10-Q')) continue;
    }
    out.push(r);
  }
  /* Dedup on (end, fp, form) — the same period may appear in multiple
   * filings; keep the latest filing (max accn). */
  const byKey = new Map<string, SecConceptRow>();
  for (const r of out) {
    const k = `${r.end}|${r.fp ?? ''}|${r.form ?? ''}`;
    const prev = byKey.get(k);
    if (!prev || (r.accn ?? '') > (prev.accn ?? '')) byKey.set(k, r);
  }
  return [...byKey.values()].sort((a, b) => (b.end ?? '').localeCompare(a.end ?? ''));
}

async function resolveCik(ticker: string): Promise<string | null> {
  const matches = await secEdgarCompanies(ticker);
  const exact = matches.find(m => m.ticker === ticker);
  return (exact ?? matches[0])?.cik ?? null;
}

async function pullBank(ticker: string): Promise<void> {
  const cik = await resolveCik(ticker);
  if (!cik) {
    console.log(`\n──── ${ticker} ────  (no CIK)`);
    return;
  }
  console.log(`\n──── ${ticker}  CIK=${cik} ────`);
  for (const c of CONCEPTS) {
    const tagsToTry = [c.tag, ...(c.fallbackTags ?? [])];
    let used: { tag: string; rows: readonly SecConceptRow[]; recents: SecConceptRow[] } | null = null;
    for (const tag of tagsToTry) {
      try {
        const cc = await secCompanyConcept(cik, 'us-gaap', tag, c.unit);
        const rows = cc.units[c.unit] ?? [];
        const recents = recentRows(rows, c.kind);
        if (recents.length > 0) {
          used = { tag, rows, recents };
          break;
        }
      } catch (err) {
        if ((err as { category?: string }).category === 'no-content') continue;
        throw err;
      }
    }
    if (!used) {
      console.log(`  ${c.key.padEnd(10)} (no rows in last ~400d under ${tagsToTry.join('/')})`);
      continue;
    }
    const recents = used.recents;
    console.log(`  ${c.key.padEnd(10)} via ${used.tag} (${c.unit}) — newest ${Math.min(5, recents.length)}:`);
    for (const r of recents.slice(0, 5)) {
      const periodLabel = c.kind === 'duration'
        ? `${r.start ?? '?'}→${r.end}`
        : `as of ${r.end}`;
      const tag = `${r.form ?? '?'} ${r.fp ?? ''} fy=${r.fy ?? '?'}`.padEnd(20);
      console.log(`     ${tag}  ${periodLabel}   ${fmtUsd(r.val).padStart(10)}   accn ${r.accn ?? '?'}`);
    }
  }
}

async function frameSnapshot(): Promise<void> {
  /* Banks split between two revenue tags — pull both frames and merge. */
  const tags = ['Revenues', 'RevenuesNetOfInterestExpense'] as const;
  const targetCiks = new Set<string>();
  for (const t of BANKS) {
    const cik = await resolveCik(t);
    if (cik) targetCiks.add(String(parseInt(cik, 10)));
  }
  console.log('\n════════════ Cross-company frame: CY2024 revenue (both common bank tags) ════════════');
  for (const tag of tags) {
    const frame = await secXbrlFrames('us-gaap', tag, 'USD', 'CY2024');
    console.log(`\nframe us-gaap/${tag}/USD/CY2024 — label="${frame.label}" total=${frame.pts}`);
    const ours = frame.rows
      .filter(r => targetCiks.has(String(r.cik)))
      .sort((a, b) => b.val - a.val);
    for (const r of ours) {
      console.log(`  CIK ${String(r.cik).padStart(7)}  ${r.entityName.padEnd(34)}  ${fmtUsd(r.val).padStart(10)}   ${r.start ?? '?'}→${r.end}`);
    }
    if (ours.length === 0) console.log('  (none of our six banks reported under this tag)');
  }
}

async function insiderSpotCheck(): Promise<void> {
  console.log('\n════════════ Insider Form 4 spot check: JPM, last 90 days ════════════');
  const cik = await resolveCik('JPM');
  if (!cik) { console.log('  no CIK'); return; }
  const dateFrom = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
  const f4 = await secInsiderForm4(cik, { dateFrom, maxFilings: 10 });
  console.log(`filings returned: ${f4.filings.length} (window from ${dateFrom})`);
  for (const f of f4.filings.slice(0, 10)) {
    console.log(`  ${f.accessionNumber}  filed=${f.filingDate}  primary=${f.primaryDocument}`);
  }
}

async function main(): Promise<void> {
  console.log('SEC verification — six banks, last ~1 year of financials.');
  console.log(`User-Agent: MR mitchell.roy@sia-partners.com  (cutoff: ${ONE_YEAR_AGO_ISO})`);
  const startedAt = Date.now();
  for (const t of BANKS) {
    try {
      await pullBank(t);
    } catch (err) {
      console.log(`  ${t} ERROR: ${err instanceof Error ? err.message : String(err)}`);
    }
  }
  try {
    await frameSnapshot();
  } catch (err) {
    console.log(`frame ERROR: ${err instanceof Error ? err.message : String(err)}`);
  }
  try {
    await insiderSpotCheck();
  } catch (err) {
    console.log(`insider ERROR: ${err instanceof Error ? err.message : String(err)}`);
  }
  console.log(`\nDone. Elapsed: ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
}

main().catch(err => {
  console.error('Unhandled error:', err);
  process.exit(1);
});