fdic-mcp-server 1.11.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 +3019 -1
- package/dist/server.js +3019 -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;
|
|
@@ -34351,6 +34351,3013 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
34351
34351
|
);
|
|
34352
34352
|
}
|
|
34353
34353
|
|
|
34354
|
+
// src/tools/bankHealth.ts
|
|
34355
|
+
var import_zod9 = require("zod");
|
|
34356
|
+
|
|
34357
|
+
// src/tools/shared/camelsScoring.ts
|
|
34358
|
+
var SCORING_RULES = {
|
|
34359
|
+
// Capital
|
|
34360
|
+
tier1_leverage: { thresholds: [8, 6, 5, 4], higher_is_better: true, weight: 0.35, label: "Tier 1 Leverage Ratio", unit: "%" },
|
|
34361
|
+
tier1_rbc: { thresholds: [10, 8, 6, 4], higher_is_better: true, weight: 0.35, label: "Tier 1 Risk-Based Capital", unit: "%" },
|
|
34362
|
+
equity_ratio: { thresholds: [10, 8, 7, 5], higher_is_better: true, weight: 0.3, label: "Equity / Assets", unit: "%" },
|
|
34363
|
+
// Asset Quality
|
|
34364
|
+
noncurrent_loans_ratio: { thresholds: [1, 2, 3, 5], higher_is_better: false, weight: 0.3, label: "Noncurrent Loans / Loans", unit: "%" },
|
|
34365
|
+
net_chargeoff_ratio: { thresholds: [0.25, 0.5, 1, 2], higher_is_better: false, weight: 0.25, label: "Net Charge-Off Ratio", unit: "%" },
|
|
34366
|
+
reserve_coverage: { thresholds: [150, 100, 80, 50], higher_is_better: true, weight: 0.25, label: "Reserve Coverage", unit: "%" },
|
|
34367
|
+
noncurrent_assets_ratio: { thresholds: [0.5, 1, 2, 4], higher_is_better: false, weight: 0.2, label: "Noncurrent Assets / Assets", unit: "%" },
|
|
34368
|
+
// Earnings
|
|
34369
|
+
roa: { thresholds: [1, 0.75, 0.5, 0], higher_is_better: true, weight: 0.3, label: "Return on Assets", unit: "%" },
|
|
34370
|
+
nim: { thresholds: [3.5, 3, 2.5, 2], higher_is_better: true, weight: 0.25, label: "Net Interest Margin", unit: "%" },
|
|
34371
|
+
efficiency_ratio: { thresholds: [55, 60, 70, 85], higher_is_better: false, weight: 0.25, label: "Efficiency Ratio", unit: "%" },
|
|
34372
|
+
roe: { thresholds: [10, 8, 5, 0], higher_is_better: true, weight: 0.2, label: "Return on Equity", unit: "%" },
|
|
34373
|
+
// Liquidity
|
|
34374
|
+
core_deposit_ratio: { thresholds: [80, 70, 60, 45], higher_is_better: true, weight: 0.3, label: "Core Deposits / Deposits", unit: "%" },
|
|
34375
|
+
loan_to_deposit: { thresholds: [80, 85, 95, 105], higher_is_better: false, weight: 0.25, label: "Loan-to-Deposit Ratio", unit: "%" },
|
|
34376
|
+
brokered_deposit_ratio: { thresholds: [5, 10, 15, 25], higher_is_better: false, weight: 0.25, label: "Brokered Deposits / Deposits", unit: "%" },
|
|
34377
|
+
cash_ratio: { thresholds: [8, 5, 3, 1], higher_is_better: true, weight: 0.2, label: "Cash / Assets", unit: "%" },
|
|
34378
|
+
// Sensitivity
|
|
34379
|
+
nim_4q_change: { thresholds: [0.1, 0, -0.15, -0.3], higher_is_better: true, weight: 0.5, label: "NIM 4Q Change", unit: "pp" },
|
|
34380
|
+
securities_to_assets: { thresholds: [25, 30, 40, 50], higher_is_better: false, weight: 0.5, label: "Securities / Assets", unit: "%" }
|
|
34381
|
+
};
|
|
34382
|
+
var COMPONENT_METRIC_MAP = {
|
|
34383
|
+
C: ["tier1_leverage", "tier1_rbc", "equity_ratio"],
|
|
34384
|
+
A: ["noncurrent_loans_ratio", "net_chargeoff_ratio", "reserve_coverage", "noncurrent_assets_ratio"],
|
|
34385
|
+
E: ["roa", "nim", "efficiency_ratio", "roe"],
|
|
34386
|
+
L: ["core_deposit_ratio", "loan_to_deposit", "brokered_deposit_ratio", "cash_ratio"],
|
|
34387
|
+
S: ["nim_4q_change", "securities_to_assets"]
|
|
34388
|
+
};
|
|
34389
|
+
var RATING_LABELS = {
|
|
34390
|
+
1: "Strong",
|
|
34391
|
+
2: "Satisfactory",
|
|
34392
|
+
3: "Fair",
|
|
34393
|
+
4: "Marginal",
|
|
34394
|
+
5: "Unsatisfactory"
|
|
34395
|
+
};
|
|
34396
|
+
var CAMELS_FIELDS = [
|
|
34397
|
+
"CERT",
|
|
34398
|
+
"REPDTE",
|
|
34399
|
+
"ASSET",
|
|
34400
|
+
"EQTOT",
|
|
34401
|
+
"EQV",
|
|
34402
|
+
"IDT1CER",
|
|
34403
|
+
"IDT1RWAJR",
|
|
34404
|
+
"NPERFV",
|
|
34405
|
+
"NCLNLSR",
|
|
34406
|
+
"NTLNLSR",
|
|
34407
|
+
"LNATRESR",
|
|
34408
|
+
"LNRESNCR",
|
|
34409
|
+
"ELNATRY",
|
|
34410
|
+
"ROA",
|
|
34411
|
+
"ROAPTX",
|
|
34412
|
+
"ROE",
|
|
34413
|
+
"NIMY",
|
|
34414
|
+
"EEFFR",
|
|
34415
|
+
"NETINC",
|
|
34416
|
+
"INTINC",
|
|
34417
|
+
"EINTEXP",
|
|
34418
|
+
"NONII",
|
|
34419
|
+
"NONIX",
|
|
34420
|
+
"DEP",
|
|
34421
|
+
"COREDEP",
|
|
34422
|
+
"BROR",
|
|
34423
|
+
"LNLSDEPR",
|
|
34424
|
+
"DEPDASTR",
|
|
34425
|
+
"CHBALR",
|
|
34426
|
+
"SC"
|
|
34427
|
+
].join(",");
|
|
34428
|
+
function safe(v) {
|
|
34429
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
34430
|
+
}
|
|
34431
|
+
function safeDivide(num, den) {
|
|
34432
|
+
if (num === null || den === null || den === 0) return null;
|
|
34433
|
+
return num / den;
|
|
34434
|
+
}
|
|
34435
|
+
function computeCamelsMetrics(current, prior) {
|
|
34436
|
+
const dep = safe(current.DEP);
|
|
34437
|
+
const asset = safe(current.ASSET);
|
|
34438
|
+
const coredep = safe(current.COREDEP);
|
|
34439
|
+
const sc = safe(current.SC);
|
|
34440
|
+
const intinc = safe(current.INTINC);
|
|
34441
|
+
const eintexp = safe(current.EINTEXP);
|
|
34442
|
+
const nonii = safe(current.NONII);
|
|
34443
|
+
const nii = intinc !== null && eintexp !== null ? intinc - eintexp : null;
|
|
34444
|
+
const totalRevenue = nii !== null && nonii !== null ? nii + nonii : null;
|
|
34445
|
+
const nimNow = safe(current.NIMY);
|
|
34446
|
+
const nim4qAgo = prior && prior.length >= 4 ? safe(prior[3].NIMY) : null;
|
|
34447
|
+
const nim4qChange = nimNow !== null && nim4qAgo !== null ? nimNow - nim4qAgo : null;
|
|
34448
|
+
const coreDepRatio = safeDivide(coredep, dep);
|
|
34449
|
+
const secToAssets = safeDivide(sc, asset);
|
|
34450
|
+
const niiShare = totalRevenue !== null && totalRevenue > 0 && nonii !== null ? nonii / totalRevenue * 100 : null;
|
|
34451
|
+
return {
|
|
34452
|
+
equity_ratio: safe(current.EQV),
|
|
34453
|
+
tier1_leverage: safe(current.IDT1CER),
|
|
34454
|
+
tier1_rbc: safe(current.IDT1RWAJR),
|
|
34455
|
+
noncurrent_assets_ratio: safe(current.NPERFV),
|
|
34456
|
+
noncurrent_loans_ratio: safe(current.NCLNLSR),
|
|
34457
|
+
net_chargeoff_ratio: safe(current.NTLNLSR),
|
|
34458
|
+
reserve_to_loans: safe(current.LNATRESR),
|
|
34459
|
+
reserve_coverage: safe(current.LNRESNCR),
|
|
34460
|
+
provision_ratio: safe(current.ELNATRY),
|
|
34461
|
+
roa: safe(current.ROA),
|
|
34462
|
+
roe: safe(current.ROE),
|
|
34463
|
+
nim: nimNow,
|
|
34464
|
+
efficiency_ratio: safe(current.EEFFR),
|
|
34465
|
+
pretax_roa: safe(current.ROAPTX),
|
|
34466
|
+
noninterest_income_share: niiShare,
|
|
34467
|
+
loan_to_deposit: safe(current.LNLSDEPR),
|
|
34468
|
+
deposits_to_assets: safe(current.DEPDASTR),
|
|
34469
|
+
core_deposit_ratio: coreDepRatio !== null ? coreDepRatio * 100 : null,
|
|
34470
|
+
brokered_deposit_ratio: safe(current.BROR),
|
|
34471
|
+
cash_ratio: safe(current.CHBALR),
|
|
34472
|
+
securities_to_assets: secToAssets !== null ? secToAssets * 100 : null,
|
|
34473
|
+
nim_4q_change: nim4qChange
|
|
34474
|
+
};
|
|
34475
|
+
}
|
|
34476
|
+
function scoreMetric(value, rule) {
|
|
34477
|
+
if (value === null) return 3;
|
|
34478
|
+
const [t1, t2, t3, t4] = rule.thresholds;
|
|
34479
|
+
if (rule.higher_is_better) {
|
|
34480
|
+
if (value >= t1) return 1;
|
|
34481
|
+
if (value >= t2) return 2;
|
|
34482
|
+
if (value >= t3) return 3;
|
|
34483
|
+
if (value >= t4) return 4;
|
|
34484
|
+
return 5;
|
|
34485
|
+
}
|
|
34486
|
+
if (value <= t1) return 1;
|
|
34487
|
+
if (value <= t2) return 2;
|
|
34488
|
+
if (value <= t3) return 3;
|
|
34489
|
+
if (value <= t4) return 4;
|
|
34490
|
+
return 5;
|
|
34491
|
+
}
|
|
34492
|
+
function scoreComponent(component, metrics) {
|
|
34493
|
+
const metricNames = COMPONENT_METRIC_MAP[component];
|
|
34494
|
+
let weightedSum = 0;
|
|
34495
|
+
let totalWeight = 0;
|
|
34496
|
+
const scored = [];
|
|
34497
|
+
const flags = [];
|
|
34498
|
+
for (const metricName of metricNames) {
|
|
34499
|
+
const rule = SCORING_RULES[metricName];
|
|
34500
|
+
const value = metrics[metricName];
|
|
34501
|
+
const rating2 = scoreMetric(value, rule);
|
|
34502
|
+
scored.push({
|
|
34503
|
+
name: metricName,
|
|
34504
|
+
label: rule.label,
|
|
34505
|
+
value,
|
|
34506
|
+
rating: rating2,
|
|
34507
|
+
rating_label: RATING_LABELS[rating2],
|
|
34508
|
+
unit: rule.unit
|
|
34509
|
+
});
|
|
34510
|
+
if (value !== null) {
|
|
34511
|
+
weightedSum += rating2 * rule.weight;
|
|
34512
|
+
totalWeight += rule.weight;
|
|
34513
|
+
}
|
|
34514
|
+
if (rating2 >= 4 && value !== null) {
|
|
34515
|
+
flags.push(`${rule.label} at ${value.toFixed(2)}${rule.unit} rated ${RATING_LABELS[rating2]}`);
|
|
34516
|
+
}
|
|
34517
|
+
}
|
|
34518
|
+
const raw = totalWeight > 0 ? weightedSum / totalWeight : 3;
|
|
34519
|
+
const rating = Math.round(raw);
|
|
34520
|
+
return { component, rating, label: RATING_LABELS[rating], metrics: scored, flags };
|
|
34521
|
+
}
|
|
34522
|
+
var COMPONENT_WEIGHTS = {
|
|
34523
|
+
C: 0.25,
|
|
34524
|
+
A: 0.25,
|
|
34525
|
+
E: 0.2,
|
|
34526
|
+
L: 0.15,
|
|
34527
|
+
S: 0.15
|
|
34528
|
+
};
|
|
34529
|
+
function compositeScore(components) {
|
|
34530
|
+
let sum = 0;
|
|
34531
|
+
const allFlags = [];
|
|
34532
|
+
for (const c of components) {
|
|
34533
|
+
sum += c.rating * (COMPONENT_WEIGHTS[c.component] ?? 0);
|
|
34534
|
+
allFlags.push(...c.flags);
|
|
34535
|
+
}
|
|
34536
|
+
const rating = Math.round(sum);
|
|
34537
|
+
return { rating, label: RATING_LABELS[rating], components, flags: allFlags };
|
|
34538
|
+
}
|
|
34539
|
+
function analyzeTrend(metricName, timeseries, higherIsBetter) {
|
|
34540
|
+
const label = SCORING_RULES[metricName]?.label ?? metricName;
|
|
34541
|
+
const valid = timeseries.filter(
|
|
34542
|
+
(t) => t.value !== null
|
|
34543
|
+
);
|
|
34544
|
+
if (valid.length < 2) {
|
|
34545
|
+
return {
|
|
34546
|
+
metric: metricName,
|
|
34547
|
+
label,
|
|
34548
|
+
values: valid,
|
|
34549
|
+
direction: "stable",
|
|
34550
|
+
magnitude: "minimal",
|
|
34551
|
+
quarters_analyzed: valid.length
|
|
34552
|
+
};
|
|
34553
|
+
}
|
|
34554
|
+
const n = valid.length;
|
|
34555
|
+
const xMean = (n - 1) / 2;
|
|
34556
|
+
const yMean = valid.reduce((s, v) => s + v.value, 0) / n;
|
|
34557
|
+
let num = 0;
|
|
34558
|
+
let den = 0;
|
|
34559
|
+
for (let i = 0; i < n; i++) {
|
|
34560
|
+
num += (i - xMean) * (valid[i].value - yMean);
|
|
34561
|
+
den += (i - xMean) ** 2;
|
|
34562
|
+
}
|
|
34563
|
+
const slope = den !== 0 ? num / den : 0;
|
|
34564
|
+
const relSlope = yMean !== 0 ? slope / Math.abs(yMean) : 0;
|
|
34565
|
+
const direction = higherIsBetter && relSlope > 0.02 || !higherIsBetter && relSlope < -0.02 ? "improving" : higherIsBetter && relSlope < -0.02 || !higherIsBetter && relSlope > 0.02 ? "deteriorating" : "stable";
|
|
34566
|
+
const absMag = Math.abs(relSlope);
|
|
34567
|
+
const magnitude = absMag > 0.1 ? "significant" : absMag > 0.03 ? "moderate" : "minimal";
|
|
34568
|
+
return {
|
|
34569
|
+
metric: metricName,
|
|
34570
|
+
label,
|
|
34571
|
+
values: valid,
|
|
34572
|
+
direction,
|
|
34573
|
+
magnitude,
|
|
34574
|
+
quarters_analyzed: n
|
|
34575
|
+
};
|
|
34576
|
+
}
|
|
34577
|
+
function isStale(repdte) {
|
|
34578
|
+
const year = Number.parseInt(repdte.slice(0, 4), 10);
|
|
34579
|
+
const month = Number.parseInt(repdte.slice(4, 6), 10);
|
|
34580
|
+
const day = Number.parseInt(repdte.slice(6, 8), 10);
|
|
34581
|
+
const reportDate = new Date(Date.UTC(year, month - 1, day));
|
|
34582
|
+
if (Number.isNaN(reportDate.getTime())) return true;
|
|
34583
|
+
const daysSince = (Date.now() - reportDate.getTime()) / 864e5;
|
|
34584
|
+
return daysSince > 120;
|
|
34585
|
+
}
|
|
34586
|
+
function formatRating(rating) {
|
|
34587
|
+
return `${rating} - ${RATING_LABELS[rating]}`;
|
|
34588
|
+
}
|
|
34589
|
+
|
|
34590
|
+
// src/tools/bankHealth.ts
|
|
34591
|
+
var COMPONENT_NAMES = {
|
|
34592
|
+
C: "Capital Adequacy",
|
|
34593
|
+
A: "Asset Quality",
|
|
34594
|
+
E: "Earnings",
|
|
34595
|
+
L: "Liquidity",
|
|
34596
|
+
S: "Sensitivity to Market Risk"
|
|
34597
|
+
};
|
|
34598
|
+
var TREND_METRICS = [
|
|
34599
|
+
{ key: "tier1_leverage", fdic_field: "IDT1CER", higher_is_better: true },
|
|
34600
|
+
{ key: "noncurrent_loans_ratio", fdic_field: "NCLNLSR", higher_is_better: false },
|
|
34601
|
+
{ key: "roa", fdic_field: "ROA", higher_is_better: true },
|
|
34602
|
+
{ key: "nim", fdic_field: "NIMY", higher_is_better: true },
|
|
34603
|
+
{ key: "efficiency_ratio", fdic_field: "EEFFR", higher_is_better: false },
|
|
34604
|
+
{ key: "loan_to_deposit", fdic_field: "LNLSDEPR", higher_is_better: false }
|
|
34605
|
+
];
|
|
34606
|
+
function formatHealthSummaryText(summary) {
|
|
34607
|
+
const parts = [];
|
|
34608
|
+
const { institution: inst, composite } = summary;
|
|
34609
|
+
parts.push(`CAMELS-Style Health Assessment: ${inst.name} (CERT ${inst.cert})`);
|
|
34610
|
+
parts.push(`${inst.city}, ${inst.state} | Charter: ${inst.charter_class} | Assets: $${Math.round(inst.total_assets).toLocaleString()}k`);
|
|
34611
|
+
parts.push(`Report Date: ${inst.report_date} | Data: ${inst.data_staleness}`);
|
|
34612
|
+
parts.push("");
|
|
34613
|
+
parts.push("NOTE: This is an analytical assessment based on public financial data, not an official regulatory CAMELS rating.");
|
|
34614
|
+
parts.push("");
|
|
34615
|
+
parts.push(`Composite Rating: ${formatRating(composite.rating)}`);
|
|
34616
|
+
parts.push("");
|
|
34617
|
+
for (const comp of summary.components) {
|
|
34618
|
+
const name = COMPONENT_NAMES[comp.component] ?? comp.component;
|
|
34619
|
+
parts.push(`${name} (${comp.component}): ${formatRating(comp.rating)}`);
|
|
34620
|
+
for (const m of comp.metrics) {
|
|
34621
|
+
const val = m.value !== null ? `${m.value.toFixed(2)}${m.unit}` : "n/a";
|
|
34622
|
+
parts.push(` ${m.label.padEnd(30)} ${val.padStart(10)} ${m.rating_label}`);
|
|
34623
|
+
}
|
|
34624
|
+
if (comp.flags.length > 0) {
|
|
34625
|
+
for (const flag of comp.flags) {
|
|
34626
|
+
parts.push(` \u26A0 ${flag}`);
|
|
34627
|
+
}
|
|
34628
|
+
}
|
|
34629
|
+
parts.push("");
|
|
34630
|
+
}
|
|
34631
|
+
if (summary.trends.length > 0) {
|
|
34632
|
+
parts.push("Trend Analysis:");
|
|
34633
|
+
for (const t of summary.trends) {
|
|
34634
|
+
if (t.direction !== "stable") {
|
|
34635
|
+
parts.push(` ${t.label}: ${t.direction} (${t.magnitude}, ${t.quarters_analyzed}Q analyzed)`);
|
|
34636
|
+
}
|
|
34637
|
+
}
|
|
34638
|
+
parts.push("");
|
|
34639
|
+
}
|
|
34640
|
+
if (summary.risk_signals.length > 0) {
|
|
34641
|
+
parts.push("Risk Signals:");
|
|
34642
|
+
for (const signal of summary.risk_signals) {
|
|
34643
|
+
parts.push(` \u2022 ${signal}`);
|
|
34644
|
+
}
|
|
34645
|
+
parts.push("");
|
|
34646
|
+
}
|
|
34647
|
+
return parts.join("\n");
|
|
34648
|
+
}
|
|
34649
|
+
function getPriorQuarterDates(repdte, count) {
|
|
34650
|
+
const dates = [];
|
|
34651
|
+
const suffixes = ["0331", "0630", "0930", "1231"];
|
|
34652
|
+
let year = Number.parseInt(repdte.slice(0, 4), 10);
|
|
34653
|
+
let qIdx = suffixes.indexOf(repdte.slice(4));
|
|
34654
|
+
if (qIdx === -1) return dates;
|
|
34655
|
+
for (let i = 0; i < count; i++) {
|
|
34656
|
+
qIdx--;
|
|
34657
|
+
if (qIdx < 0) {
|
|
34658
|
+
qIdx = 3;
|
|
34659
|
+
year--;
|
|
34660
|
+
}
|
|
34661
|
+
dates.push(`${year}${suffixes[qIdx]}`);
|
|
34662
|
+
}
|
|
34663
|
+
return dates;
|
|
34664
|
+
}
|
|
34665
|
+
function collectRiskSignals(metrics, components, trends) {
|
|
34666
|
+
const signals = [];
|
|
34667
|
+
if (metrics.tier1_leverage !== null && metrics.tier1_leverage < 5) {
|
|
34668
|
+
signals.push(`Tier 1 leverage ratio at ${metrics.tier1_leverage.toFixed(2)}% \u2014 below well-capitalized threshold (5%)`);
|
|
34669
|
+
}
|
|
34670
|
+
if (metrics.roa !== null && metrics.roa < 0) {
|
|
34671
|
+
signals.push(`Operating losses: ROA at ${metrics.roa.toFixed(2)}%`);
|
|
34672
|
+
}
|
|
34673
|
+
if (metrics.reserve_coverage !== null && metrics.reserve_coverage < 50) {
|
|
34674
|
+
signals.push(`Reserve coverage critically low at ${metrics.reserve_coverage.toFixed(1)}%`);
|
|
34675
|
+
}
|
|
34676
|
+
if (metrics.brokered_deposit_ratio !== null && metrics.brokered_deposit_ratio > 15) {
|
|
34677
|
+
signals.push(`High brokered deposit reliance: ${metrics.brokered_deposit_ratio.toFixed(1)}%`);
|
|
34678
|
+
}
|
|
34679
|
+
if (metrics.noncurrent_loans_ratio !== null && metrics.noncurrent_loans_ratio > 3) {
|
|
34680
|
+
signals.push(`Elevated noncurrent loans: ${metrics.noncurrent_loans_ratio.toFixed(2)}%`);
|
|
34681
|
+
}
|
|
34682
|
+
for (const comp of components) {
|
|
34683
|
+
if (comp.rating >= 4) {
|
|
34684
|
+
const name = COMPONENT_NAMES[comp.component] ?? comp.component;
|
|
34685
|
+
signals.push(`${name} component rated ${comp.rating} (${comp.label})`);
|
|
34686
|
+
}
|
|
34687
|
+
}
|
|
34688
|
+
for (const t of trends) {
|
|
34689
|
+
if (t.direction === "deteriorating" && t.magnitude === "significant") {
|
|
34690
|
+
signals.push(`${t.label} deteriorating significantly over ${t.quarters_analyzed} quarters`);
|
|
34691
|
+
}
|
|
34692
|
+
}
|
|
34693
|
+
return signals;
|
|
34694
|
+
}
|
|
34695
|
+
var BankHealthInputSchema = import_zod9.z.object({
|
|
34696
|
+
cert: import_zod9.z.number().int().positive().describe("FDIC Certificate Number of the institution to analyze."),
|
|
34697
|
+
repdte: import_zod9.z.string().regex(/^\d{8}$/).optional().describe(
|
|
34698
|
+
"Report Date (YYYYMMDD). Defaults to the most recent quarter likely to have published data."
|
|
34699
|
+
),
|
|
34700
|
+
quarters: import_zod9.z.number().int().min(1).max(20).default(8).describe("Number of prior quarters to fetch for trend analysis (default 8).")
|
|
34701
|
+
});
|
|
34702
|
+
function registerBankHealthTools(server) {
|
|
34703
|
+
server.registerTool(
|
|
34704
|
+
"fdic_analyze_bank_health",
|
|
34705
|
+
{
|
|
34706
|
+
title: "Analyze Bank Health (CAMELS-Style)",
|
|
34707
|
+
description: `Produce a CAMELS-style analytical assessment for a single FDIC-insured institution.
|
|
34708
|
+
|
|
34709
|
+
Scores five components \u2014 Capital (C), Asset Quality (A), Earnings (E), Liquidity (L), Sensitivity (S) \u2014 using published FDIC financial data and derives a weighted composite rating (1=Strong to 5=Unsatisfactory).
|
|
34710
|
+
|
|
34711
|
+
Output includes:
|
|
34712
|
+
- Composite and component ratings with individual metric scores
|
|
34713
|
+
- Trend analysis across prior quarters for key metrics
|
|
34714
|
+
- Risk signals flagging critical and warning-level concerns
|
|
34715
|
+
- Structured JSON for programmatic consumption
|
|
34716
|
+
|
|
34717
|
+
NOTE: Management (M) is omitted \u2014 cannot be assessed from public data. Sensitivity (S) uses proxy metrics (NIM trend, securities concentration). This is an analytical tool, not an official regulatory rating.`,
|
|
34718
|
+
inputSchema: BankHealthInputSchema,
|
|
34719
|
+
annotations: {
|
|
34720
|
+
readOnlyHint: true,
|
|
34721
|
+
destructiveHint: false,
|
|
34722
|
+
idempotentHint: true,
|
|
34723
|
+
openWorldHint: true
|
|
34724
|
+
}
|
|
34725
|
+
},
|
|
34726
|
+
async (rawParams, extra) => {
|
|
34727
|
+
const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
|
|
34728
|
+
const controller = new AbortController();
|
|
34729
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
34730
|
+
const progressToken = extra._meta?.progressToken;
|
|
34731
|
+
try {
|
|
34732
|
+
const dateError = validateQuarterEndDate(params.repdte, "repdte");
|
|
34733
|
+
if (dateError) {
|
|
34734
|
+
return formatToolError(new Error(dateError));
|
|
34735
|
+
}
|
|
34736
|
+
await sendProgressNotification(server.server, progressToken, 0.1, "Fetching institution profile");
|
|
34737
|
+
const [profileResponse, financialsResponse] = await Promise.all([
|
|
34738
|
+
queryEndpoint(
|
|
34739
|
+
ENDPOINTS.INSTITUTIONS,
|
|
34740
|
+
{
|
|
34741
|
+
filters: `CERT:${params.cert}`,
|
|
34742
|
+
fields: "CERT,NAME,CITY,STALP,BKCLASS,ASSET,ACTIVE",
|
|
34743
|
+
limit: 1
|
|
34744
|
+
},
|
|
34745
|
+
{ signal: controller.signal }
|
|
34746
|
+
),
|
|
34747
|
+
queryEndpoint(
|
|
34748
|
+
ENDPOINTS.FINANCIALS,
|
|
34749
|
+
{
|
|
34750
|
+
filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
|
|
34751
|
+
fields: CAMELS_FIELDS,
|
|
34752
|
+
limit: 1
|
|
34753
|
+
},
|
|
34754
|
+
{ signal: controller.signal }
|
|
34755
|
+
)
|
|
34756
|
+
]);
|
|
34757
|
+
const profileRecords = extractRecords(profileResponse);
|
|
34758
|
+
if (profileRecords.length === 0) {
|
|
34759
|
+
return formatToolError(new Error(`No institution found with CERT number ${params.cert}.`));
|
|
34760
|
+
}
|
|
34761
|
+
const profile = profileRecords[0];
|
|
34762
|
+
const financialRecords = extractRecords(financialsResponse);
|
|
34763
|
+
if (financialRecords.length === 0) {
|
|
34764
|
+
return formatToolError(
|
|
34765
|
+
new Error(
|
|
34766
|
+
`No financial data for CERT ${params.cert} at report date ${params.repdte}. Try an earlier quarter-end date (0331, 0630, 0930, 1231).`
|
|
34767
|
+
)
|
|
34768
|
+
);
|
|
34769
|
+
}
|
|
34770
|
+
const currentFinancials = financialRecords[0];
|
|
34771
|
+
await sendProgressNotification(server.server, progressToken, 0.3, "Fetching prior quarters for trend analysis");
|
|
34772
|
+
const priorDates = getPriorQuarterDates(params.repdte, params.quarters);
|
|
34773
|
+
let priorQuarters = [];
|
|
34774
|
+
if (priorDates.length > 0) {
|
|
34775
|
+
const dateFilter = priorDates.map((d) => `REPDTE:${d}`).join(" OR ");
|
|
34776
|
+
const priorResponse = await queryEndpoint(
|
|
34777
|
+
ENDPOINTS.FINANCIALS,
|
|
34778
|
+
{
|
|
34779
|
+
filters: `CERT:${params.cert} AND (${dateFilter})`,
|
|
34780
|
+
fields: CAMELS_FIELDS,
|
|
34781
|
+
sort_by: "REPDTE",
|
|
34782
|
+
sort_order: "DESC",
|
|
34783
|
+
limit: params.quarters
|
|
34784
|
+
},
|
|
34785
|
+
{ signal: controller.signal }
|
|
34786
|
+
);
|
|
34787
|
+
priorQuarters = extractRecords(priorResponse);
|
|
34788
|
+
}
|
|
34789
|
+
await sendProgressNotification(server.server, progressToken, 0.6, "Computing CAMELS scores");
|
|
34790
|
+
const metrics = computeCamelsMetrics(currentFinancials, priorQuarters);
|
|
34791
|
+
const components = ["C", "A", "E", "L", "S"].map(
|
|
34792
|
+
(c) => scoreComponent(c, metrics)
|
|
34793
|
+
);
|
|
34794
|
+
const composite = compositeScore(components);
|
|
34795
|
+
await sendProgressNotification(server.server, progressToken, 0.8, "Analyzing trends");
|
|
34796
|
+
const allQuarters = [currentFinancials, ...priorQuarters];
|
|
34797
|
+
const trends = [];
|
|
34798
|
+
for (const tm of TREND_METRICS) {
|
|
34799
|
+
const timeseries = allQuarters.map((q) => ({
|
|
34800
|
+
repdte: String(q.REPDTE ?? ""),
|
|
34801
|
+
value: typeof q[tm.fdic_field] === "number" ? q[tm.fdic_field] : null
|
|
34802
|
+
}));
|
|
34803
|
+
timeseries.reverse();
|
|
34804
|
+
trends.push(analyzeTrend(String(tm.key), timeseries, tm.higher_is_better));
|
|
34805
|
+
}
|
|
34806
|
+
const riskSignals = collectRiskSignals(metrics, components, trends);
|
|
34807
|
+
const staleness = isStale(params.repdte) ? "stale (>120 days old)" : "current";
|
|
34808
|
+
const summary = {
|
|
34809
|
+
institution: {
|
|
34810
|
+
cert: params.cert,
|
|
34811
|
+
name: String(profile.NAME ?? ""),
|
|
34812
|
+
city: String(profile.CITY ?? ""),
|
|
34813
|
+
state: String(profile.STALP ?? ""),
|
|
34814
|
+
charter_class: String(profile.BKCLASS ?? ""),
|
|
34815
|
+
total_assets: typeof currentFinancials.ASSET === "number" ? currentFinancials.ASSET : 0,
|
|
34816
|
+
report_date: params.repdte,
|
|
34817
|
+
data_staleness: staleness
|
|
34818
|
+
},
|
|
34819
|
+
composite: { rating: composite.rating, label: composite.label },
|
|
34820
|
+
components,
|
|
34821
|
+
trends,
|
|
34822
|
+
outliers: [],
|
|
34823
|
+
risk_signals: riskSignals
|
|
34824
|
+
};
|
|
34825
|
+
const text = truncateIfNeeded(
|
|
34826
|
+
formatHealthSummaryText(summary),
|
|
34827
|
+
CHARACTER_LIMIT
|
|
34828
|
+
);
|
|
34829
|
+
return {
|
|
34830
|
+
content: [{ type: "text", text }],
|
|
34831
|
+
structuredContent: summary
|
|
34832
|
+
};
|
|
34833
|
+
} catch (err) {
|
|
34834
|
+
return formatToolError(err);
|
|
34835
|
+
} finally {
|
|
34836
|
+
clearTimeout(timeoutId);
|
|
34837
|
+
}
|
|
34838
|
+
}
|
|
34839
|
+
);
|
|
34840
|
+
}
|
|
34841
|
+
|
|
34842
|
+
// src/tools/peerHealth.ts
|
|
34843
|
+
var import_zod10 = require("zod");
|
|
34844
|
+
var PeerHealthInputSchema = import_zod10.z.object({
|
|
34845
|
+
cert: import_zod10.z.number().int().positive().optional().describe("Subject institution CERT to highlight in the ranking. Optional."),
|
|
34846
|
+
certs: import_zod10.z.array(import_zod10.z.number().int().positive()).max(50).optional().describe("Explicit list of CERTs to compare (max 50)."),
|
|
34847
|
+
state: import_zod10.z.string().regex(/^[A-Z]{2}$/).optional().describe('Two-letter state code to select all active institutions (e.g., "WY").'),
|
|
34848
|
+
asset_min: import_zod10.z.number().positive().optional().describe("Minimum total assets ($thousands) for peer selection."),
|
|
34849
|
+
asset_max: import_zod10.z.number().positive().optional().describe("Maximum total assets ($thousands) for peer selection."),
|
|
34850
|
+
repdte: import_zod10.z.string().regex(/^\d{8}$/).optional().describe("Report Date (YYYYMMDD). Defaults to the most recent quarter."),
|
|
34851
|
+
sort_by: import_zod10.z.enum(["composite", "capital", "asset_quality", "earnings", "liquidity", "sensitivity"]).default("composite").describe("Sort results by composite or a specific CAMELS component rating."),
|
|
34852
|
+
limit: import_zod10.z.number().int().min(1).max(100).default(25).describe("Max institutions to return in the response.")
|
|
34853
|
+
});
|
|
34854
|
+
function sortKeyToComponent(key) {
|
|
34855
|
+
const map = {
|
|
34856
|
+
capital: "C",
|
|
34857
|
+
asset_quality: "A",
|
|
34858
|
+
earnings: "E",
|
|
34859
|
+
liquidity: "L",
|
|
34860
|
+
sensitivity: "S"
|
|
34861
|
+
};
|
|
34862
|
+
return map[key] ?? null;
|
|
34863
|
+
}
|
|
34864
|
+
function registerPeerHealthTools(server) {
|
|
34865
|
+
server.registerTool(
|
|
34866
|
+
"fdic_compare_peer_health",
|
|
34867
|
+
{
|
|
34868
|
+
title: "Compare Peer Health (CAMELS Rankings)",
|
|
34869
|
+
description: `Compare CAMELS-style health scores across a group of FDIC-insured institutions.
|
|
34870
|
+
|
|
34871
|
+
Three usage modes:
|
|
34872
|
+
- Explicit list: provide certs (up to 50) for a specific comparison set
|
|
34873
|
+
- State-wide scan: provide state to compare all active institutions in that state
|
|
34874
|
+
- Asset-based: provide asset_min/asset_max to compare institutions by size
|
|
34875
|
+
|
|
34876
|
+
Optionally provide cert to highlight a subject institution's position in the ranking.
|
|
34877
|
+
|
|
34878
|
+
Output: Ranked list of institutions with CAMELS composite and component scores, sorted by composite or any individual component.
|
|
34879
|
+
|
|
34880
|
+
NOTE: This is an analytical assessment, not official regulatory ratings.`,
|
|
34881
|
+
inputSchema: PeerHealthInputSchema,
|
|
34882
|
+
annotations: {
|
|
34883
|
+
readOnlyHint: true,
|
|
34884
|
+
destructiveHint: false,
|
|
34885
|
+
idempotentHint: true,
|
|
34886
|
+
openWorldHint: true
|
|
34887
|
+
}
|
|
34888
|
+
},
|
|
34889
|
+
async (rawParams, extra) => {
|
|
34890
|
+
const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
|
|
34891
|
+
const controller = new AbortController();
|
|
34892
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
34893
|
+
const progressToken = extra._meta?.progressToken;
|
|
34894
|
+
try {
|
|
34895
|
+
if (!params.certs && !params.state && params.asset_min === void 0 && params.asset_max === void 0 && !params.cert) {
|
|
34896
|
+
return formatToolError(new Error("At least one selection criteria required: certs, state, asset_min/asset_max, or cert."));
|
|
34897
|
+
}
|
|
34898
|
+
const dateError = validateQuarterEndDate(params.repdte, "repdte");
|
|
34899
|
+
if (dateError) {
|
|
34900
|
+
return formatToolError(new Error(dateError));
|
|
34901
|
+
}
|
|
34902
|
+
await sendProgressNotification(server.server, progressToken, 0.1, "Building peer roster");
|
|
34903
|
+
let peerCerts;
|
|
34904
|
+
if (params.certs) {
|
|
34905
|
+
peerCerts = [...params.certs];
|
|
34906
|
+
if (params.cert && !peerCerts.includes(params.cert)) {
|
|
34907
|
+
peerCerts.push(params.cert);
|
|
34908
|
+
}
|
|
34909
|
+
} else {
|
|
34910
|
+
const filterParts = ["ACTIVE:1"];
|
|
34911
|
+
if (params.state) filterParts.push(`STALP:${params.state}`);
|
|
34912
|
+
if (params.asset_min !== void 0 || params.asset_max !== void 0) {
|
|
34913
|
+
const min = params.asset_min ?? 0;
|
|
34914
|
+
const max = params.asset_max ?? "*";
|
|
34915
|
+
filterParts.push(`ASSET:[${min} TO ${max}]`);
|
|
34916
|
+
}
|
|
34917
|
+
if (params.cert && !params.state && params.asset_min === void 0) {
|
|
34918
|
+
const profileResp = await queryEndpoint(
|
|
34919
|
+
ENDPOINTS.INSTITUTIONS,
|
|
34920
|
+
{
|
|
34921
|
+
filters: `CERT:${params.cert}`,
|
|
34922
|
+
fields: "CERT,ASSET,STALP,BKCLASS",
|
|
34923
|
+
limit: 1
|
|
34924
|
+
},
|
|
34925
|
+
{ signal: controller.signal }
|
|
34926
|
+
);
|
|
34927
|
+
const profileRecs = extractRecords(profileResp);
|
|
34928
|
+
if (profileRecs.length === 0) {
|
|
34929
|
+
return formatToolError(new Error(`No institution found with CERT ${params.cert}.`));
|
|
34930
|
+
}
|
|
34931
|
+
const subjectAsset = asNumber(profileRecs[0].ASSET);
|
|
34932
|
+
if (subjectAsset !== null) {
|
|
34933
|
+
filterParts.push(`ASSET:[${subjectAsset * 0.5} TO ${subjectAsset * 2}]`);
|
|
34934
|
+
}
|
|
34935
|
+
const bkclass = profileRecs[0].BKCLASS;
|
|
34936
|
+
if (typeof bkclass === "string") {
|
|
34937
|
+
filterParts.push(`BKCLASS:${bkclass}`);
|
|
34938
|
+
}
|
|
34939
|
+
}
|
|
34940
|
+
const rosterResp = await queryEndpoint(
|
|
34941
|
+
ENDPOINTS.INSTITUTIONS,
|
|
34942
|
+
{
|
|
34943
|
+
filters: filterParts.join(" AND "),
|
|
34944
|
+
fields: "CERT",
|
|
34945
|
+
limit: 1e4,
|
|
34946
|
+
sort_by: "CERT",
|
|
34947
|
+
sort_order: "ASC"
|
|
34948
|
+
},
|
|
34949
|
+
{ signal: controller.signal }
|
|
34950
|
+
);
|
|
34951
|
+
peerCerts = extractRecords(rosterResp).map((r) => asNumber(r.CERT)).filter((c) => c !== null);
|
|
34952
|
+
}
|
|
34953
|
+
if (peerCerts.length === 0) {
|
|
34954
|
+
return formatToolError(new Error("No institutions matched the specified criteria."));
|
|
34955
|
+
}
|
|
34956
|
+
await sendProgressNotification(server.server, progressToken, 0.4, `Fetching financials for ${peerCerts.length} institutions`);
|
|
34957
|
+
const certFilters = buildCertFilters(peerCerts);
|
|
34958
|
+
const financialResponses = await mapWithConcurrency(
|
|
34959
|
+
certFilters,
|
|
34960
|
+
MAX_CONCURRENCY,
|
|
34961
|
+
async (certFilter) => queryEndpoint(
|
|
34962
|
+
ENDPOINTS.FINANCIALS,
|
|
34963
|
+
{
|
|
34964
|
+
filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
|
|
34965
|
+
fields: CAMELS_FIELDS,
|
|
34966
|
+
limit: 1e4,
|
|
34967
|
+
sort_by: "CERT",
|
|
34968
|
+
sort_order: "ASC"
|
|
34969
|
+
},
|
|
34970
|
+
{ signal: controller.signal }
|
|
34971
|
+
)
|
|
34972
|
+
);
|
|
34973
|
+
const allFinancials = financialResponses.flatMap(extractRecords);
|
|
34974
|
+
const rosterResp2 = await queryEndpoint(
|
|
34975
|
+
ENDPOINTS.INSTITUTIONS,
|
|
34976
|
+
{
|
|
34977
|
+
filters: peerCerts.length <= 25 ? peerCerts.map((c) => `CERT:${c}`).join(" OR ") : `CERT:[${Math.min(...peerCerts)} TO ${Math.max(...peerCerts)}]`,
|
|
34978
|
+
fields: "CERT,NAME,CITY,STALP",
|
|
34979
|
+
limit: 1e4,
|
|
34980
|
+
sort_by: "CERT",
|
|
34981
|
+
sort_order: "ASC"
|
|
34982
|
+
},
|
|
34983
|
+
{ signal: controller.signal }
|
|
34984
|
+
);
|
|
34985
|
+
const profileMap = /* @__PURE__ */ new Map();
|
|
34986
|
+
for (const r of extractRecords(rosterResp2)) {
|
|
34987
|
+
const c = asNumber(r.CERT);
|
|
34988
|
+
if (c !== null) profileMap.set(c, r);
|
|
34989
|
+
}
|
|
34990
|
+
await sendProgressNotification(server.server, progressToken, 0.7, "Computing CAMELS scores");
|
|
34991
|
+
const entries = [];
|
|
34992
|
+
for (const fin of allFinancials) {
|
|
34993
|
+
const cert = asNumber(fin.CERT);
|
|
34994
|
+
if (cert === null) continue;
|
|
34995
|
+
const metrics = computeCamelsMetrics(fin);
|
|
34996
|
+
const components = ["C", "A", "E", "L", "S"].map(
|
|
34997
|
+
(c) => scoreComponent(c, metrics)
|
|
34998
|
+
);
|
|
34999
|
+
const comp = compositeScore(components);
|
|
35000
|
+
const profile = profileMap.get(cert);
|
|
35001
|
+
entries.push({
|
|
35002
|
+
cert,
|
|
35003
|
+
name: String(profile?.NAME ?? `CERT ${cert}`),
|
|
35004
|
+
city: profile?.CITY ? String(profile.CITY) : null,
|
|
35005
|
+
state: profile?.STALP ? String(profile.STALP) : null,
|
|
35006
|
+
total_assets: asNumber(fin.ASSET),
|
|
35007
|
+
composite_rating: comp.rating,
|
|
35008
|
+
composite_label: comp.label,
|
|
35009
|
+
component_ratings: Object.fromEntries(components.map((c) => [c.component, c.rating])),
|
|
35010
|
+
flags: comp.flags
|
|
35011
|
+
});
|
|
35012
|
+
}
|
|
35013
|
+
const sortComponent = sortKeyToComponent(params.sort_by);
|
|
35014
|
+
entries.sort((a, b) => {
|
|
35015
|
+
const aVal = sortComponent ? a.component_ratings[sortComponent] ?? 3 : a.composite_rating;
|
|
35016
|
+
const bVal = sortComponent ? b.component_ratings[sortComponent] ?? 3 : b.composite_rating;
|
|
35017
|
+
if (aVal !== bVal) return aVal - bVal;
|
|
35018
|
+
return (b.total_assets ?? 0) - (a.total_assets ?? 0);
|
|
35019
|
+
});
|
|
35020
|
+
const subjectRank = params.cert ? entries.findIndex((e) => e.cert === params.cert) + 1 : null;
|
|
35021
|
+
const returned = entries.slice(0, params.limit);
|
|
35022
|
+
await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
|
|
35023
|
+
const parts = [];
|
|
35024
|
+
parts.push(`CAMELS Peer Health Comparison \u2014 ${entries.length} institutions ranked by ${params.sort_by}`);
|
|
35025
|
+
parts.push(`Report Date: ${params.repdte}`);
|
|
35026
|
+
parts.push("NOTE: Analytical assessment, not official regulatory ratings.");
|
|
35027
|
+
parts.push("");
|
|
35028
|
+
if (subjectRank && subjectRank > 0 && params.cert) {
|
|
35029
|
+
const subj = entries[subjectRank - 1];
|
|
35030
|
+
parts.push(`Subject: ${subj.name} (CERT ${subj.cert}) \u2014 Rank ${subjectRank} of ${entries.length}, Composite: ${formatRating(subj.composite_rating)}`);
|
|
35031
|
+
parts.push("");
|
|
35032
|
+
}
|
|
35033
|
+
for (let i = 0; i < returned.length; i++) {
|
|
35034
|
+
const e = returned[i];
|
|
35035
|
+
const rank = entries.indexOf(e) + 1;
|
|
35036
|
+
const marker = params.cert && e.cert === params.cert ? " \u25C4 SUBJECT" : "";
|
|
35037
|
+
const location = [e.city, e.state].filter(Boolean).join(", ");
|
|
35038
|
+
const compStr = ["C", "A", "E", "L", "S"].map((c) => `${c}:${e.component_ratings[c] ?? "?"}`).join(" ");
|
|
35039
|
+
const assetStr = e.total_assets !== null ? `$${Math.round(e.total_assets).toLocaleString()}k` : "n/a";
|
|
35040
|
+
parts.push(
|
|
35041
|
+
`${String(rank).padStart(3)}. ${e.name} (${location}) CERT ${e.cert}${marker}`
|
|
35042
|
+
);
|
|
35043
|
+
parts.push(
|
|
35044
|
+
` Composite: ${formatRating(e.composite_rating)} | ${compStr} | Assets: ${assetStr}`
|
|
35045
|
+
);
|
|
35046
|
+
if (e.flags.length > 0) {
|
|
35047
|
+
parts.push(` Flags: ${e.flags.join("; ")}`);
|
|
35048
|
+
}
|
|
35049
|
+
}
|
|
35050
|
+
if (entries.length > returned.length) {
|
|
35051
|
+
parts.push("");
|
|
35052
|
+
parts.push(`Showing ${returned.length} of ${entries.length}. Increase limit to see more.`);
|
|
35053
|
+
}
|
|
35054
|
+
const text = truncateIfNeeded(parts.join("\n"), CHARACTER_LIMIT);
|
|
35055
|
+
return {
|
|
35056
|
+
content: [{ type: "text", text }],
|
|
35057
|
+
structuredContent: {
|
|
35058
|
+
report_date: params.repdte,
|
|
35059
|
+
sort_by: params.sort_by,
|
|
35060
|
+
total_institutions: entries.length,
|
|
35061
|
+
returned_count: returned.length,
|
|
35062
|
+
subject_cert: params.cert ?? null,
|
|
35063
|
+
subject_rank: subjectRank,
|
|
35064
|
+
institutions: returned
|
|
35065
|
+
}
|
|
35066
|
+
};
|
|
35067
|
+
} catch (err) {
|
|
35068
|
+
return formatToolError(err);
|
|
35069
|
+
} finally {
|
|
35070
|
+
clearTimeout(timeoutId);
|
|
35071
|
+
}
|
|
35072
|
+
}
|
|
35073
|
+
);
|
|
35074
|
+
}
|
|
35075
|
+
|
|
35076
|
+
// src/tools/riskSignals.ts
|
|
35077
|
+
var import_zod11 = require("zod");
|
|
35078
|
+
var TREND_METRICS2 = [
|
|
35079
|
+
{ key: "tier1_leverage", fdic_field: "IDT1CER", higher_is_better: true, category: "capital" },
|
|
35080
|
+
{ key: "noncurrent_loans_ratio", fdic_field: "NCLNLSR", higher_is_better: false, category: "asset_quality" },
|
|
35081
|
+
{ key: "roa", fdic_field: "ROA", higher_is_better: true, category: "earnings" },
|
|
35082
|
+
{ key: "nim", fdic_field: "NIMY", higher_is_better: true, category: "earnings" },
|
|
35083
|
+
{ key: "efficiency_ratio", fdic_field: "EEFFR", higher_is_better: false, category: "earnings" },
|
|
35084
|
+
{ key: "loan_to_deposit", fdic_field: "LNLSDEPR", higher_is_better: false, category: "liquidity" }
|
|
35085
|
+
];
|
|
35086
|
+
function classifyRiskSignals(metrics, trends) {
|
|
35087
|
+
const signals = [];
|
|
35088
|
+
if (metrics.tier1_leverage !== null && metrics.tier1_leverage < 5) {
|
|
35089
|
+
signals.push({ severity: "critical", category: "capital", message: `Tier 1 leverage at ${metrics.tier1_leverage.toFixed(2)}% \u2014 below well-capitalized threshold (5%)` });
|
|
35090
|
+
}
|
|
35091
|
+
if (metrics.tier1_rbc !== null && metrics.tier1_rbc < 6) {
|
|
35092
|
+
signals.push({ severity: "critical", category: "capital", message: `Tier 1 risk-based capital at ${metrics.tier1_rbc.toFixed(2)}% \u2014 below well-capitalized threshold (6%)` });
|
|
35093
|
+
}
|
|
35094
|
+
if (metrics.roa !== null && metrics.roa < 0) {
|
|
35095
|
+
signals.push({ severity: "critical", category: "earnings", message: `Operating losses: ROA at ${metrics.roa.toFixed(2)}%` });
|
|
35096
|
+
}
|
|
35097
|
+
if (metrics.reserve_coverage !== null && metrics.reserve_coverage < 50) {
|
|
35098
|
+
signals.push({ severity: "critical", category: "asset_quality", message: `Reserve coverage critically low at ${metrics.reserve_coverage.toFixed(1)}%` });
|
|
35099
|
+
}
|
|
35100
|
+
if (metrics.noncurrent_loans_ratio !== null && metrics.noncurrent_loans_ratio > 3) {
|
|
35101
|
+
signals.push({ severity: "warning", category: "asset_quality", message: `Noncurrent loans elevated at ${metrics.noncurrent_loans_ratio.toFixed(2)}%` });
|
|
35102
|
+
}
|
|
35103
|
+
if (metrics.brokered_deposit_ratio !== null && metrics.brokered_deposit_ratio > 15) {
|
|
35104
|
+
signals.push({ severity: "warning", category: "liquidity", message: `High brokered deposit reliance: ${metrics.brokered_deposit_ratio.toFixed(1)}%` });
|
|
35105
|
+
}
|
|
35106
|
+
if (metrics.nim_4q_change !== null && metrics.nim_4q_change < -0.3) {
|
|
35107
|
+
signals.push({ severity: "warning", category: "sensitivity", message: `NIM compressed ${Math.abs(metrics.nim_4q_change).toFixed(2)}pp over 4 quarters` });
|
|
35108
|
+
}
|
|
35109
|
+
const components = ["C", "A", "E", "L", "S"].map(
|
|
35110
|
+
(c) => scoreComponent(c, metrics)
|
|
35111
|
+
);
|
|
35112
|
+
const componentCategoryMap = {
|
|
35113
|
+
C: "capital",
|
|
35114
|
+
A: "asset_quality",
|
|
35115
|
+
E: "earnings",
|
|
35116
|
+
L: "liquidity",
|
|
35117
|
+
S: "sensitivity"
|
|
35118
|
+
};
|
|
35119
|
+
const componentNames = {
|
|
35120
|
+
C: "Capital",
|
|
35121
|
+
A: "Asset Quality",
|
|
35122
|
+
E: "Earnings",
|
|
35123
|
+
L: "Liquidity",
|
|
35124
|
+
S: "Sensitivity"
|
|
35125
|
+
};
|
|
35126
|
+
for (const comp of components) {
|
|
35127
|
+
if (comp.rating >= 4) {
|
|
35128
|
+
signals.push({
|
|
35129
|
+
severity: "warning",
|
|
35130
|
+
category: componentCategoryMap[comp.component],
|
|
35131
|
+
message: `${componentNames[comp.component]} component rated ${formatRating(comp.rating)}`
|
|
35132
|
+
});
|
|
35133
|
+
}
|
|
35134
|
+
}
|
|
35135
|
+
const trendCategoryMap = {
|
|
35136
|
+
tier1_leverage: "capital",
|
|
35137
|
+
noncurrent_loans_ratio: "asset_quality",
|
|
35138
|
+
roa: "earnings",
|
|
35139
|
+
nim: "earnings",
|
|
35140
|
+
efficiency_ratio: "earnings",
|
|
35141
|
+
loan_to_deposit: "liquidity"
|
|
35142
|
+
};
|
|
35143
|
+
for (const t of trends) {
|
|
35144
|
+
if (t.direction === "deteriorating") {
|
|
35145
|
+
const cat = trendCategoryMap[t.metric] ?? "trend";
|
|
35146
|
+
if (t.magnitude === "significant") {
|
|
35147
|
+
signals.push({ severity: "warning", category: cat, message: `${t.label} deteriorating significantly over ${t.quarters_analyzed} quarters` });
|
|
35148
|
+
} else if (t.magnitude === "moderate") {
|
|
35149
|
+
signals.push({ severity: "info", category: cat, message: `${t.label} deteriorating moderately over ${t.quarters_analyzed} quarters` });
|
|
35150
|
+
}
|
|
35151
|
+
}
|
|
35152
|
+
}
|
|
35153
|
+
return signals;
|
|
35154
|
+
}
|
|
35155
|
+
function getPriorQuarterDates2(repdte, count) {
|
|
35156
|
+
const dates = [];
|
|
35157
|
+
const suffixes = ["0331", "0630", "0930", "1231"];
|
|
35158
|
+
let year = Number.parseInt(repdte.slice(0, 4), 10);
|
|
35159
|
+
let qIdx = suffixes.indexOf(repdte.slice(4));
|
|
35160
|
+
if (qIdx === -1) return dates;
|
|
35161
|
+
for (let i = 0; i < count; i++) {
|
|
35162
|
+
qIdx--;
|
|
35163
|
+
if (qIdx < 0) {
|
|
35164
|
+
qIdx = 3;
|
|
35165
|
+
year--;
|
|
35166
|
+
}
|
|
35167
|
+
dates.push(`${year}${suffixes[qIdx]}`);
|
|
35168
|
+
}
|
|
35169
|
+
return dates;
|
|
35170
|
+
}
|
|
35171
|
+
var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
35172
|
+
var RiskSignalsInputSchema = import_zod11.z.object({
|
|
35173
|
+
state: import_zod11.z.string().regex(/^[A-Z]{2}$/).optional().describe("Scan all active institutions in this state."),
|
|
35174
|
+
certs: import_zod11.z.array(import_zod11.z.number().int().positive()).max(50).optional().describe("Specific CERTs to scan (max 50)."),
|
|
35175
|
+
asset_min: import_zod11.z.number().positive().optional().describe("Minimum total assets ($thousands) filter."),
|
|
35176
|
+
asset_max: import_zod11.z.number().positive().optional().describe("Maximum total assets ($thousands) filter."),
|
|
35177
|
+
repdte: import_zod11.z.string().regex(/^\d{8}$/).optional().describe("Report Date (YYYYMMDD). Defaults to the most recent quarter."),
|
|
35178
|
+
min_severity: import_zod11.z.enum(["info", "warning", "critical"]).default("warning").describe("Minimum severity level to include in results (default: warning)."),
|
|
35179
|
+
quarters: import_zod11.z.number().int().min(1).max(12).default(4).describe("Prior quarters to fetch for trend analysis (default 4)."),
|
|
35180
|
+
limit: import_zod11.z.number().int().min(1).max(100).default(25).describe("Max flagged institutions to return.")
|
|
35181
|
+
});
|
|
35182
|
+
function registerRiskSignalTools(server) {
|
|
35183
|
+
server.registerTool(
|
|
35184
|
+
"fdic_detect_risk_signals",
|
|
35185
|
+
{
|
|
35186
|
+
title: "Detect Risk Signals (Early Warning)",
|
|
35187
|
+
description: `Scan FDIC-insured institutions for early warning risk signals using CAMELS-style analysis.
|
|
35188
|
+
|
|
35189
|
+
Scans institutions for:
|
|
35190
|
+
- Critical: undercapitalized (Tier 1 < 5%), operating losses (ROA < 0), reserve coverage < 50%
|
|
35191
|
+
- Warning: CAMELS component rated 4+, significant deteriorating trends, brokered deposits > 15%, noncurrent loans > 3%
|
|
35192
|
+
- Info: moderate deteriorating trends
|
|
35193
|
+
|
|
35194
|
+
Three scan modes:
|
|
35195
|
+
- State-wide: provide state to scan all active institutions
|
|
35196
|
+
- Explicit list: provide certs (up to 50)
|
|
35197
|
+
- Asset-based: provide asset_min/asset_max
|
|
35198
|
+
|
|
35199
|
+
Output: Ranked list of flagged institutions sorted by signal severity count.
|
|
35200
|
+
|
|
35201
|
+
NOTE: Analytical screening tool, not official supervisory ratings.`,
|
|
35202
|
+
inputSchema: RiskSignalsInputSchema,
|
|
35203
|
+
annotations: {
|
|
35204
|
+
readOnlyHint: true,
|
|
35205
|
+
destructiveHint: false,
|
|
35206
|
+
idempotentHint: true,
|
|
35207
|
+
openWorldHint: true
|
|
35208
|
+
}
|
|
35209
|
+
},
|
|
35210
|
+
async (rawParams, extra) => {
|
|
35211
|
+
const params = { ...rawParams, repdte: rawParams.repdte ?? getDefaultReportDate() };
|
|
35212
|
+
const controller = new AbortController();
|
|
35213
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
35214
|
+
const progressToken = extra._meta?.progressToken;
|
|
35215
|
+
try {
|
|
35216
|
+
if (!params.certs && !params.state && params.asset_min === void 0 && params.asset_max === void 0) {
|
|
35217
|
+
return formatToolError(new Error("At least one selection criteria required: certs, state, or asset_min/asset_max."));
|
|
35218
|
+
}
|
|
35219
|
+
const dateError = validateQuarterEndDate(params.repdte, "repdte");
|
|
35220
|
+
if (dateError) {
|
|
35221
|
+
return formatToolError(new Error(dateError));
|
|
35222
|
+
}
|
|
35223
|
+
await sendProgressNotification(server.server, progressToken, 0.1, "Building institution roster");
|
|
35224
|
+
let targetCerts;
|
|
35225
|
+
if (params.certs) {
|
|
35226
|
+
targetCerts = [...params.certs];
|
|
35227
|
+
} else {
|
|
35228
|
+
const filterParts = ["ACTIVE:1"];
|
|
35229
|
+
if (params.state) filterParts.push(`STALP:${params.state}`);
|
|
35230
|
+
if (params.asset_min !== void 0 || params.asset_max !== void 0) {
|
|
35231
|
+
const min = params.asset_min ?? 0;
|
|
35232
|
+
const max = params.asset_max ?? "*";
|
|
35233
|
+
filterParts.push(`ASSET:[${min} TO ${max}]`);
|
|
35234
|
+
}
|
|
35235
|
+
const rosterResp = await queryEndpoint(
|
|
35236
|
+
ENDPOINTS.INSTITUTIONS,
|
|
35237
|
+
{
|
|
35238
|
+
filters: filterParts.join(" AND "),
|
|
35239
|
+
fields: "CERT",
|
|
35240
|
+
limit: 1e4,
|
|
35241
|
+
sort_by: "CERT",
|
|
35242
|
+
sort_order: "ASC"
|
|
35243
|
+
},
|
|
35244
|
+
{ signal: controller.signal }
|
|
35245
|
+
);
|
|
35246
|
+
targetCerts = extractRecords(rosterResp).map((r) => asNumber(r.CERT)).filter((c) => c !== null);
|
|
35247
|
+
}
|
|
35248
|
+
if (targetCerts.length === 0) {
|
|
35249
|
+
return formatToolError(new Error("No institutions matched the specified criteria."));
|
|
35250
|
+
}
|
|
35251
|
+
await sendProgressNotification(server.server, progressToken, 0.3, `Fetching financials for ${targetCerts.length} institutions`);
|
|
35252
|
+
const certFilters = buildCertFilters(targetCerts);
|
|
35253
|
+
const currentResponses = await mapWithConcurrency(
|
|
35254
|
+
certFilters,
|
|
35255
|
+
MAX_CONCURRENCY,
|
|
35256
|
+
async (certFilter) => queryEndpoint(
|
|
35257
|
+
ENDPOINTS.FINANCIALS,
|
|
35258
|
+
{
|
|
35259
|
+
filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
|
|
35260
|
+
fields: CAMELS_FIELDS,
|
|
35261
|
+
limit: 1e4,
|
|
35262
|
+
sort_by: "CERT",
|
|
35263
|
+
sort_order: "ASC"
|
|
35264
|
+
},
|
|
35265
|
+
{ signal: controller.signal }
|
|
35266
|
+
)
|
|
35267
|
+
);
|
|
35268
|
+
const allCurrentFinancials = currentResponses.flatMap(extractRecords);
|
|
35269
|
+
const priorDates = getPriorQuarterDates2(params.repdte, params.quarters);
|
|
35270
|
+
let priorByInstitution = /* @__PURE__ */ new Map();
|
|
35271
|
+
if (priorDates.length > 0) {
|
|
35272
|
+
await sendProgressNotification(server.server, progressToken, 0.5, "Fetching prior quarters for trends");
|
|
35273
|
+
const dateFilter = priorDates.map((d) => `REPDTE:${d}`).join(" OR ");
|
|
35274
|
+
const priorResponses = await mapWithConcurrency(
|
|
35275
|
+
certFilters,
|
|
35276
|
+
MAX_CONCURRENCY,
|
|
35277
|
+
async (certFilter) => queryEndpoint(
|
|
35278
|
+
ENDPOINTS.FINANCIALS,
|
|
35279
|
+
{
|
|
35280
|
+
filters: `(${certFilter}) AND (${dateFilter})`,
|
|
35281
|
+
fields: CAMELS_FIELDS,
|
|
35282
|
+
limit: 1e4,
|
|
35283
|
+
sort_by: "REPDTE",
|
|
35284
|
+
sort_order: "DESC"
|
|
35285
|
+
},
|
|
35286
|
+
{ signal: controller.signal }
|
|
35287
|
+
)
|
|
35288
|
+
);
|
|
35289
|
+
for (const rec of priorResponses.flatMap(extractRecords)) {
|
|
35290
|
+
const cert = asNumber(rec.CERT);
|
|
35291
|
+
if (cert === null) continue;
|
|
35292
|
+
const existing = priorByInstitution.get(cert) ?? [];
|
|
35293
|
+
existing.push(rec);
|
|
35294
|
+
priorByInstitution.set(cert, existing);
|
|
35295
|
+
}
|
|
35296
|
+
}
|
|
35297
|
+
const profileResp = await queryEndpoint(
|
|
35298
|
+
ENDPOINTS.INSTITUTIONS,
|
|
35299
|
+
{
|
|
35300
|
+
filters: targetCerts.length <= 25 ? targetCerts.map((c) => `CERT:${c}`).join(" OR ") : `CERT:[${Math.min(...targetCerts)} TO ${Math.max(...targetCerts)}]`,
|
|
35301
|
+
fields: "CERT,NAME,CITY,STALP",
|
|
35302
|
+
limit: 1e4,
|
|
35303
|
+
sort_by: "CERT",
|
|
35304
|
+
sort_order: "ASC"
|
|
35305
|
+
},
|
|
35306
|
+
{ signal: controller.signal }
|
|
35307
|
+
);
|
|
35308
|
+
const profileMap = /* @__PURE__ */ new Map();
|
|
35309
|
+
for (const r of extractRecords(profileResp)) {
|
|
35310
|
+
const c = asNumber(r.CERT);
|
|
35311
|
+
if (c !== null) profileMap.set(c, r);
|
|
35312
|
+
}
|
|
35313
|
+
await sendProgressNotification(server.server, progressToken, 0.7, "Analyzing risk signals");
|
|
35314
|
+
const minSeverityOrder = SEVERITY_ORDER[params.min_severity];
|
|
35315
|
+
const results = [];
|
|
35316
|
+
for (const fin of allCurrentFinancials) {
|
|
35317
|
+
const cert = asNumber(fin.CERT);
|
|
35318
|
+
if (cert === null) continue;
|
|
35319
|
+
const priorQuarters = priorByInstitution.get(cert) ?? [];
|
|
35320
|
+
const metrics = computeCamelsMetrics(fin, priorQuarters);
|
|
35321
|
+
const allQuarters = [fin, ...priorQuarters];
|
|
35322
|
+
const trends = [];
|
|
35323
|
+
for (const tm of TREND_METRICS2) {
|
|
35324
|
+
const timeseries = allQuarters.map((q) => ({
|
|
35325
|
+
repdte: String(q.REPDTE ?? ""),
|
|
35326
|
+
value: typeof q[tm.fdic_field] === "number" ? q[tm.fdic_field] : null
|
|
35327
|
+
}));
|
|
35328
|
+
timeseries.reverse();
|
|
35329
|
+
trends.push(analyzeTrend(String(tm.key), timeseries, tm.higher_is_better));
|
|
35330
|
+
}
|
|
35331
|
+
const allSignals = classifyRiskSignals(metrics, trends);
|
|
35332
|
+
const filteredSignals = allSignals.filter((s) => SEVERITY_ORDER[s.severity] <= minSeverityOrder);
|
|
35333
|
+
if (filteredSignals.length === 0) continue;
|
|
35334
|
+
const components = ["C", "A", "E", "L", "S"].map((c) => scoreComponent(c, metrics));
|
|
35335
|
+
const comp = compositeScore(components);
|
|
35336
|
+
const profile = profileMap.get(cert);
|
|
35337
|
+
results.push({
|
|
35338
|
+
cert,
|
|
35339
|
+
name: String(profile?.NAME ?? `CERT ${cert}`),
|
|
35340
|
+
city: profile?.CITY ? String(profile.CITY) : null,
|
|
35341
|
+
state: profile?.STALP ? String(profile.STALP) : null,
|
|
35342
|
+
total_assets: asNumber(fin.ASSET),
|
|
35343
|
+
composite_rating: comp.rating,
|
|
35344
|
+
composite_label: comp.label,
|
|
35345
|
+
signals: filteredSignals,
|
|
35346
|
+
critical_count: filteredSignals.filter((s) => s.severity === "critical").length,
|
|
35347
|
+
warning_count: filteredSignals.filter((s) => s.severity === "warning").length
|
|
35348
|
+
});
|
|
35349
|
+
}
|
|
35350
|
+
results.sort((a, b) => {
|
|
35351
|
+
if (a.critical_count !== b.critical_count) return b.critical_count - a.critical_count;
|
|
35352
|
+
if (a.warning_count !== b.warning_count) return b.warning_count - a.warning_count;
|
|
35353
|
+
return b.composite_rating - a.composite_rating;
|
|
35354
|
+
});
|
|
35355
|
+
const returned = results.slice(0, params.limit);
|
|
35356
|
+
await sendProgressNotification(server.server, progressToken, 0.9, "Formatting results");
|
|
35357
|
+
const parts = [];
|
|
35358
|
+
parts.push(`Risk Signal Scan \u2014 ${results.length} flagged of ${allCurrentFinancials.length} institutions scanned`);
|
|
35359
|
+
parts.push(`Report Date: ${params.repdte} | Min Severity: ${params.min_severity}`);
|
|
35360
|
+
parts.push("NOTE: Analytical screening tool, not official supervisory ratings.");
|
|
35361
|
+
parts.push("");
|
|
35362
|
+
if (returned.length === 0) {
|
|
35363
|
+
parts.push("No institutions flagged at the specified severity level.");
|
|
35364
|
+
}
|
|
35365
|
+
for (let i = 0; i < returned.length; i++) {
|
|
35366
|
+
const r = returned[i];
|
|
35367
|
+
const location = [r.city, r.state].filter(Boolean).join(", ");
|
|
35368
|
+
const assetStr = r.total_assets !== null ? `$${Math.round(r.total_assets).toLocaleString()}k` : "n/a";
|
|
35369
|
+
parts.push(`${i + 1}. ${r.name} (${location}) CERT ${r.cert} | Assets: ${assetStr}`);
|
|
35370
|
+
parts.push(` Composite: ${formatRating(r.composite_rating)} | Critical: ${r.critical_count} | Warnings: ${r.warning_count}`);
|
|
35371
|
+
for (const s of r.signals) {
|
|
35372
|
+
const icon = s.severity === "critical" ? "\u{1F534}" : s.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
|
|
35373
|
+
parts.push(` ${icon} [${s.severity}] ${s.message}`);
|
|
35374
|
+
}
|
|
35375
|
+
parts.push("");
|
|
35376
|
+
}
|
|
35377
|
+
if (results.length > returned.length) {
|
|
35378
|
+
parts.push(`Showing ${returned.length} of ${results.length} flagged institutions. Increase limit to see more.`);
|
|
35379
|
+
}
|
|
35380
|
+
const text = truncateIfNeeded(parts.join("\n"), CHARACTER_LIMIT);
|
|
35381
|
+
return {
|
|
35382
|
+
content: [{ type: "text", text }],
|
|
35383
|
+
structuredContent: {
|
|
35384
|
+
report_date: params.repdte,
|
|
35385
|
+
min_severity: params.min_severity,
|
|
35386
|
+
institutions_scanned: allCurrentFinancials.length,
|
|
35387
|
+
institutions_flagged: results.length,
|
|
35388
|
+
returned_count: returned.length,
|
|
35389
|
+
institutions: returned
|
|
35390
|
+
}
|
|
35391
|
+
};
|
|
35392
|
+
} catch (err) {
|
|
35393
|
+
return formatToolError(err);
|
|
35394
|
+
} finally {
|
|
35395
|
+
clearTimeout(timeoutId);
|
|
35396
|
+
}
|
|
35397
|
+
}
|
|
35398
|
+
);
|
|
35399
|
+
}
|
|
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
|
+
|
|
34354
37361
|
// src/resources/schemaResources.ts
|
|
34355
37362
|
var RESOURCE_SCHEME = "fdic";
|
|
34356
37363
|
var INDEX_URI = `${RESOURCE_SCHEME}://schemas/index`;
|
|
@@ -34436,6 +37443,17 @@ function createServer() {
|
|
|
34436
37443
|
registerDemographicsTools(server);
|
|
34437
37444
|
registerAnalysisTools(server);
|
|
34438
37445
|
registerPeerGroupTools(server);
|
|
37446
|
+
registerBankHealthTools(server);
|
|
37447
|
+
registerPeerHealthTools(server);
|
|
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);
|
|
34439
37457
|
registerSchemaResources(server);
|
|
34440
37458
|
return server;
|
|
34441
37459
|
}
|