fdic-mcp-server 1.10.2 → 1.12.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 +1051 -1
- package/dist/server.js +1051 -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.12.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,1053 @@ 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
|
+
|
|
34354
35401
|
// src/resources/schemaResources.ts
|
|
34355
35402
|
var RESOURCE_SCHEME = "fdic";
|
|
34356
35403
|
var INDEX_URI = `${RESOURCE_SCHEME}://schemas/index`;
|
|
@@ -34436,6 +35483,9 @@ function createServer() {
|
|
|
34436
35483
|
registerDemographicsTools(server);
|
|
34437
35484
|
registerAnalysisTools(server);
|
|
34438
35485
|
registerPeerGroupTools(server);
|
|
35486
|
+
registerBankHealthTools(server);
|
|
35487
|
+
registerPeerHealthTools(server);
|
|
35488
|
+
registerRiskSignalTools(server);
|
|
34439
35489
|
registerSchemaResources(server);
|
|
34440
35490
|
return server;
|
|
34441
35491
|
}
|