fdic_peer_group_analysis
Analyze FDIC-insured banks by building peer groups and ranking financial metrics like ROA, ROE, and efficiency ratios to benchmark performance against similar institutions.
Instructions
Build a peer group for an FDIC-insured institution and rank it against peers on financial and efficiency metrics at a single report date.
Three usage modes:
Subject-driven: provide cert and repdte — auto-derives peer criteria from the subject's asset size and charter class
Explicit criteria: provide repdte plus asset_min/asset_max, charter_classes, state, or raw_filter
Subject with overrides: provide cert plus explicit criteria to override auto-derived defaults
Metrics ranked (fixed order):
Total Assets, Total Deposits, ROA, ROE, Net Interest Margin
Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
Deposits-to-Assets Ratio, Non-Interest Income Share
Rankings use competition rank (1, 2, 2, 4). Rank, denominator, and percentile all use the same comparison set: matched peers plus the subject institution.
Output includes:
Subject rankings and percentiles (when cert provided)
Peer group medians
Peer list with CERTs (pass to fdic_compare_bank_snapshots for trend analysis)
Metric definitions with directionality metadata
Override precedence: cert derives defaults, then explicit params override them.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| cert | No | Subject institution CERT number. When provided, auto-derives peer criteria and ranks this bank against peers. | |
| repdte | No | Report Date (REPDTE) in YYYYMMDD format. FDIC data is published quarterly on: March 31, June 30, September 30, and December 31. Example: 20231231 for Q4 2023. If omitted, defaults to the most recent quarter-end date likely to have published data (~90-day lag). | |
| asset_min | No | Minimum total assets ($thousands) for peer selection. Defaults to 50% of subject's report-date assets when cert is provided. | |
| asset_max | No | Maximum total assets ($thousands) for peer selection. Defaults to 200% of subject's report-date assets when cert is provided. | |
| charter_classes | No | Charter class codes to include (e.g., ["N", "SM"]). Defaults to the subject's charter class when cert is provided. | |
| state | No | Two-letter state code (e.g., "NC", "TX"). | |
| raw_filter | No | Advanced: raw ElasticSearch query string appended to peer selection criteria with AND. | |
| active_only | No | Limit to institutions where ACTIVE:1 (currently operating, FDIC-insured). | |
| extra_fields | No | Additional FDIC field names to include as raw values in the response. Does not affect peer selection. | |
| limit | No | Max peer records returned in the response. All matched peers are used for ranking regardless of this limit. |
Implementation Reference
- src/tools/peerGroup.ts:424-836 (handler)The main handler for fdic_peer_group_analysis tool.
async (rawParams, extra) => { const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS); const progressToken = extra._meta?.progressToken; try { const validationError = validatePeerGroupParams(params); if (validationError) { return formatToolError(new Error(validationError)); } const dateError = validateQuarterEndDate(params.repdte, "repdte"); if (dateError) { return formatToolError(new Error(dateError)); } const extraFieldsError = validateExtraFields(params.extra_fields); if (extraFieldsError) { return formatToolError(extraFieldsError); } await sendProgressNotification( server.server, progressToken, 0.1, "Resolving subject and peer criteria", ); const warnings: string[] = []; let subjectProfile: Record<string, unknown> | null = null; let subjectFinancials: Record<string, unknown> | null = null; // --- Phase 1: Resolve subject --- if (params.cert) { const [profileResponse, financialsResponse] = await Promise.all([ queryEndpoint( ENDPOINTS.INSTITUTIONS, { filters: `CERT:${params.cert}`, fields: "CERT,NAME,CITY,STALP,BKCLASS", limit: 1, }, { signal: controller.signal }, ), queryEndpoint( ENDPOINTS.FINANCIALS, { filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`, fields: FINANCIAL_FIELDS, limit: 1, }, { signal: controller.signal }, ), ]); const profileRecords = extractRecords(profileResponse); if (profileRecords.length === 0) { return formatToolError( new Error( `No institution found with CERT number ${params.cert}.`, ), ); } subjectProfile = profileRecords[0]; const financialRecords = extractRecords(financialsResponse); if (financialRecords.length === 0) { return formatToolError( new Error( `No financial data for CERT ${params.cert} at report date ${params.repdte}. ` + `FDIC quarterly data is published ~90 days after each quarter-end (March 31, June 30, September 30, December 31). ` + `Try an earlier quarter-end date, or verify the institution was active at that date.`, ), ); } subjectFinancials = financialRecords[0]; } // Derive defaults and apply overrides const subjectAsset = subjectFinancials && typeof subjectFinancials.ASSET === "number" ? subjectFinancials.ASSET : null; const assetMin = params.asset_min ?? (subjectAsset !== null ? subjectAsset * 0.5 : undefined); const assetMax = params.asset_max ?? (subjectAsset !== null ? subjectAsset * 2.0 : undefined); const charterClasses = params.charter_classes ?? (subjectProfile && typeof subjectProfile.BKCLASS === "string" ? [subjectProfile.BKCLASS] : undefined); const { state, active_only, raw_filter } = params; // --- Phase 2: Build peer roster --- await sendProgressNotification( server.server, progressToken, 0.4, "Fetching peer roster", ); const filterParts: string[] = []; if (assetMin !== undefined || assetMax !== undefined) { const min = assetMin ?? 0; const max = assetMax ?? "*"; filterParts.push(`ASSET:[${min} TO ${max}]`); } if (charterClasses && charterClasses.length > 0) { const classFilter = charterClasses .map((cls) => `BKCLASS:${cls}`) .join(" OR "); filterParts.push( charterClasses.length > 1 ? `(${classFilter})` : classFilter, ); } if (state) filterParts.push(`STALP:${state}`); if (active_only) filterParts.push("ACTIVE:1"); if (raw_filter) filterParts.push(`(${raw_filter})`); const rosterResponse = await queryEndpoint( ENDPOINTS.INSTITUTIONS, { filters: filterParts.join(" AND "), fields: "CERT,NAME,CITY,STALP,BKCLASS", limit: 10_000, offset: 0, sort_by: "CERT", sort_order: "ASC", }, { signal: controller.signal }, ); let rosterRecords = extractRecords(rosterResponse); if (rosterResponse.meta.total > rosterRecords.length) { warnings.push( `Institution roster truncated to ${rosterRecords.length.toLocaleString()} records ` + `out of ${rosterResponse.meta.total.toLocaleString()} matched institutions. ` + `Narrow the peer group criteria for complete analysis.`, ); } // Remove subject from roster if (params.cert) { rosterRecords = rosterRecords.filter( (r) => asNumber(r.CERT) !== params.cert, ); } const criteriaUsed = { asset_min: assetMin ?? null, asset_max: assetMax ?? null, charter_classes: charterClasses ?? null, state: state ?? null, active_only, raw_filter: raw_filter ?? null, }; if (rosterRecords.length === 0) { const subjectMetrics = subjectFinancials ? deriveMetrics(subjectFinancials) : null; const output: Record<string, unknown> = {}; if (subjectProfile) { output.subject = { cert: params.cert, name: subjectProfile.NAME, city: subjectProfile.CITY, stalp: subjectProfile.STALP, bkclass: subjectProfile.BKCLASS, metrics: subjectMetrics, rankings: null, }; } output.peer_group = { repdte: params.repdte, criteria_used: criteriaUsed, medians: {}, }; output.metric_definitions = METRIC_DEFINITIONS; output.peers = []; output.peer_count = 0; output.returned_count = 0; output.has_more = false; output.message = "No peers matched the specified criteria."; output.warnings = warnings; const text = formatPeerGroupText( params.repdte, subjectProfile, subjectMetrics, {}, {}, [], 0, warnings, ); return { content: [{ type: "text", text }], structuredContent: output, }; } // --- Phase 3: Fetch peer financials --- const peerCerts = rosterRecords .map((r) => asNumber(r.CERT)) .filter((c): c is number => c !== null); await sendProgressNotification( server.server, progressToken, 0.7, "Fetching peer financials", ); const certFilters = buildCertFilters(peerCerts); const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : ""; const financialResponses = await mapWithConcurrency( certFilters, MAX_CONCURRENCY, async (certFilter) => queryEndpoint( ENDPOINTS.FINANCIALS, { filters: `(${certFilter}) AND REPDTE:${params.repdte}`, fields: FINANCIAL_FIELDS + extraFieldsCsv, limit: 10_000, offset: 0, sort_by: "CERT", sort_order: "ASC", }, { signal: controller.signal }, ), ); const peerFinancialsByCert = new Map< number, Record<string, unknown> >(); for (const response of financialResponses) { const records = extractRecords(response); const warning = buildTruncationWarning( `financials batch for REPDTE:${params.repdte}`, response.meta.total, records.length, "Narrow the peer group criteria for complete analysis.", ); if (warning && !warnings.includes(warning)) warnings.push(warning); for (const record of records) { const cert = asNumber(record.CERT); if (cert !== null) peerFinancialsByCert.set(cert, record); } } // Build roster lookup const rosterByCert = new Map( rosterRecords .map((r) => [asNumber(r.CERT), r] as const) .filter( (e): e is [number, Record<string, unknown>] => e[0] !== null, ), ); // Compute metrics for peers that have financials const peers: PeerEntry[] = []; for (const [cert, financials] of peerFinancialsByCert) { const roster = rosterByCert.get(cert); const metrics = deriveMetrics(financials); const extraFields: Record<string, unknown> = {}; if (params.extra_fields) { for (const field of params.extra_fields) { extraFields[field] = financials[field] ?? null; } } peers.push({ cert, name: String(roster?.NAME ?? financials.NAME ?? cert), city: roster?.CITY != null ? String(roster.CITY) : null, stalp: roster?.STALP != null ? String(roster.STALP) : null, bkclass: roster?.BKCLASS != null ? String(roster.BKCLASS) : null, metrics, extraFields, }); } const peerCount = peers.length; // --- Phase 4: Rank and assemble --- await sendProgressNotification( server.server, progressToken, 0.9, "Computing peer rankings", ); const subjectMetrics = subjectFinancials ? deriveMetrics(subjectFinancials) : null; const rankings: Record<string, RankResult | null> = {}; const medians: Record<string, number | null> = {}; for (const key of METRIC_KEYS) { const peerValues = peers .map((p) => p.metrics[key]) .filter((v): v is number => v !== null); medians[key] = computeMedian(peerValues); if (subjectMetrics && subjectMetrics[key] !== null) { rankings[key] = computeCompetitionRank( subjectMetrics[key]!, peerValues, METRIC_DEFINITIONS[key].higher_is_better, ); } else { rankings[key] = null; } } // Sort peers by asset descending with a deterministic tie-breaker. peers.sort(comparePeerEntriesByAsset); const returnedPeers = peers.slice(0, params.limit); const returnedCount = returnedPeers.length; const hasMore = peerCount > returnedCount; // Build output const output: Record<string, unknown> = {}; if (subjectProfile && subjectMetrics) { output.subject = { cert: params.cert, name: subjectProfile.NAME, city: subjectProfile.CITY, stalp: subjectProfile.STALP, bkclass: subjectProfile.BKCLASS, metrics: subjectMetrics, rankings, }; } output.peer_group = { repdte: params.repdte, criteria_used: criteriaUsed, medians, }; output.metric_definitions = METRIC_DEFINITIONS; output.peers = returnedPeers.map((p) => ({ cert: p.cert, name: p.name, city: p.city, stalp: p.stalp, metrics: p.metrics, ...p.extraFields, })); output.peer_count = peerCount; output.returned_count = returnedCount; output.has_more = hasMore; output.message = null; output.warnings = warnings; const text = truncateIfNeeded( formatPeerGroupText( params.repdte, subjectProfile, subjectMetrics, rankings, medians, returnedPeers, peerCount, warnings, ), CHARACTER_LIMIT, "Reduce the number of peers, narrow the peer-group criteria, request fewer fields, or shorten the analysis scope.", ); await sendProgressNotification( server.server, progressToken, 1, "Analysis complete", ); return { content: [{ type: "text", text }], structuredContent: output, }; } catch (err) { if (controller.signal.aborted) { return formatToolError( new Error( `Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1000)} seconds. ` + `Try narrowing the peer group: add a state filter, tighten the asset_min/asset_max range, or specify charter_classes.`, ), ); } return formatToolError(err); } finally { clearTimeout(timeoutId); } }, ); - src/tools/peerGroup.ts:161-230 (schema)Zod schema for tool input.
export const PeerGroupInputSchema = z .object({ cert: z .number() .int() .positive() .optional() .describe( "Subject institution CERT number. When provided, auto-derives peer criteria and ranks this bank against peers.", ), repdte: z .string() .regex(/^\d{8}$/) .optional() .describe( "Report Date (REPDTE) in YYYYMMDD format. FDIC data is published quarterly on: March 31, June 30, September 30, and December 31. Example: 20231231 for Q4 2023. If omitted, defaults to the most recent quarter-end date likely to have published data (~90-day lag).", ), asset_min: z .number() .positive() .optional() .describe( "Minimum total assets ($thousands) for peer selection. Defaults to 50% of subject's report-date assets when cert is provided.", ), asset_max: z .number() .positive() .optional() .describe( "Maximum total assets ($thousands) for peer selection. Defaults to 200% of subject's report-date assets when cert is provided.", ), charter_classes: z .array(z.string()) .optional() .describe( 'Charter class codes to include (e.g., ["N", "SM"]). Defaults to the subject\'s charter class when cert is provided.', ), state: z .string() .regex(/^[A-Z]{2}$/) .optional() .describe('Two-letter state code (e.g., "NC", "TX").'), raw_filter: z .string() .optional() .describe( "Advanced: raw ElasticSearch query string appended to peer selection criteria with AND.", ), active_only: z .boolean() .default(true) .describe( "Limit to institutions where ACTIVE:1 (currently operating, FDIC-insured).", ), extra_fields: z .array(z.string()) .optional() .describe( "Additional FDIC field names to include as raw values in the response. Does not affect peer selection.", ), limit: z .number() .int() .min(1) .max(500) .default(50) .describe( "Max peer records returned in the response. All matched peers are used for ranking regardless of this limit.", ), }); - src/tools/peerGroup.ts:391-423 (registration)Registration of the fdic_peer_group_analysis tool.
server.registerTool( "fdic_peer_group_analysis", { title: "Peer Group Analysis", description: `Build a peer group for an FDIC-insured institution and rank it against peers on financial and efficiency metrics at a single report date. Three usage modes: - Subject-driven: provide cert and repdte — auto-derives peer criteria from the subject's asset size and charter class - Explicit criteria: provide repdte plus asset_min/asset_max, charter_classes, state, or raw_filter - Subject with overrides: provide cert plus explicit criteria to override auto-derived defaults Metrics ranked (fixed order): - Total Assets, Total Deposits, ROA, ROE, Net Interest Margin - Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio - Deposits-to-Assets Ratio, Non-Interest Income Share Rankings use competition rank (1, 2, 2, 4). Rank, denominator, and percentile all use the same comparison set: matched peers plus the subject institution. Output includes: - Subject rankings and percentiles (when cert provided) - Peer group medians - Peer list with CERTs (pass to fdic_compare_bank_snapshots for trend analysis) - Metric definitions with directionality metadata Override precedence: cert derives defaults, then explicit params override them.`, inputSchema: PeerGroupInputSchema, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, },