fdic-mcp-server 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1969 -1
- package/dist/server.js +1969 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -32,7 +32,7 @@ var import_types = require("@modelcontextprotocol/sdk/types.js");
|
|
|
32
32
|
var import_express2 = __toESM(require("express"));
|
|
33
33
|
|
|
34
34
|
// src/constants.ts
|
|
35
|
-
var VERSION = true ? "1.
|
|
35
|
+
var VERSION = true ? "1.13.0" : process.env.npm_package_version ?? "0.0.0-dev";
|
|
36
36
|
var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
|
|
37
37
|
var CHARACTER_LIMIT = 5e4;
|
|
38
38
|
var DEFAULT_FDIC_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
@@ -35398,6 +35398,1966 @@ NOTE: Analytical screening tool, not official supervisory ratings.`,
|
|
|
35398
35398
|
);
|
|
35399
35399
|
}
|
|
35400
35400
|
|
|
35401
|
+
// src/tools/creditConcentration.ts
|
|
35402
|
+
var import_zod12 = require("zod");
|
|
35403
|
+
|
|
35404
|
+
// src/tools/shared/creditConcentration.ts
|
|
35405
|
+
var CREDIT_FIELDS = [
|
|
35406
|
+
"CERT",
|
|
35407
|
+
"REPDTE",
|
|
35408
|
+
"ASSET",
|
|
35409
|
+
"EQTOT",
|
|
35410
|
+
"EQV",
|
|
35411
|
+
"LNLSNET",
|
|
35412
|
+
"LNRE",
|
|
35413
|
+
"LNRERES",
|
|
35414
|
+
"LNRECONS",
|
|
35415
|
+
"LNREMULT",
|
|
35416
|
+
"LNRENRES",
|
|
35417
|
+
"LNREAG",
|
|
35418
|
+
"LNRELOC",
|
|
35419
|
+
"LNCI",
|
|
35420
|
+
"LNCON",
|
|
35421
|
+
"LNAG",
|
|
35422
|
+
"LNOTH",
|
|
35423
|
+
"LNREDOM",
|
|
35424
|
+
"LNREFOR"
|
|
35425
|
+
].join(",");
|
|
35426
|
+
function safePct(num, den) {
|
|
35427
|
+
if (num === null || den === null || den === 0) return null;
|
|
35428
|
+
return num / den * 100;
|
|
35429
|
+
}
|
|
35430
|
+
function computeCreditMetrics(raw) {
|
|
35431
|
+
const lnlsnet = asNumber(raw.LNLSNET);
|
|
35432
|
+
const lnrecons = asNumber(raw.LNRECONS);
|
|
35433
|
+
const lnremult = asNumber(raw.LNREMULT);
|
|
35434
|
+
const lnrenres = asNumber(raw.LNRENRES);
|
|
35435
|
+
const eqtot = asNumber(raw.EQTOT);
|
|
35436
|
+
const asset = asNumber(raw.ASSET);
|
|
35437
|
+
const lnci = asNumber(raw.LNCI);
|
|
35438
|
+
const lncon = asNumber(raw.LNCON);
|
|
35439
|
+
const lnreres = asNumber(raw.LNRERES);
|
|
35440
|
+
const lnag = asNumber(raw.LNAG);
|
|
35441
|
+
const cre = lnrecons !== null && lnremult !== null && lnrenres !== null ? lnrecons + lnremult + lnrenres : null;
|
|
35442
|
+
return {
|
|
35443
|
+
total_loans: lnlsnet,
|
|
35444
|
+
cre_to_total_loans: safePct(cre, lnlsnet),
|
|
35445
|
+
cre_to_capital: safePct(cre, eqtot),
|
|
35446
|
+
construction_to_capital: safePct(lnrecons, eqtot),
|
|
35447
|
+
ci_share: safePct(lnci, lnlsnet),
|
|
35448
|
+
consumer_share: safePct(lncon, lnlsnet),
|
|
35449
|
+
residential_re_share: safePct(lnreres, lnlsnet),
|
|
35450
|
+
ag_share: safePct(lnag, lnlsnet),
|
|
35451
|
+
loans_to_assets: safePct(lnlsnet, asset)
|
|
35452
|
+
};
|
|
35453
|
+
}
|
|
35454
|
+
function scoreCreditConcentration(m) {
|
|
35455
|
+
const signals = [];
|
|
35456
|
+
if (m.cre_to_capital !== null && m.cre_to_capital > 300) {
|
|
35457
|
+
signals.push({
|
|
35458
|
+
severity: "warning",
|
|
35459
|
+
category: "credit_concentration",
|
|
35460
|
+
message: `CRE concentration at ${m.cre_to_capital.toFixed(0)}% of capital exceeds 300% interagency guidance threshold`
|
|
35461
|
+
});
|
|
35462
|
+
}
|
|
35463
|
+
if (m.construction_to_capital !== null && m.construction_to_capital > 100) {
|
|
35464
|
+
signals.push({
|
|
35465
|
+
severity: "warning",
|
|
35466
|
+
category: "credit_concentration",
|
|
35467
|
+
message: `construction loan concentration at ${m.construction_to_capital.toFixed(0)}% of capital exceeds 100% interagency guidance threshold`
|
|
35468
|
+
});
|
|
35469
|
+
}
|
|
35470
|
+
if (m.loans_to_assets !== null && m.loans_to_assets > 80) {
|
|
35471
|
+
signals.push({
|
|
35472
|
+
severity: "info",
|
|
35473
|
+
category: "credit_concentration",
|
|
35474
|
+
message: `High loan-to-asset ratio at ${m.loans_to_assets.toFixed(1)}% indicates elevated lending relative to total assets`
|
|
35475
|
+
});
|
|
35476
|
+
}
|
|
35477
|
+
return signals;
|
|
35478
|
+
}
|
|
35479
|
+
|
|
35480
|
+
// src/tools/creditConcentration.ts
|
|
35481
|
+
function fmtPct(val) {
|
|
35482
|
+
return val !== null ? `${val.toFixed(1)}%` : "n/a";
|
|
35483
|
+
}
|
|
35484
|
+
function fmtDollarsK(val) {
|
|
35485
|
+
return val !== null ? `$${Math.round(val).toLocaleString()}K` : "n/a";
|
|
35486
|
+
}
|
|
35487
|
+
function formatCreditSummaryText(summary) {
|
|
35488
|
+
const parts = [];
|
|
35489
|
+
const { institution: inst, metrics: m, signals } = summary;
|
|
35490
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
35491
|
+
parts.push(` Credit Concentration Analysis: ${inst.name}`);
|
|
35492
|
+
parts.push(` ${inst.city}, ${inst.state} | CERT ${inst.cert} | Total Assets: ${fmtDollarsK(inst.total_assets)}`);
|
|
35493
|
+
parts.push(` Report Date: ${inst.report_date}`);
|
|
35494
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
35495
|
+
parts.push("");
|
|
35496
|
+
parts.push("Loan Portfolio Composition");
|
|
35497
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35498
|
+
parts.push(` Total Loans: ${fmtDollarsK(m.total_loans)}`);
|
|
35499
|
+
parts.push(` Loans / Assets: ${fmtPct(m.loans_to_assets)}`);
|
|
35500
|
+
parts.push("");
|
|
35501
|
+
parts.push(` CRE / Total Loans: ${fmtPct(m.cre_to_total_loans)}`);
|
|
35502
|
+
parts.push(` CRE / Capital: ${fmtPct(m.cre_to_capital)}`);
|
|
35503
|
+
parts.push(` Construction / Capital: ${fmtPct(m.construction_to_capital)}`);
|
|
35504
|
+
parts.push("");
|
|
35505
|
+
parts.push(` C&I Share: ${fmtPct(m.ci_share)}`);
|
|
35506
|
+
parts.push(` Consumer Share: ${fmtPct(m.consumer_share)}`);
|
|
35507
|
+
parts.push(` Residential RE Share: ${fmtPct(m.residential_re_share)}`);
|
|
35508
|
+
parts.push(` Agricultural Share: ${fmtPct(m.ag_share)}`);
|
|
35509
|
+
if (signals.length > 0) {
|
|
35510
|
+
parts.push("");
|
|
35511
|
+
parts.push("\u26A0 Concentration Signals");
|
|
35512
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35513
|
+
for (const signal of signals) {
|
|
35514
|
+
parts.push(` \u2022 ${signal.message}`);
|
|
35515
|
+
}
|
|
35516
|
+
}
|
|
35517
|
+
return parts.join("\n");
|
|
35518
|
+
}
|
|
35519
|
+
var CreditConcentrationSchema = import_zod12.z.object({
|
|
35520
|
+
cert: import_zod12.z.number().int().positive().describe("FDIC Certificate Number"),
|
|
35521
|
+
repdte: import_zod12.z.string().regex(/^\d{8}$/).optional().describe("Report date (YYYYMMDD). Defaults to most recent quarter.")
|
|
35522
|
+
});
|
|
35523
|
+
function registerCreditConcentrationTools(server) {
|
|
35524
|
+
server.registerTool(
|
|
35525
|
+
"fdic_analyze_credit_concentration",
|
|
35526
|
+
{
|
|
35527
|
+
title: "Analyze Credit Concentration",
|
|
35528
|
+
description: `Analyze loan portfolio composition and credit concentration risk for an FDIC-insured institution. Computes CRE concentration relative to capital (per 2006 interagency guidance), loan-type breakdown, and flags concentration risks.
|
|
35529
|
+
|
|
35530
|
+
Output includes:
|
|
35531
|
+
- Loan portfolio composition (CRE, C&I, consumer, residential, agricultural shares)
|
|
35532
|
+
- CRE and construction concentration relative to total capital
|
|
35533
|
+
- Loan-to-asset ratio
|
|
35534
|
+
- Concentration risk signals based on interagency guidance thresholds
|
|
35535
|
+
- Structured JSON for programmatic consumption
|
|
35536
|
+
|
|
35537
|
+
NOTE: This is an analytical tool based on public financial data.`,
|
|
35538
|
+
inputSchema: CreditConcentrationSchema,
|
|
35539
|
+
annotations: {
|
|
35540
|
+
readOnlyHint: true,
|
|
35541
|
+
destructiveHint: false,
|
|
35542
|
+
idempotentHint: true,
|
|
35543
|
+
openWorldHint: true
|
|
35544
|
+
}
|
|
35545
|
+
},
|
|
35546
|
+
async (rawParams, extra) => {
|
|
35547
|
+
const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
|
|
35548
|
+
const controller = new AbortController();
|
|
35549
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
35550
|
+
const progressToken = extra._meta?.progressToken;
|
|
35551
|
+
try {
|
|
35552
|
+
const dateError = validateQuarterEndDate(params.repdte, "repdte");
|
|
35553
|
+
if (dateError) {
|
|
35554
|
+
return formatToolError(new Error(dateError));
|
|
35555
|
+
}
|
|
35556
|
+
await sendProgressNotification(server.server, progressToken, 0.1, "Fetching institution profile");
|
|
35557
|
+
const [profileResponse, financialsResponse] = await Promise.all([
|
|
35558
|
+
queryEndpoint(
|
|
35559
|
+
ENDPOINTS.INSTITUTIONS,
|
|
35560
|
+
{
|
|
35561
|
+
filters: `CERT:${params.cert}`,
|
|
35562
|
+
fields: "CERT,NAME,CITY,STALP,ASSET",
|
|
35563
|
+
limit: 1
|
|
35564
|
+
},
|
|
35565
|
+
{ signal: controller.signal }
|
|
35566
|
+
),
|
|
35567
|
+
queryEndpoint(
|
|
35568
|
+
ENDPOINTS.FINANCIALS,
|
|
35569
|
+
{
|
|
35570
|
+
filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
|
|
35571
|
+
fields: CREDIT_FIELDS,
|
|
35572
|
+
limit: 1
|
|
35573
|
+
},
|
|
35574
|
+
{ signal: controller.signal }
|
|
35575
|
+
)
|
|
35576
|
+
]);
|
|
35577
|
+
const profileRecords = extractRecords(profileResponse);
|
|
35578
|
+
if (profileRecords.length === 0) {
|
|
35579
|
+
return formatToolError(new Error(`No institution found with CERT number ${params.cert}.`));
|
|
35580
|
+
}
|
|
35581
|
+
const profile = profileRecords[0];
|
|
35582
|
+
const financialRecords = extractRecords(financialsResponse);
|
|
35583
|
+
if (financialRecords.length === 0) {
|
|
35584
|
+
return formatToolError(
|
|
35585
|
+
new Error(
|
|
35586
|
+
`No financial data for CERT ${params.cert} at report date ${params.repdte}. Try an earlier quarter-end date (0331, 0630, 0930, 1231).`
|
|
35587
|
+
)
|
|
35588
|
+
);
|
|
35589
|
+
}
|
|
35590
|
+
const currentFinancials = financialRecords[0];
|
|
35591
|
+
await sendProgressNotification(server.server, progressToken, 0.5, "Computing credit metrics");
|
|
35592
|
+
const metrics = computeCreditMetrics(currentFinancials);
|
|
35593
|
+
const signals = scoreCreditConcentration(metrics);
|
|
35594
|
+
await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
|
|
35595
|
+
const summary = {
|
|
35596
|
+
institution: {
|
|
35597
|
+
cert: params.cert,
|
|
35598
|
+
name: String(profile.NAME ?? ""),
|
|
35599
|
+
city: String(profile.CITY ?? ""),
|
|
35600
|
+
state: String(profile.STALP ?? ""),
|
|
35601
|
+
total_assets: typeof currentFinancials.ASSET === "number" ? currentFinancials.ASSET : 0,
|
|
35602
|
+
report_date: params.repdte
|
|
35603
|
+
},
|
|
35604
|
+
metrics,
|
|
35605
|
+
signals
|
|
35606
|
+
};
|
|
35607
|
+
const text = truncateIfNeeded(
|
|
35608
|
+
formatCreditSummaryText(summary),
|
|
35609
|
+
CHARACTER_LIMIT
|
|
35610
|
+
);
|
|
35611
|
+
return {
|
|
35612
|
+
content: [{ type: "text", text }],
|
|
35613
|
+
structuredContent: summary
|
|
35614
|
+
};
|
|
35615
|
+
} catch (err) {
|
|
35616
|
+
return formatToolError(err);
|
|
35617
|
+
} finally {
|
|
35618
|
+
clearTimeout(timeoutId);
|
|
35619
|
+
}
|
|
35620
|
+
}
|
|
35621
|
+
);
|
|
35622
|
+
}
|
|
35623
|
+
|
|
35624
|
+
// src/tools/fundingProfile.ts
|
|
35625
|
+
var import_zod13 = require("zod");
|
|
35626
|
+
|
|
35627
|
+
// src/tools/shared/fundingProfile.ts
|
|
35628
|
+
var FUNDING_FIELDS = "CERT,REPDTE,ASSET,DEP,DEPDOM,DEPFOR,COREDEP,BROR,FREPP,EFREPP,EINTEXP,DEPDASTR,CHBALR,LNLSDEPR";
|
|
35629
|
+
function safePct2(num, den) {
|
|
35630
|
+
if (num === null || den === null || den === 0) return null;
|
|
35631
|
+
return num / den * 100;
|
|
35632
|
+
}
|
|
35633
|
+
function computeFundingMetrics(raw) {
|
|
35634
|
+
const asset = asNumber(raw.ASSET);
|
|
35635
|
+
const dep = asNumber(raw.DEP);
|
|
35636
|
+
const depfor = asNumber(raw.DEPFOR);
|
|
35637
|
+
const coredep = asNumber(raw.COREDEP);
|
|
35638
|
+
const bror = asNumber(raw.BROR);
|
|
35639
|
+
const frepp = asNumber(raw.FREPP);
|
|
35640
|
+
const chbalr = asNumber(raw.CHBALR);
|
|
35641
|
+
const wholesaleNum = dep !== null && coredep !== null && frepp !== null ? dep - coredep + frepp : null;
|
|
35642
|
+
return {
|
|
35643
|
+
core_deposit_ratio: safePct2(coredep, dep),
|
|
35644
|
+
brokered_deposit_ratio: bror,
|
|
35645
|
+
wholesale_funding_ratio: safePct2(wholesaleNum, asset),
|
|
35646
|
+
fhlb_to_assets: safePct2(frepp, asset),
|
|
35647
|
+
foreign_deposit_share: safePct2(depfor, dep),
|
|
35648
|
+
deposits_to_assets: safePct2(dep, asset),
|
|
35649
|
+
cost_of_funds: null,
|
|
35650
|
+
// needs prior quarter data — future enhancement
|
|
35651
|
+
cash_ratio: chbalr
|
|
35652
|
+
};
|
|
35653
|
+
}
|
|
35654
|
+
function scoreFundingRisks(m) {
|
|
35655
|
+
const signals = [];
|
|
35656
|
+
if (m.brokered_deposit_ratio !== null && m.brokered_deposit_ratio > 15) {
|
|
35657
|
+
signals.push({
|
|
35658
|
+
severity: "warning",
|
|
35659
|
+
category: "funding_risk",
|
|
35660
|
+
message: `Brokered deposits at ${m.brokered_deposit_ratio.toFixed(1)}% exceed 15% threshold, indicating potential funding volatility`
|
|
35661
|
+
});
|
|
35662
|
+
}
|
|
35663
|
+
if (m.wholesale_funding_ratio !== null && m.wholesale_funding_ratio > 25) {
|
|
35664
|
+
signals.push({
|
|
35665
|
+
severity: "warning",
|
|
35666
|
+
category: "funding_risk",
|
|
35667
|
+
message: `Wholesale funding at ${m.wholesale_funding_ratio.toFixed(1)}% of assets exceeds 25% threshold`
|
|
35668
|
+
});
|
|
35669
|
+
}
|
|
35670
|
+
if (m.core_deposit_ratio !== null && m.core_deposit_ratio < 60) {
|
|
35671
|
+
signals.push({
|
|
35672
|
+
severity: "warning",
|
|
35673
|
+
category: "funding_risk",
|
|
35674
|
+
message: `Core deposit ratio at ${m.core_deposit_ratio.toFixed(1)}% is below 60% threshold, indicating reliance on less stable funding`
|
|
35675
|
+
});
|
|
35676
|
+
}
|
|
35677
|
+
if (m.fhlb_to_assets !== null && m.fhlb_to_assets > 15) {
|
|
35678
|
+
signals.push({
|
|
35679
|
+
severity: "info",
|
|
35680
|
+
category: "funding_risk",
|
|
35681
|
+
message: `FHLB borrowings at ${m.fhlb_to_assets.toFixed(1)}% of assets exceed 15%, indicating elevated reliance on wholesale borrowing`
|
|
35682
|
+
});
|
|
35683
|
+
}
|
|
35684
|
+
return signals;
|
|
35685
|
+
}
|
|
35686
|
+
|
|
35687
|
+
// src/tools/fundingProfile.ts
|
|
35688
|
+
function fmtPct2(val) {
|
|
35689
|
+
return val !== null ? `${val.toFixed(1)}%` : "n/a";
|
|
35690
|
+
}
|
|
35691
|
+
function fmtDollarsK2(val) {
|
|
35692
|
+
return val !== null ? `$${Math.round(val).toLocaleString()}K` : "n/a";
|
|
35693
|
+
}
|
|
35694
|
+
function formatFundingSummaryText(summary) {
|
|
35695
|
+
const parts = [];
|
|
35696
|
+
const { institution: inst, metrics: m, signals } = summary;
|
|
35697
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
35698
|
+
parts.push(` Funding Profile Analysis: ${inst.name}`);
|
|
35699
|
+
parts.push(` ${inst.city}, ${inst.state} | CERT ${inst.cert} | Total Assets: ${fmtDollarsK2(inst.total_assets)}`);
|
|
35700
|
+
parts.push(` Report Date: ${inst.report_date}`);
|
|
35701
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
35702
|
+
parts.push("");
|
|
35703
|
+
parts.push("Deposit Composition");
|
|
35704
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35705
|
+
parts.push(` Deposits / Assets: ${fmtPct2(m.deposits_to_assets)}`);
|
|
35706
|
+
parts.push(` Core Deposit Ratio: ${fmtPct2(m.core_deposit_ratio)}`);
|
|
35707
|
+
parts.push(` Brokered Deposit Ratio: ${fmtPct2(m.brokered_deposit_ratio)}`);
|
|
35708
|
+
parts.push(` Foreign Deposit Share: ${fmtPct2(m.foreign_deposit_share)}`);
|
|
35709
|
+
parts.push("");
|
|
35710
|
+
parts.push("Wholesale Funding & Liquidity");
|
|
35711
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35712
|
+
parts.push(` Wholesale Funding Ratio: ${fmtPct2(m.wholesale_funding_ratio)}`);
|
|
35713
|
+
parts.push(` FHLB / Assets: ${fmtPct2(m.fhlb_to_assets)}`);
|
|
35714
|
+
parts.push(` Cash Ratio: ${fmtPct2(m.cash_ratio)}`);
|
|
35715
|
+
if (signals.length > 0) {
|
|
35716
|
+
parts.push("");
|
|
35717
|
+
parts.push("\u26A0 Funding Risk Signals");
|
|
35718
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35719
|
+
for (const signal of signals) {
|
|
35720
|
+
parts.push(` \u2022 ${signal.message}`);
|
|
35721
|
+
}
|
|
35722
|
+
}
|
|
35723
|
+
return parts.join("\n");
|
|
35724
|
+
}
|
|
35725
|
+
var FundingProfileSchema = import_zod13.z.object({
|
|
35726
|
+
cert: import_zod13.z.number().int().positive().describe("FDIC Certificate Number"),
|
|
35727
|
+
repdte: import_zod13.z.string().regex(/^\d{8}$/).optional().describe("Report date (YYYYMMDD). Defaults to most recent quarter.")
|
|
35728
|
+
});
|
|
35729
|
+
function registerFundingProfileTools(server) {
|
|
35730
|
+
server.registerTool(
|
|
35731
|
+
"fdic_analyze_funding_profile",
|
|
35732
|
+
{
|
|
35733
|
+
title: "Analyze Funding Profile",
|
|
35734
|
+
description: `Analyze deposit composition, wholesale funding reliance, and funding risk for an FDIC-insured institution.
|
|
35735
|
+
|
|
35736
|
+
Output includes:
|
|
35737
|
+
- Deposit composition (core, brokered, foreign deposit shares)
|
|
35738
|
+
- Wholesale funding reliance and FHLB advances relative to assets
|
|
35739
|
+
- Cash ratio for near-term liquidity
|
|
35740
|
+
- Funding risk signals based on supervisory thresholds
|
|
35741
|
+
- Structured JSON for programmatic consumption
|
|
35742
|
+
|
|
35743
|
+
NOTE: This is an analytical tool based on public financial data.`,
|
|
35744
|
+
inputSchema: FundingProfileSchema,
|
|
35745
|
+
annotations: {
|
|
35746
|
+
readOnlyHint: true,
|
|
35747
|
+
destructiveHint: false,
|
|
35748
|
+
idempotentHint: true,
|
|
35749
|
+
openWorldHint: true
|
|
35750
|
+
}
|
|
35751
|
+
},
|
|
35752
|
+
async (rawParams, extra) => {
|
|
35753
|
+
const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
|
|
35754
|
+
const controller = new AbortController();
|
|
35755
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
35756
|
+
const progressToken = extra._meta?.progressToken;
|
|
35757
|
+
try {
|
|
35758
|
+
const dateError = validateQuarterEndDate(params.repdte, "repdte");
|
|
35759
|
+
if (dateError) {
|
|
35760
|
+
return formatToolError(new Error(dateError));
|
|
35761
|
+
}
|
|
35762
|
+
await sendProgressNotification(server.server, progressToken, 0.1, "Fetching institution profile");
|
|
35763
|
+
const [profileResponse, financialsResponse] = await Promise.all([
|
|
35764
|
+
queryEndpoint(
|
|
35765
|
+
ENDPOINTS.INSTITUTIONS,
|
|
35766
|
+
{
|
|
35767
|
+
filters: `CERT:${params.cert}`,
|
|
35768
|
+
fields: "CERT,NAME,CITY,STALP,ASSET",
|
|
35769
|
+
limit: 1
|
|
35770
|
+
},
|
|
35771
|
+
{ signal: controller.signal }
|
|
35772
|
+
),
|
|
35773
|
+
queryEndpoint(
|
|
35774
|
+
ENDPOINTS.FINANCIALS,
|
|
35775
|
+
{
|
|
35776
|
+
filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
|
|
35777
|
+
fields: FUNDING_FIELDS,
|
|
35778
|
+
limit: 1
|
|
35779
|
+
},
|
|
35780
|
+
{ signal: controller.signal }
|
|
35781
|
+
)
|
|
35782
|
+
]);
|
|
35783
|
+
const profileRecords = extractRecords(profileResponse);
|
|
35784
|
+
if (profileRecords.length === 0) {
|
|
35785
|
+
return formatToolError(new Error(`No institution found with CERT number ${params.cert}.`));
|
|
35786
|
+
}
|
|
35787
|
+
const profile = profileRecords[0];
|
|
35788
|
+
const financialRecords = extractRecords(financialsResponse);
|
|
35789
|
+
if (financialRecords.length === 0) {
|
|
35790
|
+
return formatToolError(
|
|
35791
|
+
new Error(
|
|
35792
|
+
`No financial data for CERT ${params.cert} at report date ${params.repdte}. Try an earlier quarter-end date (0331, 0630, 0930, 1231).`
|
|
35793
|
+
)
|
|
35794
|
+
);
|
|
35795
|
+
}
|
|
35796
|
+
const currentFinancials = financialRecords[0];
|
|
35797
|
+
await sendProgressNotification(server.server, progressToken, 0.5, "Computing funding metrics");
|
|
35798
|
+
const metrics = computeFundingMetrics(currentFinancials);
|
|
35799
|
+
const signals = scoreFundingRisks(metrics);
|
|
35800
|
+
await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
|
|
35801
|
+
const summary = {
|
|
35802
|
+
institution: {
|
|
35803
|
+
cert: params.cert,
|
|
35804
|
+
name: String(profile.NAME ?? ""),
|
|
35805
|
+
city: String(profile.CITY ?? ""),
|
|
35806
|
+
state: String(profile.STALP ?? ""),
|
|
35807
|
+
total_assets: typeof currentFinancials.ASSET === "number" ? currentFinancials.ASSET : 0,
|
|
35808
|
+
report_date: params.repdte
|
|
35809
|
+
},
|
|
35810
|
+
metrics,
|
|
35811
|
+
signals
|
|
35812
|
+
};
|
|
35813
|
+
const text = truncateIfNeeded(
|
|
35814
|
+
formatFundingSummaryText(summary),
|
|
35815
|
+
CHARACTER_LIMIT
|
|
35816
|
+
);
|
|
35817
|
+
return {
|
|
35818
|
+
content: [{ type: "text", text }],
|
|
35819
|
+
structuredContent: summary
|
|
35820
|
+
};
|
|
35821
|
+
} catch (err) {
|
|
35822
|
+
return formatToolError(err);
|
|
35823
|
+
} finally {
|
|
35824
|
+
clearTimeout(timeoutId);
|
|
35825
|
+
}
|
|
35826
|
+
}
|
|
35827
|
+
);
|
|
35828
|
+
}
|
|
35829
|
+
|
|
35830
|
+
// src/tools/securitiesPortfolio.ts
|
|
35831
|
+
var import_zod14 = require("zod");
|
|
35832
|
+
|
|
35833
|
+
// src/tools/shared/securitiesPortfolio.ts
|
|
35834
|
+
var SECURITIES_FIELDS = "CERT,REPDTE,ASSET,EQTOT,SC,SCRES";
|
|
35835
|
+
function safePct3(num, den) {
|
|
35836
|
+
if (num === null || den === null || den === 0) return null;
|
|
35837
|
+
return num / den * 100;
|
|
35838
|
+
}
|
|
35839
|
+
function computeSecuritiesMetrics(raw) {
|
|
35840
|
+
const asset = asNumber(raw.ASSET);
|
|
35841
|
+
const eqtot = asNumber(raw.EQTOT);
|
|
35842
|
+
const sc = asNumber(raw.SC);
|
|
35843
|
+
const scres = asNumber(raw.SCRES);
|
|
35844
|
+
return {
|
|
35845
|
+
securities_to_assets: safePct3(sc, asset),
|
|
35846
|
+
securities_to_capital: safePct3(sc, eqtot),
|
|
35847
|
+
mbs_share: safePct3(scres, sc),
|
|
35848
|
+
afs_share: null,
|
|
35849
|
+
// SCAFS not available in FDIC API
|
|
35850
|
+
htm_share: null
|
|
35851
|
+
// SCHTML not available in FDIC API
|
|
35852
|
+
};
|
|
35853
|
+
}
|
|
35854
|
+
function scoreSecuritiesRisks(m) {
|
|
35855
|
+
const signals = [];
|
|
35856
|
+
if (m.securities_to_assets !== null && m.securities_to_assets > 40) {
|
|
35857
|
+
signals.push({
|
|
35858
|
+
severity: "info",
|
|
35859
|
+
category: "securities_risk",
|
|
35860
|
+
message: `Securities at ${m.securities_to_assets.toFixed(1)}% of assets indicates high concentration in the investment portfolio`
|
|
35861
|
+
});
|
|
35862
|
+
}
|
|
35863
|
+
if (m.securities_to_capital !== null && m.securities_to_capital > 300) {
|
|
35864
|
+
signals.push({
|
|
35865
|
+
severity: "warning",
|
|
35866
|
+
category: "securities_risk",
|
|
35867
|
+
message: `Securities at ${m.securities_to_capital.toFixed(0)}% of capital exceeds 300% threshold, indicating significant portfolio exposure`
|
|
35868
|
+
});
|
|
35869
|
+
}
|
|
35870
|
+
if (m.mbs_share !== null && m.mbs_share > 60) {
|
|
35871
|
+
signals.push({
|
|
35872
|
+
severity: "info",
|
|
35873
|
+
category: "securities_risk",
|
|
35874
|
+
message: `MBS concentration at ${m.mbs_share.toFixed(1)}% of securities portfolio indicates elevated interest rate risk`
|
|
35875
|
+
});
|
|
35876
|
+
}
|
|
35877
|
+
return signals;
|
|
35878
|
+
}
|
|
35879
|
+
|
|
35880
|
+
// src/tools/securitiesPortfolio.ts
|
|
35881
|
+
function fmtPct3(val) {
|
|
35882
|
+
return val !== null ? `${val.toFixed(1)}%` : "n/a";
|
|
35883
|
+
}
|
|
35884
|
+
function fmtDollarsK3(val) {
|
|
35885
|
+
return val !== null ? `$${Math.round(val).toLocaleString()}K` : "n/a";
|
|
35886
|
+
}
|
|
35887
|
+
function formatSecuritiesSummaryText(summary) {
|
|
35888
|
+
const parts = [];
|
|
35889
|
+
const { institution: inst, metrics: m, signals } = summary;
|
|
35890
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
35891
|
+
parts.push(` Securities Portfolio Analysis: ${inst.name}`);
|
|
35892
|
+
parts.push(` ${inst.city}, ${inst.state} | CERT ${inst.cert} | Total Assets: ${fmtDollarsK3(inst.total_assets)}`);
|
|
35893
|
+
parts.push(` Report Date: ${inst.report_date}`);
|
|
35894
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
35895
|
+
parts.push("");
|
|
35896
|
+
parts.push("Portfolio Overview");
|
|
35897
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35898
|
+
parts.push(` Securities / Assets: ${fmtPct3(m.securities_to_assets)}`);
|
|
35899
|
+
parts.push(` Securities / Capital: ${fmtPct3(m.securities_to_capital)}`);
|
|
35900
|
+
parts.push("");
|
|
35901
|
+
parts.push("Composition");
|
|
35902
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35903
|
+
parts.push(` MBS Concentration: ${fmtPct3(m.mbs_share)}`);
|
|
35904
|
+
parts.push(` AFS Share: ${fmtPct3(m.afs_share)}`);
|
|
35905
|
+
parts.push(` HTM Share: ${fmtPct3(m.htm_share)}`);
|
|
35906
|
+
if (signals.length > 0) {
|
|
35907
|
+
parts.push("");
|
|
35908
|
+
parts.push("\u26A0 Securities Risk Signals");
|
|
35909
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
35910
|
+
for (const signal of signals) {
|
|
35911
|
+
parts.push(` \u2022 ${signal.message}`);
|
|
35912
|
+
}
|
|
35913
|
+
}
|
|
35914
|
+
return parts.join("\n");
|
|
35915
|
+
}
|
|
35916
|
+
var SecuritiesPortfolioSchema = import_zod14.z.object({
|
|
35917
|
+
cert: import_zod14.z.number().int().positive().describe("FDIC Certificate Number"),
|
|
35918
|
+
repdte: import_zod14.z.string().regex(/^\d{8}$/).optional().describe("Report date (YYYYMMDD). Defaults to most recent quarter.")
|
|
35919
|
+
});
|
|
35920
|
+
function registerSecuritiesPortfolioTools(server) {
|
|
35921
|
+
server.registerTool(
|
|
35922
|
+
"fdic_analyze_securities_portfolio",
|
|
35923
|
+
{
|
|
35924
|
+
title: "Analyze Securities Portfolio",
|
|
35925
|
+
description: `Analyze securities portfolio size, composition, and concentration risk for an FDIC-insured institution.
|
|
35926
|
+
|
|
35927
|
+
Output includes:
|
|
35928
|
+
- Securities relative to total assets and capital
|
|
35929
|
+
- MBS concentration within the securities portfolio
|
|
35930
|
+
- AFS/HTM breakdown (when available)
|
|
35931
|
+
- Risk signals for portfolio concentration and interest rate exposure
|
|
35932
|
+
- Structured JSON for programmatic consumption
|
|
35933
|
+
|
|
35934
|
+
NOTE: This is an analytical tool based on public financial data. AFS/HTM breakdown is not currently available from the FDIC API.`,
|
|
35935
|
+
inputSchema: SecuritiesPortfolioSchema,
|
|
35936
|
+
annotations: {
|
|
35937
|
+
readOnlyHint: true,
|
|
35938
|
+
destructiveHint: false,
|
|
35939
|
+
idempotentHint: true,
|
|
35940
|
+
openWorldHint: true
|
|
35941
|
+
}
|
|
35942
|
+
},
|
|
35943
|
+
async (rawParams, extra) => {
|
|
35944
|
+
const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
|
|
35945
|
+
const controller = new AbortController();
|
|
35946
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
35947
|
+
const progressToken = extra._meta?.progressToken;
|
|
35948
|
+
try {
|
|
35949
|
+
const dateError = validateQuarterEndDate(params.repdte, "repdte");
|
|
35950
|
+
if (dateError) {
|
|
35951
|
+
return formatToolError(new Error(dateError));
|
|
35952
|
+
}
|
|
35953
|
+
await sendProgressNotification(server.server, progressToken, 0.1, "Fetching institution profile");
|
|
35954
|
+
const [profileResponse, financialsResponse] = await Promise.all([
|
|
35955
|
+
queryEndpoint(
|
|
35956
|
+
ENDPOINTS.INSTITUTIONS,
|
|
35957
|
+
{
|
|
35958
|
+
filters: `CERT:${params.cert}`,
|
|
35959
|
+
fields: "CERT,NAME,CITY,STALP,ASSET",
|
|
35960
|
+
limit: 1
|
|
35961
|
+
},
|
|
35962
|
+
{ signal: controller.signal }
|
|
35963
|
+
),
|
|
35964
|
+
queryEndpoint(
|
|
35965
|
+
ENDPOINTS.FINANCIALS,
|
|
35966
|
+
{
|
|
35967
|
+
filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
|
|
35968
|
+
fields: SECURITIES_FIELDS,
|
|
35969
|
+
limit: 1
|
|
35970
|
+
},
|
|
35971
|
+
{ signal: controller.signal }
|
|
35972
|
+
)
|
|
35973
|
+
]);
|
|
35974
|
+
const profileRecords = extractRecords(profileResponse);
|
|
35975
|
+
if (profileRecords.length === 0) {
|
|
35976
|
+
return formatToolError(new Error(`No institution found with CERT number ${params.cert}.`));
|
|
35977
|
+
}
|
|
35978
|
+
const profile = profileRecords[0];
|
|
35979
|
+
const financialRecords = extractRecords(financialsResponse);
|
|
35980
|
+
if (financialRecords.length === 0) {
|
|
35981
|
+
return formatToolError(
|
|
35982
|
+
new Error(
|
|
35983
|
+
`No financial data for CERT ${params.cert} at report date ${params.repdte}. Try an earlier quarter-end date (0331, 0630, 0930, 1231).`
|
|
35984
|
+
)
|
|
35985
|
+
);
|
|
35986
|
+
}
|
|
35987
|
+
const currentFinancials = financialRecords[0];
|
|
35988
|
+
await sendProgressNotification(server.server, progressToken, 0.5, "Computing securities metrics");
|
|
35989
|
+
const metrics = computeSecuritiesMetrics(currentFinancials);
|
|
35990
|
+
const signals = scoreSecuritiesRisks(metrics);
|
|
35991
|
+
await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
|
|
35992
|
+
const summary = {
|
|
35993
|
+
institution: {
|
|
35994
|
+
cert: params.cert,
|
|
35995
|
+
name: String(profile.NAME ?? ""),
|
|
35996
|
+
city: String(profile.CITY ?? ""),
|
|
35997
|
+
state: String(profile.STALP ?? ""),
|
|
35998
|
+
total_assets: typeof currentFinancials.ASSET === "number" ? currentFinancials.ASSET : 0,
|
|
35999
|
+
report_date: params.repdte
|
|
36000
|
+
},
|
|
36001
|
+
metrics,
|
|
36002
|
+
signals
|
|
36003
|
+
};
|
|
36004
|
+
const text = truncateIfNeeded(
|
|
36005
|
+
formatSecuritiesSummaryText(summary),
|
|
36006
|
+
CHARACTER_LIMIT
|
|
36007
|
+
);
|
|
36008
|
+
return {
|
|
36009
|
+
content: [{ type: "text", text }],
|
|
36010
|
+
structuredContent: summary
|
|
36011
|
+
};
|
|
36012
|
+
} catch (err) {
|
|
36013
|
+
return formatToolError(err);
|
|
36014
|
+
} finally {
|
|
36015
|
+
clearTimeout(timeoutId);
|
|
36016
|
+
}
|
|
36017
|
+
}
|
|
36018
|
+
);
|
|
36019
|
+
}
|
|
36020
|
+
|
|
36021
|
+
// src/tools/ubprAnalysis.ts
|
|
36022
|
+
var import_zod15 = require("zod");
|
|
36023
|
+
|
|
36024
|
+
// src/tools/shared/ubprRatios.ts
|
|
36025
|
+
var UBPR_FIELDS = "CERT,REPDTE,ASSET,ROA,ROE,ROAPTX,NIMY,EEFFR,INTINC,EINTEXP,NONII,NONIX,NETINC,ELNATRY,LNLSNET,LNRE,LNCI,LNCON,LNAG,LNOTH,DEP,COREDEP,DEPDOM,DEPFOR,BROR,FREPP,IDT1CER,IDT1RWAJR,EQV,EQTOT,LNLSDEPR,DEPDASTR,CHBALR,NPERFV,NCLNLSR,NTLNLSR,LNATRESR,LNRESNCR,SC";
|
|
36026
|
+
function safePct4(num, den) {
|
|
36027
|
+
if (num === null || den === null || den === 0) return null;
|
|
36028
|
+
return num / den * 100;
|
|
36029
|
+
}
|
|
36030
|
+
function growthRate(current, prior) {
|
|
36031
|
+
if (current === null || prior === null || prior === 0) return null;
|
|
36032
|
+
return (current - prior) / prior * 100;
|
|
36033
|
+
}
|
|
36034
|
+
function computeUbprRatios(raw) {
|
|
36035
|
+
const lnlsnet = asNumber(raw.LNLSNET);
|
|
36036
|
+
const lnre = asNumber(raw.LNRE);
|
|
36037
|
+
const lnci = asNumber(raw.LNCI);
|
|
36038
|
+
const lncon = asNumber(raw.LNCON);
|
|
36039
|
+
const lnag = asNumber(raw.LNAG);
|
|
36040
|
+
const dep = asNumber(raw.DEP);
|
|
36041
|
+
const coredep = asNumber(raw.COREDEP);
|
|
36042
|
+
const asset = asNumber(raw.ASSET);
|
|
36043
|
+
const eqtot = asNumber(raw.EQTOT);
|
|
36044
|
+
return {
|
|
36045
|
+
summary: {
|
|
36046
|
+
roa: asNumber(raw.ROA),
|
|
36047
|
+
roe: asNumber(raw.ROE),
|
|
36048
|
+
nim: asNumber(raw.NIMY),
|
|
36049
|
+
efficiency_ratio: asNumber(raw.EEFFR),
|
|
36050
|
+
pretax_roa: asNumber(raw.ROAPTX)
|
|
36051
|
+
},
|
|
36052
|
+
loan_mix: {
|
|
36053
|
+
re_share: safePct4(lnre, lnlsnet),
|
|
36054
|
+
ci_share: safePct4(lnci, lnlsnet),
|
|
36055
|
+
consumer_share: safePct4(lncon, lnlsnet),
|
|
36056
|
+
ag_share: safePct4(lnag, lnlsnet)
|
|
36057
|
+
},
|
|
36058
|
+
capital: {
|
|
36059
|
+
tier1_leverage: asNumber(raw.IDT1CER),
|
|
36060
|
+
tier1_rbc: asNumber(raw.IDT1RWAJR),
|
|
36061
|
+
equity_ratio: safePct4(eqtot, asset)
|
|
36062
|
+
},
|
|
36063
|
+
liquidity: {
|
|
36064
|
+
loan_to_deposit: safePct4(lnlsnet, dep),
|
|
36065
|
+
core_deposit_ratio: safePct4(coredep, dep),
|
|
36066
|
+
brokered_ratio: asNumber(raw.BROR),
|
|
36067
|
+
cash_ratio: asNumber(raw.CHBALR)
|
|
36068
|
+
}
|
|
36069
|
+
};
|
|
36070
|
+
}
|
|
36071
|
+
function computeGrowthRates(current, prior) {
|
|
36072
|
+
return {
|
|
36073
|
+
asset_growth: growthRate(asNumber(current.ASSET), asNumber(prior.ASSET)),
|
|
36074
|
+
loan_growth: growthRate(asNumber(current.LNLSNET), asNumber(prior.LNLSNET)),
|
|
36075
|
+
deposit_growth: growthRate(asNumber(current.DEP), asNumber(prior.DEP))
|
|
36076
|
+
};
|
|
36077
|
+
}
|
|
36078
|
+
|
|
36079
|
+
// src/tools/ubprAnalysis.ts
|
|
36080
|
+
function fmtPct4(val) {
|
|
36081
|
+
return val !== null ? `${val.toFixed(2)}%` : "n/a";
|
|
36082
|
+
}
|
|
36083
|
+
function fmtDollarsK4(val) {
|
|
36084
|
+
return val !== null ? `$${Math.round(val).toLocaleString()}K` : "n/a";
|
|
36085
|
+
}
|
|
36086
|
+
function formatUbprSummaryText(summary) {
|
|
36087
|
+
const parts = [];
|
|
36088
|
+
const { institution: inst, ratios, growth } = summary;
|
|
36089
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36090
|
+
parts.push(` UBPR-Equivalent Ratio Analysis: ${inst.name}`);
|
|
36091
|
+
parts.push(` ${inst.city}, ${inst.state} | CERT ${inst.cert} | Total Assets: ${fmtDollarsK4(inst.total_assets)}`);
|
|
36092
|
+
parts.push(` Report Date: ${inst.report_date} (vs. year-ago: ${inst.prior_report_date})`);
|
|
36093
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36094
|
+
parts.push("");
|
|
36095
|
+
parts.push("Summary Ratios");
|
|
36096
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36097
|
+
parts.push(` Return on Assets (ROA): ${fmtPct4(ratios.summary.roa)}`);
|
|
36098
|
+
parts.push(` Return on Equity (ROE): ${fmtPct4(ratios.summary.roe)}`);
|
|
36099
|
+
parts.push(` Net Interest Margin: ${fmtPct4(ratios.summary.nim)}`);
|
|
36100
|
+
parts.push(` Efficiency Ratio: ${fmtPct4(ratios.summary.efficiency_ratio)}`);
|
|
36101
|
+
parts.push(` Pretax ROA: ${fmtPct4(ratios.summary.pretax_roa)}`);
|
|
36102
|
+
parts.push("");
|
|
36103
|
+
parts.push("Loan Mix");
|
|
36104
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36105
|
+
parts.push(` Real Estate: ${fmtPct4(ratios.loan_mix.re_share)}`);
|
|
36106
|
+
parts.push(` Commercial: ${fmtPct4(ratios.loan_mix.ci_share)}`);
|
|
36107
|
+
parts.push(` Consumer: ${fmtPct4(ratios.loan_mix.consumer_share)}`);
|
|
36108
|
+
parts.push(` Agricultural: ${fmtPct4(ratios.loan_mix.ag_share)}`);
|
|
36109
|
+
parts.push("");
|
|
36110
|
+
parts.push("Capital Adequacy");
|
|
36111
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36112
|
+
parts.push(` Tier 1 Leverage: ${fmtPct4(ratios.capital.tier1_leverage)}`);
|
|
36113
|
+
parts.push(` Tier 1 Risk-Based: ${fmtPct4(ratios.capital.tier1_rbc)}`);
|
|
36114
|
+
parts.push(` Equity / Assets: ${fmtPct4(ratios.capital.equity_ratio)}`);
|
|
36115
|
+
parts.push("");
|
|
36116
|
+
parts.push("Liquidity");
|
|
36117
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36118
|
+
parts.push(` Loans / Deposits: ${fmtPct4(ratios.liquidity.loan_to_deposit)}`);
|
|
36119
|
+
parts.push(` Core Deposits / Deposits: ${fmtPct4(ratios.liquidity.core_deposit_ratio)}`);
|
|
36120
|
+
parts.push(` Brokered Deposits: ${fmtPct4(ratios.liquidity.brokered_ratio)}`);
|
|
36121
|
+
parts.push(` Cash / Assets: ${fmtPct4(ratios.liquidity.cash_ratio)}`);
|
|
36122
|
+
parts.push("");
|
|
36123
|
+
parts.push("Year-over-Year Growth");
|
|
36124
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36125
|
+
parts.push(` Asset Growth: ${fmtPct4(growth.asset_growth)}`);
|
|
36126
|
+
parts.push(` Loan Growth: ${fmtPct4(growth.loan_growth)}`);
|
|
36127
|
+
parts.push(` Deposit Growth: ${fmtPct4(growth.deposit_growth)}`);
|
|
36128
|
+
parts.push("");
|
|
36129
|
+
parts.push("Note: Ratios computed from FDIC Call Report data. These are");
|
|
36130
|
+
parts.push("UBPR-equivalent calculations, not official FFIEC UBPR output.");
|
|
36131
|
+
return parts.join("\n");
|
|
36132
|
+
}
|
|
36133
|
+
var UbprAnalysisSchema = import_zod15.z.object({
|
|
36134
|
+
cert: import_zod15.z.number().int().positive().describe("FDIC Certificate Number"),
|
|
36135
|
+
repdte: import_zod15.z.string().length(8).optional().describe("Report date (YYYYMMDD). Defaults to most recent quarter.")
|
|
36136
|
+
});
|
|
36137
|
+
function registerUbprAnalysisTools(server) {
|
|
36138
|
+
server.registerTool(
|
|
36139
|
+
"fdic_ubpr_analysis",
|
|
36140
|
+
{
|
|
36141
|
+
title: "UBPR-Equivalent Ratio Analysis",
|
|
36142
|
+
description: `Compute UBPR-equivalent ratio analysis for an FDIC-insured institution. Includes summary ratios (ROA, ROE, NIM, efficiency), loan mix, capital adequacy, liquidity metrics, and year-over-year growth rates. Ratios are computed from Call Report data and are UBPR-equivalent, not official FFIEC UBPR output.
|
|
36143
|
+
|
|
36144
|
+
Output includes:
|
|
36145
|
+
- Summary ratios: ROA, ROE, NIM, efficiency ratio, pretax ROA
|
|
36146
|
+
- Loan mix: real estate, commercial, consumer, agricultural shares
|
|
36147
|
+
- Capital adequacy: Tier 1 leverage, Tier 1 risk-based, equity ratio
|
|
36148
|
+
- Liquidity: loan-to-deposit, core deposit ratio, brokered deposits, cash ratio
|
|
36149
|
+
- Year-over-year growth: assets, loans, deposits
|
|
36150
|
+
- Structured JSON for programmatic consumption
|
|
36151
|
+
|
|
36152
|
+
NOTE: This is an analytical tool based on public financial data.`,
|
|
36153
|
+
inputSchema: UbprAnalysisSchema,
|
|
36154
|
+
annotations: {
|
|
36155
|
+
readOnlyHint: true,
|
|
36156
|
+
destructiveHint: false,
|
|
36157
|
+
idempotentHint: true,
|
|
36158
|
+
openWorldHint: true
|
|
36159
|
+
}
|
|
36160
|
+
},
|
|
36161
|
+
async (rawParams, extra) => {
|
|
36162
|
+
const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
|
|
36163
|
+
const controller = new AbortController();
|
|
36164
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
36165
|
+
const progressToken = extra._meta?.progressToken;
|
|
36166
|
+
try {
|
|
36167
|
+
const dateError = validateQuarterEndDate(params.repdte, "repdte");
|
|
36168
|
+
if (dateError) {
|
|
36169
|
+
return formatToolError(new Error(dateError));
|
|
36170
|
+
}
|
|
36171
|
+
const priorRepdte = getReportDateOneYearPrior(params.repdte);
|
|
36172
|
+
await sendProgressNotification(server.server, progressToken, 0.1, "Fetching institution profile");
|
|
36173
|
+
const [profileResponse, currentResponse, priorResponse] = await Promise.all([
|
|
36174
|
+
queryEndpoint(
|
|
36175
|
+
ENDPOINTS.INSTITUTIONS,
|
|
36176
|
+
{
|
|
36177
|
+
filters: `CERT:${params.cert}`,
|
|
36178
|
+
fields: "CERT,NAME,CITY,STALP,ASSET",
|
|
36179
|
+
limit: 1
|
|
36180
|
+
},
|
|
36181
|
+
{ signal: controller.signal }
|
|
36182
|
+
),
|
|
36183
|
+
queryEndpoint(
|
|
36184
|
+
ENDPOINTS.FINANCIALS,
|
|
36185
|
+
{
|
|
36186
|
+
filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
|
|
36187
|
+
fields: UBPR_FIELDS,
|
|
36188
|
+
limit: 1
|
|
36189
|
+
},
|
|
36190
|
+
{ signal: controller.signal }
|
|
36191
|
+
),
|
|
36192
|
+
queryEndpoint(
|
|
36193
|
+
ENDPOINTS.FINANCIALS,
|
|
36194
|
+
{
|
|
36195
|
+
filters: `CERT:${params.cert} AND REPDTE:${priorRepdte}`,
|
|
36196
|
+
fields: UBPR_FIELDS,
|
|
36197
|
+
limit: 1
|
|
36198
|
+
},
|
|
36199
|
+
{ signal: controller.signal }
|
|
36200
|
+
)
|
|
36201
|
+
]);
|
|
36202
|
+
const profileRecords = extractRecords(profileResponse);
|
|
36203
|
+
if (profileRecords.length === 0) {
|
|
36204
|
+
return formatToolError(new Error(`No institution found with CERT number ${params.cert}.`));
|
|
36205
|
+
}
|
|
36206
|
+
const profile = profileRecords[0];
|
|
36207
|
+
const currentRecords = extractRecords(currentResponse);
|
|
36208
|
+
if (currentRecords.length === 0) {
|
|
36209
|
+
return formatToolError(
|
|
36210
|
+
new Error(
|
|
36211
|
+
`No financial data for CERT ${params.cert} at report date ${params.repdte}. Try an earlier quarter-end date (0331, 0630, 0930, 1231).`
|
|
36212
|
+
)
|
|
36213
|
+
);
|
|
36214
|
+
}
|
|
36215
|
+
const currentFinancials = currentRecords[0];
|
|
36216
|
+
await sendProgressNotification(server.server, progressToken, 0.5, "Computing UBPR-equivalent ratios");
|
|
36217
|
+
const ratios = computeUbprRatios(currentFinancials);
|
|
36218
|
+
const priorRecords = extractRecords(priorResponse);
|
|
36219
|
+
const priorFinancials = priorRecords.length > 0 ? priorRecords[0] : {};
|
|
36220
|
+
const growth = computeGrowthRates(currentFinancials, priorFinancials);
|
|
36221
|
+
await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
|
|
36222
|
+
const summary = {
|
|
36223
|
+
institution: {
|
|
36224
|
+
cert: params.cert,
|
|
36225
|
+
name: String(profile.NAME ?? ""),
|
|
36226
|
+
city: String(profile.CITY ?? ""),
|
|
36227
|
+
state: String(profile.STALP ?? ""),
|
|
36228
|
+
total_assets: typeof currentFinancials.ASSET === "number" ? currentFinancials.ASSET : 0,
|
|
36229
|
+
report_date: params.repdte,
|
|
36230
|
+
prior_report_date: priorRepdte
|
|
36231
|
+
},
|
|
36232
|
+
ratios,
|
|
36233
|
+
growth,
|
|
36234
|
+
disclaimer: "Ratios computed from FDIC Call Report data. UBPR-equivalent, not official FFIEC output."
|
|
36235
|
+
};
|
|
36236
|
+
const text = truncateIfNeeded(
|
|
36237
|
+
formatUbprSummaryText(summary),
|
|
36238
|
+
CHARACTER_LIMIT
|
|
36239
|
+
);
|
|
36240
|
+
return {
|
|
36241
|
+
content: [{ type: "text", text }],
|
|
36242
|
+
structuredContent: summary
|
|
36243
|
+
};
|
|
36244
|
+
} catch (err) {
|
|
36245
|
+
return formatToolError(err);
|
|
36246
|
+
} finally {
|
|
36247
|
+
clearTimeout(timeoutId);
|
|
36248
|
+
}
|
|
36249
|
+
}
|
|
36250
|
+
);
|
|
36251
|
+
}
|
|
36252
|
+
|
|
36253
|
+
// src/tools/marketShareAnalysis.ts
|
|
36254
|
+
var import_zod16 = require("zod");
|
|
36255
|
+
|
|
36256
|
+
// src/tools/shared/marketShare.ts
|
|
36257
|
+
function computeMarketShare(branches) {
|
|
36258
|
+
if (branches.length === 0) return [];
|
|
36259
|
+
const byInstitution = /* @__PURE__ */ new Map();
|
|
36260
|
+
for (const branch of branches) {
|
|
36261
|
+
const existing = byInstitution.get(branch.cert);
|
|
36262
|
+
if (existing) {
|
|
36263
|
+
existing.deposits += branch.deposits;
|
|
36264
|
+
existing.branches += 1;
|
|
36265
|
+
} else {
|
|
36266
|
+
byInstitution.set(branch.cert, {
|
|
36267
|
+
name: branch.name,
|
|
36268
|
+
deposits: branch.deposits,
|
|
36269
|
+
branches: 1
|
|
36270
|
+
});
|
|
36271
|
+
}
|
|
36272
|
+
}
|
|
36273
|
+
const totalDeposits = Array.from(byInstitution.values()).reduce(
|
|
36274
|
+
(sum, inst) => sum + inst.deposits,
|
|
36275
|
+
0
|
|
36276
|
+
);
|
|
36277
|
+
const participants = [];
|
|
36278
|
+
for (const [cert, inst] of byInstitution.entries()) {
|
|
36279
|
+
participants.push({
|
|
36280
|
+
cert,
|
|
36281
|
+
name: inst.name,
|
|
36282
|
+
total_deposits: inst.deposits,
|
|
36283
|
+
branch_count: inst.branches,
|
|
36284
|
+
market_share: totalDeposits > 0 ? inst.deposits / totalDeposits * 100 : 0,
|
|
36285
|
+
rank: 0
|
|
36286
|
+
// assigned below
|
|
36287
|
+
});
|
|
36288
|
+
}
|
|
36289
|
+
participants.sort((a, b) => b.total_deposits - a.total_deposits);
|
|
36290
|
+
for (let i = 0; i < participants.length; i++) {
|
|
36291
|
+
participants[i].rank = i + 1;
|
|
36292
|
+
}
|
|
36293
|
+
return participants;
|
|
36294
|
+
}
|
|
36295
|
+
function computeHHI(shares) {
|
|
36296
|
+
return shares.reduce((sum, s) => sum + s * s, 0);
|
|
36297
|
+
}
|
|
36298
|
+
function classifyConcentration(hhi) {
|
|
36299
|
+
if (hhi < 1500) return "unconcentrated";
|
|
36300
|
+
if (hhi <= 2500) return "moderately_concentrated";
|
|
36301
|
+
return "highly_concentrated";
|
|
36302
|
+
}
|
|
36303
|
+
function buildMarketConcentration(participants) {
|
|
36304
|
+
const shares = participants.map((p) => p.market_share);
|
|
36305
|
+
const hhi = computeHHI(shares);
|
|
36306
|
+
return {
|
|
36307
|
+
hhi: Math.round(hhi * 100) / 100,
|
|
36308
|
+
classification: classifyConcentration(hhi),
|
|
36309
|
+
total_deposits: participants.reduce((s, p) => s + p.total_deposits, 0),
|
|
36310
|
+
institution_count: participants.length
|
|
36311
|
+
};
|
|
36312
|
+
}
|
|
36313
|
+
|
|
36314
|
+
// src/tools/marketShareAnalysis.ts
|
|
36315
|
+
var SOD_FIELDS = "CERT,UNINAME,DEPSUMBR,BRNUM,MSANAMEBR,CNTYBR,STALPBR,YEAR";
|
|
36316
|
+
var SOD_FETCH_LIMIT = 1e4;
|
|
36317
|
+
function fmtNum(val) {
|
|
36318
|
+
return Math.round(val).toLocaleString("en-US");
|
|
36319
|
+
}
|
|
36320
|
+
function formatMarketShareText(summary) {
|
|
36321
|
+
const parts = [];
|
|
36322
|
+
const { market, concentration } = summary;
|
|
36323
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36324
|
+
parts.push(" Deposit Market Share Analysis");
|
|
36325
|
+
parts.push(` ${market.name} | ${market.year} SOD Data`);
|
|
36326
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36327
|
+
parts.push("");
|
|
36328
|
+
parts.push("Market Overview");
|
|
36329
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36330
|
+
parts.push(` Total Market Deposits: $${fmtNum(concentration.total_deposits)}K`);
|
|
36331
|
+
parts.push(` Institutions: ${concentration.institution_count}`);
|
|
36332
|
+
parts.push(
|
|
36333
|
+
` HHI: ${fmtNum(concentration.hhi)} (${concentration.classification.replace(/_/g, " ")})`
|
|
36334
|
+
);
|
|
36335
|
+
if (summary.highlighted_institution) {
|
|
36336
|
+
const h = summary.highlighted_institution;
|
|
36337
|
+
parts.push("");
|
|
36338
|
+
parts.push(
|
|
36339
|
+
` \u2605 ${h.name} (CERT ${h.cert}): Rank #${h.rank}, ${h.market_share.toFixed(1)}% share, $${fmtNum(h.total_deposits)}K`
|
|
36340
|
+
);
|
|
36341
|
+
}
|
|
36342
|
+
parts.push("");
|
|
36343
|
+
parts.push("Top 20 Institutions");
|
|
36344
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36345
|
+
parts.push(
|
|
36346
|
+
" Rank Institution Deposits ($K) Share Branches"
|
|
36347
|
+
);
|
|
36348
|
+
parts.push(
|
|
36349
|
+
" \u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
|
|
36350
|
+
);
|
|
36351
|
+
const top = summary.participants.slice(0, 20);
|
|
36352
|
+
for (const p of top) {
|
|
36353
|
+
const rank = String(p.rank).padStart(4);
|
|
36354
|
+
const name = p.name.length > 29 ? p.name.slice(0, 26) + "..." : p.name;
|
|
36355
|
+
const deposits = fmtNum(p.total_deposits).padStart(13);
|
|
36356
|
+
const share = `${p.market_share.toFixed(1)}%`.padStart(6);
|
|
36357
|
+
const branches = String(p.branch_count).padStart(8);
|
|
36358
|
+
parts.push(
|
|
36359
|
+
` ${rank} ${name.padEnd(29)} ${deposits} ${share} ${branches}`
|
|
36360
|
+
);
|
|
36361
|
+
}
|
|
36362
|
+
return parts.join("\n");
|
|
36363
|
+
}
|
|
36364
|
+
function getDefaultSodYear() {
|
|
36365
|
+
return (/* @__PURE__ */ new Date()).getFullYear() - 1;
|
|
36366
|
+
}
|
|
36367
|
+
var MarketShareInputSchema = import_zod16.z.object({
|
|
36368
|
+
msa: import_zod16.z.string().optional().describe(
|
|
36369
|
+
'MSA name to filter (e.g., "Dallas-Fort Worth-Arlington"). Use MSANAMEBR field.'
|
|
36370
|
+
),
|
|
36371
|
+
county: import_zod16.z.string().optional().describe('County name (e.g., "Travis"). Requires state.'),
|
|
36372
|
+
state: import_zod16.z.string().length(2).optional().describe(
|
|
36373
|
+
"Two-letter state abbreviation (e.g., TX). Required when using county filter."
|
|
36374
|
+
),
|
|
36375
|
+
year: import_zod16.z.number().int().min(1994).optional().describe("SOD report year (1994-present). Defaults to most recent."),
|
|
36376
|
+
cert: import_zod16.z.number().int().positive().optional().describe("Highlight a specific institution in the results.")
|
|
36377
|
+
});
|
|
36378
|
+
function registerMarketShareAnalysisTools(server) {
|
|
36379
|
+
server.registerTool(
|
|
36380
|
+
"fdic_market_share_analysis",
|
|
36381
|
+
{
|
|
36382
|
+
title: "Deposit Market Share Analysis",
|
|
36383
|
+
description: `Analyze deposit market share and concentration for an MSA or county market using FDIC Summary of Deposits (SOD) data.
|
|
36384
|
+
|
|
36385
|
+
Computes market share for all institutions in a geographic market, ranks them by deposits, and calculates the Herfindahl-Hirschman Index (HHI) for market concentration analysis per DOJ/FTC merger guidelines.
|
|
36386
|
+
|
|
36387
|
+
Output includes:
|
|
36388
|
+
- Market overview with total deposits, institution count, and HHI classification
|
|
36389
|
+
- Optional highlighted institution showing rank and share
|
|
36390
|
+
- Top institutions ranked by deposit market share
|
|
36391
|
+
- Structured JSON for programmatic consumption
|
|
36392
|
+
|
|
36393
|
+
Requires at least one of: msa, or county + state.`,
|
|
36394
|
+
inputSchema: MarketShareInputSchema,
|
|
36395
|
+
annotations: {
|
|
36396
|
+
readOnlyHint: true,
|
|
36397
|
+
destructiveHint: false,
|
|
36398
|
+
idempotentHint: true,
|
|
36399
|
+
openWorldHint: true
|
|
36400
|
+
}
|
|
36401
|
+
},
|
|
36402
|
+
async (rawParams, extra) => {
|
|
36403
|
+
const controller = new AbortController();
|
|
36404
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
36405
|
+
const progressToken = extra._meta?.progressToken;
|
|
36406
|
+
try {
|
|
36407
|
+
if (!rawParams.msa && !rawParams.county) {
|
|
36408
|
+
return formatToolError(
|
|
36409
|
+
new Error(
|
|
36410
|
+
"At least one of 'msa' or 'county' (with 'state') must be provided."
|
|
36411
|
+
)
|
|
36412
|
+
);
|
|
36413
|
+
}
|
|
36414
|
+
if (rawParams.county && !rawParams.state) {
|
|
36415
|
+
return formatToolError(
|
|
36416
|
+
new Error(
|
|
36417
|
+
"'state' is required when using 'county' filter."
|
|
36418
|
+
)
|
|
36419
|
+
);
|
|
36420
|
+
}
|
|
36421
|
+
const year = rawParams.year ?? getDefaultSodYear();
|
|
36422
|
+
let filterStr;
|
|
36423
|
+
let marketName;
|
|
36424
|
+
if (rawParams.msa) {
|
|
36425
|
+
filterStr = `MSANAMEBR:"${rawParams.msa}" AND YEAR:${year}`;
|
|
36426
|
+
marketName = rawParams.msa;
|
|
36427
|
+
} else {
|
|
36428
|
+
filterStr = `CNTYBR:"${rawParams.county}" AND STALPBR:${rawParams.state} AND YEAR:${year}`;
|
|
36429
|
+
marketName = `${rawParams.county} County, ${rawParams.state}`;
|
|
36430
|
+
}
|
|
36431
|
+
await sendProgressNotification(
|
|
36432
|
+
server.server,
|
|
36433
|
+
progressToken,
|
|
36434
|
+
0.1,
|
|
36435
|
+
"Fetching SOD records for market"
|
|
36436
|
+
);
|
|
36437
|
+
const response = await queryEndpoint(
|
|
36438
|
+
ENDPOINTS.SOD,
|
|
36439
|
+
{
|
|
36440
|
+
filters: filterStr,
|
|
36441
|
+
fields: SOD_FIELDS,
|
|
36442
|
+
limit: SOD_FETCH_LIMIT,
|
|
36443
|
+
sort_by: "DEPSUMBR",
|
|
36444
|
+
sort_order: "DESC"
|
|
36445
|
+
},
|
|
36446
|
+
{ signal: controller.signal }
|
|
36447
|
+
);
|
|
36448
|
+
const records = extractRecords(response);
|
|
36449
|
+
if (records.length === 0) {
|
|
36450
|
+
return formatToolError(
|
|
36451
|
+
new Error(
|
|
36452
|
+
`No SOD records found for ${marketName} in ${year}. Verify the market name and year.`
|
|
36453
|
+
)
|
|
36454
|
+
);
|
|
36455
|
+
}
|
|
36456
|
+
await sendProgressNotification(
|
|
36457
|
+
server.server,
|
|
36458
|
+
progressToken,
|
|
36459
|
+
0.5,
|
|
36460
|
+
"Computing market shares"
|
|
36461
|
+
);
|
|
36462
|
+
const branches = records.map((r) => ({
|
|
36463
|
+
cert: Number(r.CERT),
|
|
36464
|
+
name: String(r.UNINAME ?? ""),
|
|
36465
|
+
deposits: typeof r.DEPSUMBR === "number" ? r.DEPSUMBR : 0
|
|
36466
|
+
}));
|
|
36467
|
+
const participants = computeMarketShare(branches);
|
|
36468
|
+
const concentration = buildMarketConcentration(participants);
|
|
36469
|
+
await sendProgressNotification(
|
|
36470
|
+
server.server,
|
|
36471
|
+
progressToken,
|
|
36472
|
+
0.8,
|
|
36473
|
+
"Formatting results"
|
|
36474
|
+
);
|
|
36475
|
+
let highlighted;
|
|
36476
|
+
if (rawParams.cert) {
|
|
36477
|
+
const found = participants.find((p) => p.cert === rawParams.cert);
|
|
36478
|
+
if (found) {
|
|
36479
|
+
highlighted = {
|
|
36480
|
+
cert: found.cert,
|
|
36481
|
+
name: found.name,
|
|
36482
|
+
rank: found.rank,
|
|
36483
|
+
market_share: found.market_share,
|
|
36484
|
+
total_deposits: found.total_deposits,
|
|
36485
|
+
branch_count: found.branch_count
|
|
36486
|
+
};
|
|
36487
|
+
}
|
|
36488
|
+
}
|
|
36489
|
+
const summary = {
|
|
36490
|
+
market: { name: marketName, year },
|
|
36491
|
+
concentration,
|
|
36492
|
+
highlighted_institution: highlighted,
|
|
36493
|
+
participants: participants.slice(0, 50)
|
|
36494
|
+
};
|
|
36495
|
+
const text = truncateIfNeeded(formatMarketShareText(summary), CHARACTER_LIMIT);
|
|
36496
|
+
return {
|
|
36497
|
+
content: [{ type: "text", text }],
|
|
36498
|
+
structuredContent: summary
|
|
36499
|
+
};
|
|
36500
|
+
} catch (err) {
|
|
36501
|
+
return formatToolError(err);
|
|
36502
|
+
} finally {
|
|
36503
|
+
clearTimeout(timeoutId);
|
|
36504
|
+
}
|
|
36505
|
+
}
|
|
36506
|
+
);
|
|
36507
|
+
}
|
|
36508
|
+
|
|
36509
|
+
// src/tools/franchiseFootprint.ts
|
|
36510
|
+
var import_zod17 = require("zod");
|
|
36511
|
+
var SOD_BRANCH_FIELDS = "CERT,UNINAME,DEPSUMBR,BRNUM,MSANAMEBR,CNTYBR,STALPBR,YEAR";
|
|
36512
|
+
var SOD_FETCH_LIMIT2 = 1e4;
|
|
36513
|
+
var NON_MSA_LABEL = "Non-MSA / Rural";
|
|
36514
|
+
function fmtNum2(val) {
|
|
36515
|
+
return Math.round(val).toLocaleString("en-US");
|
|
36516
|
+
}
|
|
36517
|
+
function formatFranchiseFootprintText(summary) {
|
|
36518
|
+
const parts = [];
|
|
36519
|
+
const { institution: inst } = summary;
|
|
36520
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36521
|
+
parts.push(` Franchise Footprint: ${inst.name}`);
|
|
36522
|
+
parts.push(` CERT ${inst.cert} | ${inst.year} SOD Data`);
|
|
36523
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36524
|
+
parts.push("");
|
|
36525
|
+
parts.push(` Total Branches: ${summary.summary.total_branches}`);
|
|
36526
|
+
parts.push(` Total Deposits: $${fmtNum2(summary.summary.total_deposits)}K`);
|
|
36527
|
+
parts.push(` Markets: ${summary.summary.market_count}`);
|
|
36528
|
+
parts.push("");
|
|
36529
|
+
parts.push("Market Breakdown");
|
|
36530
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36531
|
+
parts.push(
|
|
36532
|
+
" Market Branches Deposits ($K) % of Total"
|
|
36533
|
+
);
|
|
36534
|
+
parts.push(
|
|
36535
|
+
" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
|
|
36536
|
+
);
|
|
36537
|
+
for (const m of summary.markets) {
|
|
36538
|
+
const name = m.market_name.length > 31 ? m.market_name.slice(0, 28) + "..." : m.market_name;
|
|
36539
|
+
const branches = String(m.branch_count).padStart(8);
|
|
36540
|
+
const deposits = fmtNum2(m.total_deposits).padStart(13);
|
|
36541
|
+
const pct = `${m.pct_of_total.toFixed(1)}%`.padStart(10);
|
|
36542
|
+
parts.push(` ${name.padEnd(31)} ${branches} ${deposits} ${pct}`);
|
|
36543
|
+
}
|
|
36544
|
+
return parts.join("\n");
|
|
36545
|
+
}
|
|
36546
|
+
function getDefaultSodYear2() {
|
|
36547
|
+
return (/* @__PURE__ */ new Date()).getFullYear() - 1;
|
|
36548
|
+
}
|
|
36549
|
+
var FranchiseFootprintInputSchema = import_zod17.z.object({
|
|
36550
|
+
cert: import_zod17.z.number().int().positive().describe("FDIC Certificate Number"),
|
|
36551
|
+
year: import_zod17.z.number().int().min(1994).optional().describe("SOD report year. Defaults to most recent.")
|
|
36552
|
+
});
|
|
36553
|
+
function registerFranchiseFootprintTools(server) {
|
|
36554
|
+
server.registerTool(
|
|
36555
|
+
"fdic_franchise_footprint",
|
|
36556
|
+
{
|
|
36557
|
+
title: "Institution Franchise Footprint",
|
|
36558
|
+
description: `Analyze the geographic franchise footprint of an FDIC-insured institution using Summary of Deposits (SOD) data.
|
|
36559
|
+
|
|
36560
|
+
Shows how an institution's branches and deposits are distributed across metropolitan statistical areas (MSAs), providing a market-by-market breakdown of branch count, deposit totals, and percentage of the institution's total deposits.
|
|
36561
|
+
|
|
36562
|
+
Output includes:
|
|
36563
|
+
- Total branch count, deposits, and market count
|
|
36564
|
+
- Market-by-market breakdown sorted by deposits
|
|
36565
|
+
- Structured JSON for programmatic consumption
|
|
36566
|
+
|
|
36567
|
+
Branches outside MSAs are grouped under "Non-MSA / Rural".`,
|
|
36568
|
+
inputSchema: FranchiseFootprintInputSchema,
|
|
36569
|
+
annotations: {
|
|
36570
|
+
readOnlyHint: true,
|
|
36571
|
+
destructiveHint: false,
|
|
36572
|
+
idempotentHint: true,
|
|
36573
|
+
openWorldHint: true
|
|
36574
|
+
}
|
|
36575
|
+
},
|
|
36576
|
+
async (rawParams, extra) => {
|
|
36577
|
+
const controller = new AbortController();
|
|
36578
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
36579
|
+
const progressToken = extra._meta?.progressToken;
|
|
36580
|
+
try {
|
|
36581
|
+
const year = rawParams.year ?? getDefaultSodYear2();
|
|
36582
|
+
await sendProgressNotification(
|
|
36583
|
+
server.server,
|
|
36584
|
+
progressToken,
|
|
36585
|
+
0.1,
|
|
36586
|
+
"Fetching institution profile"
|
|
36587
|
+
);
|
|
36588
|
+
const [profileResponse, sodResponse] = await Promise.all([
|
|
36589
|
+
queryEndpoint(
|
|
36590
|
+
ENDPOINTS.INSTITUTIONS,
|
|
36591
|
+
{
|
|
36592
|
+
filters: `CERT:${rawParams.cert}`,
|
|
36593
|
+
fields: "CERT,NAME",
|
|
36594
|
+
limit: 1
|
|
36595
|
+
},
|
|
36596
|
+
{ signal: controller.signal }
|
|
36597
|
+
),
|
|
36598
|
+
queryEndpoint(
|
|
36599
|
+
ENDPOINTS.SOD,
|
|
36600
|
+
{
|
|
36601
|
+
filters: `CERT:${rawParams.cert} AND YEAR:${year}`,
|
|
36602
|
+
fields: SOD_BRANCH_FIELDS,
|
|
36603
|
+
limit: SOD_FETCH_LIMIT2,
|
|
36604
|
+
sort_by: "DEPSUMBR",
|
|
36605
|
+
sort_order: "DESC"
|
|
36606
|
+
},
|
|
36607
|
+
{ signal: controller.signal }
|
|
36608
|
+
)
|
|
36609
|
+
]);
|
|
36610
|
+
const profileRecords = extractRecords(profileResponse);
|
|
36611
|
+
if (profileRecords.length === 0) {
|
|
36612
|
+
return formatToolError(
|
|
36613
|
+
new Error(`No institution found with CERT number ${rawParams.cert}.`)
|
|
36614
|
+
);
|
|
36615
|
+
}
|
|
36616
|
+
const instName = String(profileRecords[0].NAME ?? "");
|
|
36617
|
+
const branchRecords = extractRecords(sodResponse);
|
|
36618
|
+
if (branchRecords.length === 0) {
|
|
36619
|
+
return formatToolError(
|
|
36620
|
+
new Error(
|
|
36621
|
+
`No SOD branch records found for CERT ${rawParams.cert} in ${year}. The institution may not have reported SOD data for this year.`
|
|
36622
|
+
)
|
|
36623
|
+
);
|
|
36624
|
+
}
|
|
36625
|
+
await sendProgressNotification(
|
|
36626
|
+
server.server,
|
|
36627
|
+
progressToken,
|
|
36628
|
+
0.5,
|
|
36629
|
+
"Grouping branches by market"
|
|
36630
|
+
);
|
|
36631
|
+
const byMarket = /* @__PURE__ */ new Map();
|
|
36632
|
+
for (const rec of branchRecords) {
|
|
36633
|
+
const msaName = typeof rec.MSANAMEBR === "string" && rec.MSANAMEBR.trim() !== "" ? rec.MSANAMEBR.trim() : NON_MSA_LABEL;
|
|
36634
|
+
const deposits = typeof rec.DEPSUMBR === "number" ? rec.DEPSUMBR : 0;
|
|
36635
|
+
const existing = byMarket.get(msaName);
|
|
36636
|
+
if (existing) {
|
|
36637
|
+
existing.branches += 1;
|
|
36638
|
+
existing.deposits += deposits;
|
|
36639
|
+
} else {
|
|
36640
|
+
byMarket.set(msaName, { branches: 1, deposits });
|
|
36641
|
+
}
|
|
36642
|
+
}
|
|
36643
|
+
const totalDeposits = Array.from(byMarket.values()).reduce(
|
|
36644
|
+
(s, m) => s + m.deposits,
|
|
36645
|
+
0
|
|
36646
|
+
);
|
|
36647
|
+
const markets = Array.from(
|
|
36648
|
+
byMarket.entries()
|
|
36649
|
+
).map(([name, data]) => ({
|
|
36650
|
+
market_name: name,
|
|
36651
|
+
branch_count: data.branches,
|
|
36652
|
+
total_deposits: data.deposits,
|
|
36653
|
+
pct_of_total: totalDeposits > 0 ? data.deposits / totalDeposits * 100 : 0
|
|
36654
|
+
}));
|
|
36655
|
+
markets.sort((a, b) => b.total_deposits - a.total_deposits);
|
|
36656
|
+
await sendProgressNotification(
|
|
36657
|
+
server.server,
|
|
36658
|
+
progressToken,
|
|
36659
|
+
0.8,
|
|
36660
|
+
"Formatting results"
|
|
36661
|
+
);
|
|
36662
|
+
const summary = {
|
|
36663
|
+
institution: { cert: rawParams.cert, name: instName, year },
|
|
36664
|
+
summary: {
|
|
36665
|
+
total_branches: branchRecords.length,
|
|
36666
|
+
total_deposits: totalDeposits,
|
|
36667
|
+
market_count: byMarket.size
|
|
36668
|
+
},
|
|
36669
|
+
markets
|
|
36670
|
+
};
|
|
36671
|
+
const text = truncateIfNeeded(
|
|
36672
|
+
formatFranchiseFootprintText(summary),
|
|
36673
|
+
CHARACTER_LIMIT
|
|
36674
|
+
);
|
|
36675
|
+
return {
|
|
36676
|
+
content: [{ type: "text", text }],
|
|
36677
|
+
structuredContent: summary
|
|
36678
|
+
};
|
|
36679
|
+
} catch (err) {
|
|
36680
|
+
return formatToolError(err);
|
|
36681
|
+
} finally {
|
|
36682
|
+
clearTimeout(timeoutId);
|
|
36683
|
+
}
|
|
36684
|
+
}
|
|
36685
|
+
);
|
|
36686
|
+
}
|
|
36687
|
+
|
|
36688
|
+
// src/tools/holdingCompanyProfile.ts
|
|
36689
|
+
var import_zod18 = require("zod");
|
|
36690
|
+
|
|
36691
|
+
// src/tools/shared/holdingCompany.ts
|
|
36692
|
+
var INDEPENDENT_LABEL = "(Independent)";
|
|
36693
|
+
function groupByHoldingCompany(institutions) {
|
|
36694
|
+
const map = /* @__PURE__ */ new Map();
|
|
36695
|
+
for (const inst of institutions) {
|
|
36696
|
+
const key = inst.hc_name ?? INDEPENDENT_LABEL;
|
|
36697
|
+
const group = map.get(key);
|
|
36698
|
+
if (group) {
|
|
36699
|
+
group.push(inst);
|
|
36700
|
+
} else {
|
|
36701
|
+
map.set(key, [inst]);
|
|
36702
|
+
}
|
|
36703
|
+
}
|
|
36704
|
+
const groups = [];
|
|
36705
|
+
for (const [hc_name, subsidiaries] of map) {
|
|
36706
|
+
groups.push({ hc_name, subsidiaries });
|
|
36707
|
+
}
|
|
36708
|
+
groups.sort((a, b) => {
|
|
36709
|
+
const assetsA = a.subsidiaries.reduce((sum, s) => sum + s.total_assets, 0);
|
|
36710
|
+
const assetsB = b.subsidiaries.reduce((sum, s) => sum + s.total_assets, 0);
|
|
36711
|
+
return assetsB - assetsA;
|
|
36712
|
+
});
|
|
36713
|
+
return groups;
|
|
36714
|
+
}
|
|
36715
|
+
function aggregateSubsidiaryMetrics(subs) {
|
|
36716
|
+
let totalAssets = 0;
|
|
36717
|
+
let totalDeposits = 0;
|
|
36718
|
+
const stateSet = /* @__PURE__ */ new Set();
|
|
36719
|
+
let roaWeightedSum = 0;
|
|
36720
|
+
let roaAssetSum = 0;
|
|
36721
|
+
let equityWeightedSum = 0;
|
|
36722
|
+
let equityAssetSum = 0;
|
|
36723
|
+
for (const sub of subs) {
|
|
36724
|
+
totalAssets += sub.total_assets;
|
|
36725
|
+
totalDeposits += sub.total_deposits;
|
|
36726
|
+
if (sub.state) {
|
|
36727
|
+
stateSet.add(sub.state);
|
|
36728
|
+
}
|
|
36729
|
+
if (sub.roa !== null) {
|
|
36730
|
+
roaWeightedSum += sub.roa * sub.total_assets;
|
|
36731
|
+
roaAssetSum += sub.total_assets;
|
|
36732
|
+
}
|
|
36733
|
+
if (sub.equity_ratio !== null) {
|
|
36734
|
+
equityWeightedSum += sub.equity_ratio * sub.total_assets;
|
|
36735
|
+
equityAssetSum += sub.total_assets;
|
|
36736
|
+
}
|
|
36737
|
+
}
|
|
36738
|
+
const states = Array.from(stateSet).sort();
|
|
36739
|
+
return {
|
|
36740
|
+
total_assets: totalAssets,
|
|
36741
|
+
total_deposits: totalDeposits,
|
|
36742
|
+
subsidiary_count: subs.length,
|
|
36743
|
+
states,
|
|
36744
|
+
weighted_roa: roaAssetSum > 0 ? roaWeightedSum / roaAssetSum : null,
|
|
36745
|
+
weighted_equity_ratio: equityAssetSum > 0 ? equityWeightedSum / equityAssetSum : null
|
|
36746
|
+
};
|
|
36747
|
+
}
|
|
36748
|
+
function buildSubsidiaryRecord(inst, financials) {
|
|
36749
|
+
return {
|
|
36750
|
+
cert: typeof inst.CERT === "number" ? inst.CERT : 0,
|
|
36751
|
+
name: String(inst.NAME ?? ""),
|
|
36752
|
+
hc_name: inst.NAMHCR ? String(inst.NAMHCR) : null,
|
|
36753
|
+
total_assets: typeof inst.ASSET === "number" ? inst.ASSET : 0,
|
|
36754
|
+
total_deposits: typeof inst.DEP === "number" ? inst.DEP : 0,
|
|
36755
|
+
roa: financials ? asNumber(financials.ROA) : null,
|
|
36756
|
+
equity_ratio: financials ? asNumber(financials.EQV) : null,
|
|
36757
|
+
state: String(inst.STALP ?? ""),
|
|
36758
|
+
active: inst.ACTIVE === 1 || inst.ACTIVE === true
|
|
36759
|
+
};
|
|
36760
|
+
}
|
|
36761
|
+
|
|
36762
|
+
// src/tools/holdingCompanyProfile.ts
|
|
36763
|
+
var INSTITUTION_FIELDS = "CERT,NAME,STALP,CITY,ASSET,DEP,NAMHCR,HCTMULT,ACTIVE,SPECGRP,CHRTAGNT";
|
|
36764
|
+
var FINANCIAL_FIELDS2 = "CERT,ROA,EQV";
|
|
36765
|
+
function fmtPct5(val) {
|
|
36766
|
+
return val !== null ? `${val.toFixed(2)}%` : "n/a";
|
|
36767
|
+
}
|
|
36768
|
+
function fmtDollarsK5(val) {
|
|
36769
|
+
return `$${Math.round(val).toLocaleString()}K`;
|
|
36770
|
+
}
|
|
36771
|
+
function formatHoldingCompanyProfileText(result) {
|
|
36772
|
+
const parts = [];
|
|
36773
|
+
const { holding_company: hc, aggregate: agg, subsidiaries: subs } = result;
|
|
36774
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36775
|
+
parts.push(` Holding Company Profile: ${hc.name}`);
|
|
36776
|
+
parts.push(` Subsidiaries: ${hc.subsidiary_count} | States: ${hc.states.join(", ")}`);
|
|
36777
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
36778
|
+
parts.push("");
|
|
36779
|
+
parts.push("Consolidated Summary");
|
|
36780
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36781
|
+
parts.push(` Total Assets: ${fmtDollarsK5(agg.total_assets)}`);
|
|
36782
|
+
parts.push(` Total Deposits: ${fmtDollarsK5(agg.total_deposits)}`);
|
|
36783
|
+
parts.push(` Weighted ROA: ${fmtPct5(agg.weighted_roa)}`);
|
|
36784
|
+
parts.push(` Weighted Equity Ratio: ${fmtPct5(agg.weighted_equity_ratio)}`);
|
|
36785
|
+
parts.push("");
|
|
36786
|
+
parts.push("Subsidiaries");
|
|
36787
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
36788
|
+
parts.push(
|
|
36789
|
+
" CERT Name State Assets ($K) ROA Equity"
|
|
36790
|
+
);
|
|
36791
|
+
parts.push(
|
|
36792
|
+
" \u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500"
|
|
36793
|
+
);
|
|
36794
|
+
const sorted = [...subs].sort((a, b) => b.total_assets - a.total_assets);
|
|
36795
|
+
for (const sub of sorted) {
|
|
36796
|
+
const cert = String(sub.cert).padEnd(5);
|
|
36797
|
+
const name = sub.name.slice(0, 29).padEnd(29);
|
|
36798
|
+
const state = sub.state.padEnd(5);
|
|
36799
|
+
const assets = Math.round(sub.total_assets).toLocaleString().padStart(11);
|
|
36800
|
+
const roa = sub.roa !== null ? `${sub.roa.toFixed(2)}%`.padStart(6) : " n/a ";
|
|
36801
|
+
const equity = sub.equity_ratio !== null ? `${sub.equity_ratio.toFixed(1)}%`.padStart(6) : " n/a ";
|
|
36802
|
+
parts.push(` ${cert} ${name} ${state} ${assets} ${roa} ${equity}`);
|
|
36803
|
+
}
|
|
36804
|
+
return parts.join("\n");
|
|
36805
|
+
}
|
|
36806
|
+
var HoldingCompanyProfileSchema = import_zod18.z.object({
|
|
36807
|
+
hc_name: import_zod18.z.string().optional().describe(
|
|
36808
|
+
'Holding company name (e.g., "JPMORGAN CHASE & CO"). Uses NAMHCR field.'
|
|
36809
|
+
),
|
|
36810
|
+
cert: import_zod18.z.number().int().positive().optional().describe(
|
|
36811
|
+
"CERT of any subsidiary \u2014 looks up its holding company, then profiles the entire HC."
|
|
36812
|
+
)
|
|
36813
|
+
});
|
|
36814
|
+
function registerHoldingCompanyProfileTools(server) {
|
|
36815
|
+
server.registerTool(
|
|
36816
|
+
"fdic_holding_company_profile",
|
|
36817
|
+
{
|
|
36818
|
+
title: "Holding Company Profile",
|
|
36819
|
+
description: `Profile a bank holding company by grouping its FDIC-insured subsidiaries and aggregating financial metrics. Look up by holding company name or by any subsidiary's CERT number.
|
|
36820
|
+
|
|
36821
|
+
Output includes:
|
|
36822
|
+
- Consolidated summary with total assets, deposits, and asset-weighted ROA/equity ratio
|
|
36823
|
+
- List of all FDIC-insured subsidiaries with individual metrics
|
|
36824
|
+
- Structured JSON for programmatic consumption
|
|
36825
|
+
|
|
36826
|
+
NOTE: This is an analytical tool based on public financial data.`,
|
|
36827
|
+
inputSchema: HoldingCompanyProfileSchema,
|
|
36828
|
+
annotations: {
|
|
36829
|
+
readOnlyHint: true,
|
|
36830
|
+
destructiveHint: false,
|
|
36831
|
+
idempotentHint: true,
|
|
36832
|
+
openWorldHint: true
|
|
36833
|
+
}
|
|
36834
|
+
},
|
|
36835
|
+
async (rawParams, extra) => {
|
|
36836
|
+
const controller = new AbortController();
|
|
36837
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
36838
|
+
const progressToken = extra._meta?.progressToken;
|
|
36839
|
+
try {
|
|
36840
|
+
if (!rawParams.hc_name && !rawParams.cert) {
|
|
36841
|
+
return formatToolError(
|
|
36842
|
+
new Error(
|
|
36843
|
+
"At least one of hc_name or cert is required. Provide a holding company name or a subsidiary CERT number."
|
|
36844
|
+
)
|
|
36845
|
+
);
|
|
36846
|
+
}
|
|
36847
|
+
let hcName;
|
|
36848
|
+
if (rawParams.cert && !rawParams.hc_name) {
|
|
36849
|
+
await sendProgressNotification(
|
|
36850
|
+
server.server,
|
|
36851
|
+
progressToken,
|
|
36852
|
+
0.1,
|
|
36853
|
+
"Looking up subsidiary to find holding company"
|
|
36854
|
+
);
|
|
36855
|
+
const certResponse = await queryEndpoint(
|
|
36856
|
+
ENDPOINTS.INSTITUTIONS,
|
|
36857
|
+
{
|
|
36858
|
+
filters: `CERT:${rawParams.cert}`,
|
|
36859
|
+
fields: "CERT,NAMHCR",
|
|
36860
|
+
limit: 1
|
|
36861
|
+
},
|
|
36862
|
+
{ signal: controller.signal }
|
|
36863
|
+
);
|
|
36864
|
+
const certRecords = extractRecords(certResponse);
|
|
36865
|
+
if (certRecords.length === 0) {
|
|
36866
|
+
return formatToolError(
|
|
36867
|
+
new Error(`No institution found with CERT number ${rawParams.cert}.`)
|
|
36868
|
+
);
|
|
36869
|
+
}
|
|
36870
|
+
const namhcr = certRecords[0].NAMHCR;
|
|
36871
|
+
if (!namhcr || String(namhcr).trim() === "") {
|
|
36872
|
+
return formatToolError(
|
|
36873
|
+
new Error(
|
|
36874
|
+
`Institution with CERT ${rawParams.cert} is not part of a holding company (NAMHCR is empty).`
|
|
36875
|
+
)
|
|
36876
|
+
);
|
|
36877
|
+
}
|
|
36878
|
+
hcName = String(namhcr);
|
|
36879
|
+
} else {
|
|
36880
|
+
hcName = rawParams.hc_name;
|
|
36881
|
+
}
|
|
36882
|
+
await sendProgressNotification(
|
|
36883
|
+
server.server,
|
|
36884
|
+
progressToken,
|
|
36885
|
+
0.2,
|
|
36886
|
+
`Fetching subsidiaries for ${hcName}`
|
|
36887
|
+
);
|
|
36888
|
+
const instResponse = await queryEndpoint(
|
|
36889
|
+
ENDPOINTS.INSTITUTIONS,
|
|
36890
|
+
{
|
|
36891
|
+
filters: `NAMHCR:"${hcName}"`,
|
|
36892
|
+
fields: INSTITUTION_FIELDS,
|
|
36893
|
+
limit: 500,
|
|
36894
|
+
sort_by: "ASSET",
|
|
36895
|
+
sort_order: "DESC"
|
|
36896
|
+
},
|
|
36897
|
+
{ signal: controller.signal }
|
|
36898
|
+
);
|
|
36899
|
+
const instRecords = extractRecords(instResponse);
|
|
36900
|
+
if (instRecords.length === 0) {
|
|
36901
|
+
return formatToolError(
|
|
36902
|
+
new Error(
|
|
36903
|
+
`No institutions found for holding company "${hcName}". Check the name spelling (use NAMHCR value from FDIC data).`
|
|
36904
|
+
)
|
|
36905
|
+
);
|
|
36906
|
+
}
|
|
36907
|
+
await sendProgressNotification(
|
|
36908
|
+
server.server,
|
|
36909
|
+
progressToken,
|
|
36910
|
+
0.4,
|
|
36911
|
+
"Fetching financial data for subsidiaries"
|
|
36912
|
+
);
|
|
36913
|
+
const activeCerts = instRecords.filter((r) => r.ACTIVE === 1 || r.ACTIVE === true).map((r) => r.CERT);
|
|
36914
|
+
const repdte = getDefaultReportDate();
|
|
36915
|
+
const financialsMap = /* @__PURE__ */ new Map();
|
|
36916
|
+
if (activeCerts.length > 0) {
|
|
36917
|
+
const certFilterChunks = buildCertFilters(activeCerts);
|
|
36918
|
+
const chunkResults = await mapWithConcurrency(
|
|
36919
|
+
certFilterChunks,
|
|
36920
|
+
MAX_CONCURRENCY,
|
|
36921
|
+
async (certFilter) => {
|
|
36922
|
+
const response = await queryEndpoint(
|
|
36923
|
+
ENDPOINTS.FINANCIALS,
|
|
36924
|
+
{
|
|
36925
|
+
filters: `(${certFilter}) AND REPDTE:${repdte}`,
|
|
36926
|
+
fields: FINANCIAL_FIELDS2,
|
|
36927
|
+
limit: activeCerts.length
|
|
36928
|
+
},
|
|
36929
|
+
{ signal: controller.signal }
|
|
36930
|
+
);
|
|
36931
|
+
return extractRecords(response);
|
|
36932
|
+
}
|
|
36933
|
+
);
|
|
36934
|
+
for (const records of chunkResults) {
|
|
36935
|
+
for (const rec of records) {
|
|
36936
|
+
if (typeof rec.CERT === "number") {
|
|
36937
|
+
financialsMap.set(rec.CERT, rec);
|
|
36938
|
+
}
|
|
36939
|
+
}
|
|
36940
|
+
}
|
|
36941
|
+
}
|
|
36942
|
+
await sendProgressNotification(
|
|
36943
|
+
server.server,
|
|
36944
|
+
progressToken,
|
|
36945
|
+
0.7,
|
|
36946
|
+
"Aggregating metrics"
|
|
36947
|
+
);
|
|
36948
|
+
const subsidiaries = instRecords.map((inst) => {
|
|
36949
|
+
const cert = typeof inst.CERT === "number" ? inst.CERT : 0;
|
|
36950
|
+
return buildSubsidiaryRecord(inst, financialsMap.get(cert));
|
|
36951
|
+
});
|
|
36952
|
+
const groups = groupByHoldingCompany(subsidiaries);
|
|
36953
|
+
const targetGroup = groups.find((g) => g.hc_name === hcName);
|
|
36954
|
+
const targetSubs = targetGroup ? targetGroup.subsidiaries : subsidiaries;
|
|
36955
|
+
const aggregate = aggregateSubsidiaryMetrics(targetSubs);
|
|
36956
|
+
await sendProgressNotification(
|
|
36957
|
+
server.server,
|
|
36958
|
+
progressToken,
|
|
36959
|
+
0.9,
|
|
36960
|
+
"Formatting results"
|
|
36961
|
+
);
|
|
36962
|
+
const profileResult = {
|
|
36963
|
+
holding_company: {
|
|
36964
|
+
name: hcName,
|
|
36965
|
+
subsidiary_count: aggregate.subsidiary_count,
|
|
36966
|
+
states: aggregate.states
|
|
36967
|
+
},
|
|
36968
|
+
aggregate,
|
|
36969
|
+
subsidiaries: targetSubs.map((s) => ({
|
|
36970
|
+
cert: s.cert,
|
|
36971
|
+
name: s.name,
|
|
36972
|
+
state: s.state,
|
|
36973
|
+
total_assets: s.total_assets,
|
|
36974
|
+
total_deposits: s.total_deposits,
|
|
36975
|
+
roa: s.roa,
|
|
36976
|
+
equity_ratio: s.equity_ratio,
|
|
36977
|
+
active: s.active
|
|
36978
|
+
}))
|
|
36979
|
+
};
|
|
36980
|
+
const text = truncateIfNeeded(
|
|
36981
|
+
formatHoldingCompanyProfileText(profileResult),
|
|
36982
|
+
CHARACTER_LIMIT
|
|
36983
|
+
);
|
|
36984
|
+
return {
|
|
36985
|
+
content: [{ type: "text", text }],
|
|
36986
|
+
structuredContent: profileResult
|
|
36987
|
+
};
|
|
36988
|
+
} catch (err) {
|
|
36989
|
+
return formatToolError(err);
|
|
36990
|
+
} finally {
|
|
36991
|
+
clearTimeout(timeoutId);
|
|
36992
|
+
}
|
|
36993
|
+
}
|
|
36994
|
+
);
|
|
36995
|
+
}
|
|
36996
|
+
|
|
36997
|
+
// src/tools/regionalContext.ts
|
|
36998
|
+
var import_zod19 = require("zod");
|
|
36999
|
+
|
|
37000
|
+
// src/services/fredClient.ts
|
|
37001
|
+
var import_axios2 = __toESM(require("axios"));
|
|
37002
|
+
var FRED_BASE_URL = "https://api.stlouisfed.org/fred";
|
|
37003
|
+
function buildFredUrl(seriesId, opts) {
|
|
37004
|
+
const params = new URLSearchParams({
|
|
37005
|
+
series_id: seriesId,
|
|
37006
|
+
observation_start: opts.start,
|
|
37007
|
+
observation_end: opts.end,
|
|
37008
|
+
file_type: "json"
|
|
37009
|
+
});
|
|
37010
|
+
if (opts.apiKey) {
|
|
37011
|
+
params.set("api_key", opts.apiKey);
|
|
37012
|
+
}
|
|
37013
|
+
return `${FRED_BASE_URL}/series/observations?${params.toString()}`;
|
|
37014
|
+
}
|
|
37015
|
+
function parseFredResponse(raw) {
|
|
37016
|
+
if (typeof raw !== "object" || raw === null || !("observations" in raw) || !Array.isArray(raw.observations)) {
|
|
37017
|
+
return [];
|
|
37018
|
+
}
|
|
37019
|
+
const observations = raw.observations;
|
|
37020
|
+
const results = [];
|
|
37021
|
+
for (const obs of observations) {
|
|
37022
|
+
if (typeof obs !== "object" || obs === null) continue;
|
|
37023
|
+
const entry = obs;
|
|
37024
|
+
const date = entry.date;
|
|
37025
|
+
const rawValue = entry.value;
|
|
37026
|
+
if (typeof date !== "string" || typeof rawValue !== "string") continue;
|
|
37027
|
+
if (rawValue === ".") continue;
|
|
37028
|
+
const numValue = Number.parseFloat(rawValue);
|
|
37029
|
+
if (Number.isNaN(numValue)) continue;
|
|
37030
|
+
results.push({ date, value: numValue });
|
|
37031
|
+
}
|
|
37032
|
+
return results;
|
|
37033
|
+
}
|
|
37034
|
+
async function fetchFredSeries(seriesId, start, end) {
|
|
37035
|
+
const apiKey = process.env.FRED_API_KEY;
|
|
37036
|
+
const url = buildFredUrl(seriesId, { start, end, apiKey });
|
|
37037
|
+
const response = await import_axios2.default.get(url, {
|
|
37038
|
+
timeout: 15e3,
|
|
37039
|
+
headers: {
|
|
37040
|
+
Accept: "application/json",
|
|
37041
|
+
"User-Agent": `fdic-mcp-server/${VERSION}`
|
|
37042
|
+
}
|
|
37043
|
+
});
|
|
37044
|
+
return parseFredResponse(response.data);
|
|
37045
|
+
}
|
|
37046
|
+
function stateFredSeries(state) {
|
|
37047
|
+
const s = state.toUpperCase();
|
|
37048
|
+
return {
|
|
37049
|
+
unemployment: `${s}UR`,
|
|
37050
|
+
gdp: `${s}NGSP`
|
|
37051
|
+
};
|
|
37052
|
+
}
|
|
37053
|
+
|
|
37054
|
+
// src/tools/shared/regionalContext.ts
|
|
37055
|
+
function lastN(arr, n) {
|
|
37056
|
+
return arr.slice(Math.max(0, arr.length - n));
|
|
37057
|
+
}
|
|
37058
|
+
function latestValue(obs) {
|
|
37059
|
+
if (obs.length === 0) return null;
|
|
37060
|
+
return obs[obs.length - 1].value;
|
|
37061
|
+
}
|
|
37062
|
+
function assessMacroContext(inputs) {
|
|
37063
|
+
const stateUnemp = inputs.state_unemployment;
|
|
37064
|
+
const natUnemp = inputs.national_unemployment;
|
|
37065
|
+
const fedFunds = inputs.fed_funds;
|
|
37066
|
+
const latestStateUnemp = latestValue(stateUnemp);
|
|
37067
|
+
const latestNatUnemp = latestValue(natUnemp);
|
|
37068
|
+
const latestFedFunds = latestValue(fedFunds);
|
|
37069
|
+
let unemployment_trend = "stable";
|
|
37070
|
+
const recentState = lastN(stateUnemp, 3);
|
|
37071
|
+
if (recentState.length >= 2) {
|
|
37072
|
+
const earliest = recentState[0].value;
|
|
37073
|
+
const latest = recentState[recentState.length - 1].value;
|
|
37074
|
+
const diff = latest - earliest;
|
|
37075
|
+
if (diff > 0.3) unemployment_trend = "rising";
|
|
37076
|
+
else if (diff < -0.3) unemployment_trend = "falling";
|
|
37077
|
+
}
|
|
37078
|
+
let state_vs_national_unemployment = "at_parity";
|
|
37079
|
+
if (latestStateUnemp !== null && latestNatUnemp !== null) {
|
|
37080
|
+
const gap = latestStateUnemp - latestNatUnemp;
|
|
37081
|
+
if (gap > 0.3) state_vs_national_unemployment = "above";
|
|
37082
|
+
else if (gap < -0.3) state_vs_national_unemployment = "below";
|
|
37083
|
+
}
|
|
37084
|
+
let rate_environment = "moderate";
|
|
37085
|
+
if (latestFedFunds !== null) {
|
|
37086
|
+
if (latestFedFunds < 2) rate_environment = "low";
|
|
37087
|
+
else if (latestFedFunds > 4) rate_environment = "elevated";
|
|
37088
|
+
}
|
|
37089
|
+
const narrative = buildNarrative({
|
|
37090
|
+
latestStateUnemp,
|
|
37091
|
+
latestNatUnemp,
|
|
37092
|
+
latestFedFunds,
|
|
37093
|
+
unemployment_trend,
|
|
37094
|
+
state_vs_national_unemployment,
|
|
37095
|
+
rate_environment
|
|
37096
|
+
});
|
|
37097
|
+
return {
|
|
37098
|
+
unemployment_trend,
|
|
37099
|
+
state_vs_national_unemployment,
|
|
37100
|
+
rate_environment,
|
|
37101
|
+
latest_state_unemployment: latestStateUnemp,
|
|
37102
|
+
latest_national_unemployment: latestNatUnemp,
|
|
37103
|
+
latest_fed_funds: latestFedFunds,
|
|
37104
|
+
narrative
|
|
37105
|
+
};
|
|
37106
|
+
}
|
|
37107
|
+
function buildNarrative(params) {
|
|
37108
|
+
const {
|
|
37109
|
+
latestStateUnemp,
|
|
37110
|
+
latestNatUnemp,
|
|
37111
|
+
latestFedFunds,
|
|
37112
|
+
unemployment_trend,
|
|
37113
|
+
state_vs_national_unemployment,
|
|
37114
|
+
rate_environment
|
|
37115
|
+
} = params;
|
|
37116
|
+
if (latestStateUnemp === null && latestNatUnemp === null && latestFedFunds === null) {
|
|
37117
|
+
return "Economic data is unavailable. Consider manually reviewing state unemployment trends, federal funds rate environment, and regional housing and employment conditions.";
|
|
37118
|
+
}
|
|
37119
|
+
const parts = [];
|
|
37120
|
+
if (latestStateUnemp !== null) {
|
|
37121
|
+
let sentence = `The state unemployment rate is ${latestStateUnemp.toFixed(1)}%`;
|
|
37122
|
+
if (latestNatUnemp !== null) {
|
|
37123
|
+
const compWord = state_vs_national_unemployment === "above" ? "above" : state_vs_national_unemployment === "below" ? "below" : "in line with";
|
|
37124
|
+
sentence += `, ${compWord} the national rate of ${latestNatUnemp.toFixed(1)}%`;
|
|
37125
|
+
}
|
|
37126
|
+
const trendWord = unemployment_trend === "rising" ? " and rising over the past three quarters" : unemployment_trend === "falling" ? " and falling over the past three quarters" : " and stable over the past three quarters";
|
|
37127
|
+
sentence += `${trendWord}.`;
|
|
37128
|
+
parts.push(sentence);
|
|
37129
|
+
}
|
|
37130
|
+
if (latestFedFunds !== null) {
|
|
37131
|
+
const envWord = rate_environment === "low" ? "a low" : rate_environment === "elevated" ? "an elevated" : "a moderate";
|
|
37132
|
+
let impact = rate_environment === "elevated" ? "which may pressure bank net interest margins and borrower repayment capacity" : rate_environment === "low" ? "which supports borrower affordability but may compress bank net interest margins" : "which provides a balanced environment for bank lending and deposit pricing";
|
|
37133
|
+
parts.push(
|
|
37134
|
+
`The federal funds rate at ${latestFedFunds.toFixed(2)}% indicates ${envWord} rate environment, ${impact}.`
|
|
37135
|
+
);
|
|
37136
|
+
}
|
|
37137
|
+
return parts.join(" ");
|
|
37138
|
+
}
|
|
37139
|
+
|
|
37140
|
+
// src/tools/regionalContext.ts
|
|
37141
|
+
function fdicToFredDate(yyyymmdd) {
|
|
37142
|
+
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
|
37143
|
+
}
|
|
37144
|
+
function twoYearsBefore(yyyymmdd) {
|
|
37145
|
+
const year = Number.parseInt(yyyymmdd.slice(0, 4), 10);
|
|
37146
|
+
return `${year - 2}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
|
37147
|
+
}
|
|
37148
|
+
function fmtVal(val, suffix = "%") {
|
|
37149
|
+
return val !== null ? `${val.toFixed(2)}${suffix}` : "n/a";
|
|
37150
|
+
}
|
|
37151
|
+
function trendLabel(trend) {
|
|
37152
|
+
return trend === "rising" ? "rising" : trend === "falling" ? "falling" : "stable";
|
|
37153
|
+
}
|
|
37154
|
+
function comparisonLabel(comp) {
|
|
37155
|
+
return comp === "above" ? "above national" : comp === "below" ? "below national" : "at parity with national";
|
|
37156
|
+
}
|
|
37157
|
+
function envLabel(env) {
|
|
37158
|
+
return env;
|
|
37159
|
+
}
|
|
37160
|
+
function formatRegionalContextText(summary) {
|
|
37161
|
+
const parts = [];
|
|
37162
|
+
if (!summary.fred_available) {
|
|
37163
|
+
parts.push(`Regional Economic Context: ${summary.state}`);
|
|
37164
|
+
parts.push("");
|
|
37165
|
+
parts.push(
|
|
37166
|
+
"\u26A0 FRED API data unavailable. Set FRED_API_KEY environment variable"
|
|
37167
|
+
);
|
|
37168
|
+
parts.push("for reliable access to economic indicators.");
|
|
37169
|
+
parts.push("");
|
|
37170
|
+
parts.push("Without economic context, consider manually reviewing:");
|
|
37171
|
+
parts.push(" \u2022 State unemployment trends");
|
|
37172
|
+
parts.push(" \u2022 Federal funds rate environment");
|
|
37173
|
+
parts.push(" \u2022 Regional housing and employment conditions");
|
|
37174
|
+
return parts.join("\n");
|
|
37175
|
+
}
|
|
37176
|
+
const ctx = summary.context;
|
|
37177
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
37178
|
+
parts.push(` Regional Economic Context: ${summary.state}`);
|
|
37179
|
+
if (summary.institution) {
|
|
37180
|
+
parts.push(` ${summary.institution.name}`);
|
|
37181
|
+
}
|
|
37182
|
+
parts.push(` Data Period: ${summary.date_range.start} to ${summary.date_range.end}`);
|
|
37183
|
+
parts.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
37184
|
+
parts.push("");
|
|
37185
|
+
parts.push("Economic Indicators");
|
|
37186
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
37187
|
+
parts.push(` State Unemployment: ${fmtVal(ctx.latest_state_unemployment)} (${trendLabel(ctx.unemployment_trend)})`);
|
|
37188
|
+
parts.push(` National Unemployment: ${fmtVal(ctx.latest_national_unemployment)} (state is ${comparisonLabel(ctx.state_vs_national_unemployment)})`);
|
|
37189
|
+
parts.push(` Federal Funds Rate: ${fmtVal(ctx.latest_fed_funds)} (${envLabel(ctx.rate_environment)})`);
|
|
37190
|
+
parts.push("");
|
|
37191
|
+
parts.push("Assessment");
|
|
37192
|
+
parts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
37193
|
+
parts.push(ctx.narrative);
|
|
37194
|
+
parts.push("");
|
|
37195
|
+
parts.push("Note: Economic data from FRED (Federal Reserve Economic Data).");
|
|
37196
|
+
return parts.join("\n");
|
|
37197
|
+
}
|
|
37198
|
+
var RegionalContextSchema = import_zod19.z.object({
|
|
37199
|
+
cert: import_zod19.z.number().int().positive().optional().describe(
|
|
37200
|
+
"FDIC Certificate Number \u2014 auto-detects state from institution record."
|
|
37201
|
+
),
|
|
37202
|
+
state: import_zod19.z.string().length(2).optional().describe(
|
|
37203
|
+
"Two-letter state abbreviation (e.g., TX). Alternative to cert-based lookup."
|
|
37204
|
+
),
|
|
37205
|
+
repdte: import_zod19.z.string().regex(/^\d{8}$/).optional().describe(
|
|
37206
|
+
"Reference report date (YYYYMMDD). FRED data fetched for 2 years before this date."
|
|
37207
|
+
)
|
|
37208
|
+
});
|
|
37209
|
+
function registerRegionalContextTools(server) {
|
|
37210
|
+
server.registerTool(
|
|
37211
|
+
"fdic_regional_context",
|
|
37212
|
+
{
|
|
37213
|
+
title: "Regional Economic Context",
|
|
37214
|
+
description: `Overlay macro/regional economic data on a bank's geographic context. Uses FRED (Federal Reserve Economic Data) for state unemployment, national unemployment, and federal funds rate. Provides trend analysis and narrative context for bank performance assessment. Gracefully degrades if FRED API is unavailable.
|
|
37215
|
+
|
|
37216
|
+
Output includes:
|
|
37217
|
+
- State and national unemployment rates with trend analysis
|
|
37218
|
+
- Federal funds rate and rate environment classification
|
|
37219
|
+
- Narrative assessment of macro conditions for bank performance
|
|
37220
|
+
- Structured JSON for programmatic consumption
|
|
37221
|
+
|
|
37222
|
+
NOTE: Requires FRED_API_KEY environment variable for reliable data access. Degrades gracefully without it.`,
|
|
37223
|
+
inputSchema: RegionalContextSchema,
|
|
37224
|
+
annotations: {
|
|
37225
|
+
readOnlyHint: true,
|
|
37226
|
+
destructiveHint: false,
|
|
37227
|
+
idempotentHint: true,
|
|
37228
|
+
openWorldHint: true
|
|
37229
|
+
}
|
|
37230
|
+
},
|
|
37231
|
+
async (rawParams, extra) => {
|
|
37232
|
+
const controller = new AbortController();
|
|
37233
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
37234
|
+
const progressToken = extra._meta?.progressToken;
|
|
37235
|
+
try {
|
|
37236
|
+
if (!rawParams.cert && !rawParams.state) {
|
|
37237
|
+
return formatToolError(
|
|
37238
|
+
new Error(
|
|
37239
|
+
"At least one of 'cert' or 'state' is required. Provide a CERT number to auto-detect the state, or a two-letter state abbreviation directly."
|
|
37240
|
+
)
|
|
37241
|
+
);
|
|
37242
|
+
}
|
|
37243
|
+
const repdte = rawParams.repdte ?? getDefaultReportDate();
|
|
37244
|
+
await sendProgressNotification(
|
|
37245
|
+
server.server,
|
|
37246
|
+
progressToken,
|
|
37247
|
+
0.1,
|
|
37248
|
+
"Resolving state"
|
|
37249
|
+
);
|
|
37250
|
+
let state;
|
|
37251
|
+
let institution;
|
|
37252
|
+
if (rawParams.cert) {
|
|
37253
|
+
const profileResponse = await queryEndpoint(
|
|
37254
|
+
ENDPOINTS.INSTITUTIONS,
|
|
37255
|
+
{
|
|
37256
|
+
filters: `CERT:${rawParams.cert}`,
|
|
37257
|
+
fields: "CERT,NAME,STALP",
|
|
37258
|
+
limit: 1
|
|
37259
|
+
},
|
|
37260
|
+
{ signal: controller.signal }
|
|
37261
|
+
);
|
|
37262
|
+
const records = extractRecords(profileResponse);
|
|
37263
|
+
if (records.length === 0) {
|
|
37264
|
+
return formatToolError(
|
|
37265
|
+
new Error(
|
|
37266
|
+
`No institution found with CERT number ${rawParams.cert}.`
|
|
37267
|
+
)
|
|
37268
|
+
);
|
|
37269
|
+
}
|
|
37270
|
+
const profile = records[0];
|
|
37271
|
+
state = String(profile.STALP ?? "").toUpperCase();
|
|
37272
|
+
institution = {
|
|
37273
|
+
cert: rawParams.cert,
|
|
37274
|
+
name: String(profile.NAME ?? "")
|
|
37275
|
+
};
|
|
37276
|
+
if (!state || state.length !== 2) {
|
|
37277
|
+
return formatToolError(
|
|
37278
|
+
new Error(
|
|
37279
|
+
`Could not determine state for CERT ${rawParams.cert}. Try providing the 'state' parameter directly.`
|
|
37280
|
+
)
|
|
37281
|
+
);
|
|
37282
|
+
}
|
|
37283
|
+
} else {
|
|
37284
|
+
state = rawParams.state.toUpperCase();
|
|
37285
|
+
}
|
|
37286
|
+
const endDate = fdicToFredDate(repdte);
|
|
37287
|
+
const startDate = twoYearsBefore(repdte);
|
|
37288
|
+
await sendProgressNotification(
|
|
37289
|
+
server.server,
|
|
37290
|
+
progressToken,
|
|
37291
|
+
0.3,
|
|
37292
|
+
"Fetching FRED economic data"
|
|
37293
|
+
);
|
|
37294
|
+
const series = stateFredSeries(state);
|
|
37295
|
+
const [stateUnempResult, natUnempResult, fedFundsResult] = await Promise.allSettled([
|
|
37296
|
+
fetchFredSeries(series.unemployment, startDate, endDate),
|
|
37297
|
+
fetchFredSeries("UNRATE", startDate, endDate),
|
|
37298
|
+
fetchFredSeries("FEDFUNDS", startDate, endDate)
|
|
37299
|
+
]);
|
|
37300
|
+
const stateUnemp = stateUnempResult.status === "fulfilled" ? stateUnempResult.value : [];
|
|
37301
|
+
const natUnemp = natUnempResult.status === "fulfilled" ? natUnempResult.value : [];
|
|
37302
|
+
const fedFunds = fedFundsResult.status === "fulfilled" ? fedFundsResult.value : [];
|
|
37303
|
+
const allFailed = stateUnempResult.status === "rejected" && natUnempResult.status === "rejected" && fedFundsResult.status === "rejected";
|
|
37304
|
+
if (allFailed) {
|
|
37305
|
+
const summary2 = {
|
|
37306
|
+
state,
|
|
37307
|
+
institution,
|
|
37308
|
+
date_range: { start: startDate, end: endDate },
|
|
37309
|
+
context: {
|
|
37310
|
+
unemployment_trend: "stable",
|
|
37311
|
+
state_vs_national_unemployment: "at_parity",
|
|
37312
|
+
rate_environment: "moderate",
|
|
37313
|
+
latest_state_unemployment: null,
|
|
37314
|
+
latest_national_unemployment: null,
|
|
37315
|
+
latest_fed_funds: null,
|
|
37316
|
+
narrative: "Economic data is unavailable. Consider manually reviewing state unemployment trends, federal funds rate environment, and regional housing and employment conditions."
|
|
37317
|
+
},
|
|
37318
|
+
fred_available: false
|
|
37319
|
+
};
|
|
37320
|
+
const text2 = formatRegionalContextText(summary2);
|
|
37321
|
+
return {
|
|
37322
|
+
content: [{ type: "text", text: text2 }],
|
|
37323
|
+
structuredContent: summary2
|
|
37324
|
+
};
|
|
37325
|
+
}
|
|
37326
|
+
await sendProgressNotification(
|
|
37327
|
+
server.server,
|
|
37328
|
+
progressToken,
|
|
37329
|
+
0.7,
|
|
37330
|
+
"Computing macro context"
|
|
37331
|
+
);
|
|
37332
|
+
const context = assessMacroContext({
|
|
37333
|
+
state_unemployment: stateUnemp,
|
|
37334
|
+
national_unemployment: natUnemp,
|
|
37335
|
+
fed_funds: fedFunds
|
|
37336
|
+
});
|
|
37337
|
+
const summary = {
|
|
37338
|
+
state,
|
|
37339
|
+
institution,
|
|
37340
|
+
date_range: { start: startDate, end: endDate },
|
|
37341
|
+
context,
|
|
37342
|
+
fred_available: true
|
|
37343
|
+
};
|
|
37344
|
+
const text = truncateIfNeeded(
|
|
37345
|
+
formatRegionalContextText(summary),
|
|
37346
|
+
CHARACTER_LIMIT
|
|
37347
|
+
);
|
|
37348
|
+
return {
|
|
37349
|
+
content: [{ type: "text", text }],
|
|
37350
|
+
structuredContent: summary
|
|
37351
|
+
};
|
|
37352
|
+
} catch (err) {
|
|
37353
|
+
return formatToolError(err);
|
|
37354
|
+
} finally {
|
|
37355
|
+
clearTimeout(timeoutId);
|
|
37356
|
+
}
|
|
37357
|
+
}
|
|
37358
|
+
);
|
|
37359
|
+
}
|
|
37360
|
+
|
|
35401
37361
|
// src/resources/schemaResources.ts
|
|
35402
37362
|
var RESOURCE_SCHEME = "fdic";
|
|
35403
37363
|
var INDEX_URI = `${RESOURCE_SCHEME}://schemas/index`;
|
|
@@ -35486,6 +37446,14 @@ function createServer() {
|
|
|
35486
37446
|
registerBankHealthTools(server);
|
|
35487
37447
|
registerPeerHealthTools(server);
|
|
35488
37448
|
registerRiskSignalTools(server);
|
|
37449
|
+
registerCreditConcentrationTools(server);
|
|
37450
|
+
registerFundingProfileTools(server);
|
|
37451
|
+
registerSecuritiesPortfolioTools(server);
|
|
37452
|
+
registerUbprAnalysisTools(server);
|
|
37453
|
+
registerMarketShareAnalysisTools(server);
|
|
37454
|
+
registerFranchiseFootprintTools(server);
|
|
37455
|
+
registerHoldingCompanyProfileTools(server);
|
|
37456
|
+
registerRegionalContextTools(server);
|
|
35489
37457
|
registerSchemaResources(server);
|
|
35490
37458
|
return server;
|
|
35491
37459
|
}
|