fdic-mcp-server 1.0.7 → 1.1.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/README.md +144 -10
- package/dist/index.js +690 -31
- package/dist/server.js +690 -31
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -30,7 +30,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
|
|
|
30
30
|
var import_express = __toESM(require("express"));
|
|
31
31
|
|
|
32
32
|
// src/constants.ts
|
|
33
|
-
var VERSION = true ? "1.0
|
|
33
|
+
var VERSION = true ? "1.1.0" : process.env.npm_package_version ?? "0.0.0-dev";
|
|
34
34
|
var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
|
|
35
35
|
var CHARACTER_LIMIT = 5e4;
|
|
36
36
|
var ENDPOINTS = {
|
|
@@ -56,6 +56,13 @@ var apiClient = import_axios.default.create({
|
|
|
56
56
|
});
|
|
57
57
|
var QUERY_CACHE_TTL_MS = 6e4;
|
|
58
58
|
var queryCache = /* @__PURE__ */ new Map();
|
|
59
|
+
function pruneExpiredQueryCache(now) {
|
|
60
|
+
for (const [key, entry] of queryCache.entries()) {
|
|
61
|
+
if (entry.expiresAt <= now) {
|
|
62
|
+
queryCache.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
59
66
|
function getCacheKey(endpoint, params) {
|
|
60
67
|
return JSON.stringify([
|
|
61
68
|
endpoint,
|
|
@@ -67,10 +74,15 @@ function getCacheKey(endpoint, params) {
|
|
|
67
74
|
params.sort_order ?? null
|
|
68
75
|
]);
|
|
69
76
|
}
|
|
70
|
-
async function queryEndpoint(endpoint, params) {
|
|
71
|
-
|
|
77
|
+
async function queryEndpoint(endpoint, params, options = {}) {
|
|
78
|
+
if (options.signal?.aborted) {
|
|
79
|
+
throw new Error("FDIC API request was canceled before it started.");
|
|
80
|
+
}
|
|
81
|
+
const shouldUseCache = !options.signal;
|
|
72
82
|
const now = Date.now();
|
|
73
|
-
|
|
83
|
+
pruneExpiredQueryCache(now);
|
|
84
|
+
const cacheKey = getCacheKey(endpoint, params);
|
|
85
|
+
const cached = shouldUseCache ? queryCache.get(cacheKey) : void 0;
|
|
74
86
|
if (cached && cached.expiresAt > now) {
|
|
75
87
|
return cached.value;
|
|
76
88
|
}
|
|
@@ -86,10 +98,14 @@ async function queryEndpoint(endpoint, params) {
|
|
|
86
98
|
if (params.sort_by) queryParams.sort_by = params.sort_by;
|
|
87
99
|
if (params.sort_order) queryParams.sort_order = params.sort_order;
|
|
88
100
|
const response = await apiClient.get(`/${endpoint}`, {
|
|
89
|
-
params: queryParams
|
|
101
|
+
params: queryParams,
|
|
102
|
+
signal: options.signal
|
|
90
103
|
});
|
|
91
104
|
return response.data;
|
|
92
105
|
} catch (err) {
|
|
106
|
+
if (options.signal?.aborted || typeof err === "object" && err !== null && "code" in err && err.code === "ERR_CANCELED" || err instanceof DOMException && err.name === "AbortError") {
|
|
107
|
+
throw new Error("FDIC API request was canceled.");
|
|
108
|
+
}
|
|
93
109
|
if (err instanceof import_axios.AxiosError) {
|
|
94
110
|
const status = err.response?.status;
|
|
95
111
|
const detail = err.response?.data?.message ?? err.message;
|
|
@@ -112,14 +128,18 @@ async function queryEndpoint(endpoint, params) {
|
|
|
112
128
|
throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
|
|
113
129
|
}
|
|
114
130
|
})();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
if (shouldUseCache) {
|
|
132
|
+
queryCache.set(cacheKey, {
|
|
133
|
+
expiresAt: now + QUERY_CACHE_TTL_MS,
|
|
134
|
+
value: requestPromise
|
|
135
|
+
});
|
|
136
|
+
}
|
|
119
137
|
try {
|
|
120
138
|
return await requestPromise;
|
|
121
139
|
} catch (error) {
|
|
122
|
-
|
|
140
|
+
if (shouldUseCache) {
|
|
141
|
+
queryCache.delete(cacheKey);
|
|
142
|
+
}
|
|
123
143
|
throw error;
|
|
124
144
|
}
|
|
125
145
|
}
|
|
@@ -1055,6 +1075,7 @@ Prefer concise human-readable summaries or tables when answering users. Structur
|
|
|
1055
1075
|
var import_zod7 = require("zod");
|
|
1056
1076
|
var CHUNK_SIZE = 25;
|
|
1057
1077
|
var MAX_CONCURRENCY = 4;
|
|
1078
|
+
var ANALYSIS_TIMEOUT_MS = 9e4;
|
|
1058
1079
|
var SortFieldSchema = import_zod7.z.enum([
|
|
1059
1080
|
"asset_growth",
|
|
1060
1081
|
"asset_growth_pct",
|
|
@@ -1144,14 +1165,31 @@ function change(start, end) {
|
|
|
1144
1165
|
if (start === null || end === null) return null;
|
|
1145
1166
|
return end - start;
|
|
1146
1167
|
}
|
|
1168
|
+
function getQuarterIndex(repdte) {
|
|
1169
|
+
const year = Number.parseInt(repdte.slice(0, 4), 10);
|
|
1170
|
+
const month = Number.parseInt(repdte.slice(4, 6), 10);
|
|
1171
|
+
const quarter = month / 3;
|
|
1172
|
+
if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
return year * 4 + quarter;
|
|
1176
|
+
}
|
|
1147
1177
|
function yearsBetween(startRepdte, endRepdte) {
|
|
1178
|
+
const startQuarterIndex = getQuarterIndex(startRepdte);
|
|
1179
|
+
const endQuarterIndex = getQuarterIndex(endRepdte);
|
|
1180
|
+
if (startQuarterIndex !== null && endQuarterIndex !== null) {
|
|
1181
|
+
return Math.max((endQuarterIndex - startQuarterIndex) / 4, 0);
|
|
1182
|
+
}
|
|
1148
1183
|
const start = /* @__PURE__ */ new Date(
|
|
1149
|
-
`${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
|
|
1184
|
+
`${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}T00:00:00Z`
|
|
1150
1185
|
);
|
|
1151
1186
|
const end = /* @__PURE__ */ new Date(
|
|
1152
|
-
`${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
|
|
1187
|
+
`${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}T00:00:00Z`
|
|
1188
|
+
);
|
|
1189
|
+
return Math.max(
|
|
1190
|
+
(end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3),
|
|
1191
|
+
0
|
|
1153
1192
|
);
|
|
1154
|
-
return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
|
|
1155
1193
|
}
|
|
1156
1194
|
function cagr(start, end, years) {
|
|
1157
1195
|
if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
|
|
@@ -1223,6 +1261,21 @@ function buildTopLevelInsights(comparisons) {
|
|
|
1223
1261
|
(comparison) => comparison.insights?.includes(
|
|
1224
1262
|
"growth_with_branch_consolidation"
|
|
1225
1263
|
)
|
|
1264
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1265
|
+
deposit_mix_softening: comparisons.filter(
|
|
1266
|
+
(comparison) => comparison.insights?.includes(
|
|
1267
|
+
"deposit_mix_softening"
|
|
1268
|
+
)
|
|
1269
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1270
|
+
sustained_asset_growth: comparisons.filter(
|
|
1271
|
+
(comparison) => comparison.insights?.includes(
|
|
1272
|
+
"sustained_asset_growth"
|
|
1273
|
+
)
|
|
1274
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1275
|
+
multi_quarter_roa_decline: comparisons.filter(
|
|
1276
|
+
(comparison) => comparison.insights?.includes(
|
|
1277
|
+
"multi_quarter_roa_decline"
|
|
1278
|
+
)
|
|
1226
1279
|
).slice(0, 5).map((comparison) => String(comparison.name))
|
|
1227
1280
|
};
|
|
1228
1281
|
}
|
|
@@ -1378,7 +1431,7 @@ Insights
|
|
|
1378
1431
|
${insights}` : `${header}
|
|
1379
1432
|
${rows.join("\n")}`;
|
|
1380
1433
|
}
|
|
1381
|
-
async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
|
|
1434
|
+
async function fetchInstitutionRoster(state, institutionFilters, activeOnly, signal) {
|
|
1382
1435
|
const filterParts = [];
|
|
1383
1436
|
if (state) filterParts.push(`STNAME:"${state}"`);
|
|
1384
1437
|
if (activeOnly) filterParts.push("ACTIVE:1");
|
|
@@ -1390,10 +1443,12 @@ async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
|
|
|
1390
1443
|
offset: 0,
|
|
1391
1444
|
sort_by: "CERT",
|
|
1392
1445
|
sort_order: "ASC"
|
|
1393
|
-
});
|
|
1394
|
-
|
|
1446
|
+
}, { signal });
|
|
1447
|
+
const records = extractRecords(response);
|
|
1448
|
+
const warning = response.meta.total > records.length ? `Institution roster truncated to ${records.length.toLocaleString()} records out of ${response.meta.total.toLocaleString()} matched institutions. Narrow the comparison set with institution_filters or certs for complete analysis.` : void 0;
|
|
1449
|
+
return { records, warning };
|
|
1395
1450
|
}
|
|
1396
|
-
async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
|
|
1451
|
+
async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields, signal) {
|
|
1397
1452
|
const certFilters = buildCertFilters(certs);
|
|
1398
1453
|
const tasks = repdteFilters.flatMap(
|
|
1399
1454
|
(repdteFilter) => certFilters.map((certFilter) => ({
|
|
@@ -1409,7 +1464,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
|
|
|
1409
1464
|
offset: 0,
|
|
1410
1465
|
sort_by: "CERT",
|
|
1411
1466
|
sort_order: "ASC"
|
|
1412
|
-
});
|
|
1467
|
+
}, { signal });
|
|
1413
1468
|
return { repdteFilter: task.repdteFilter, response };
|
|
1414
1469
|
});
|
|
1415
1470
|
const byDate = /* @__PURE__ */ new Map();
|
|
@@ -1425,7 +1480,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
|
|
|
1425
1480
|
}
|
|
1426
1481
|
return byDate;
|
|
1427
1482
|
}
|
|
1428
|
-
async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
|
|
1483
|
+
async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields, signal) {
|
|
1429
1484
|
const certFilters = buildCertFilters(certs);
|
|
1430
1485
|
const responses = await mapWithConcurrency(
|
|
1431
1486
|
certFilters,
|
|
@@ -1437,7 +1492,7 @@ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, field
|
|
|
1437
1492
|
offset: 0,
|
|
1438
1493
|
sort_by: "REPDTE",
|
|
1439
1494
|
sort_order: "ASC"
|
|
1440
|
-
})
|
|
1495
|
+
}, { signal })
|
|
1441
1496
|
);
|
|
1442
1497
|
const grouped = /* @__PURE__ */ new Map();
|
|
1443
1498
|
for (const response of responses) {
|
|
@@ -1567,12 +1622,19 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1567
1622
|
sort_by,
|
|
1568
1623
|
sort_order
|
|
1569
1624
|
}) => {
|
|
1625
|
+
const controller = new AbortController();
|
|
1626
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
1570
1627
|
try {
|
|
1571
|
-
const
|
|
1628
|
+
const rosterResult = certs && certs.length > 0 ? {
|
|
1629
|
+
records: certs.map((cert) => ({ CERT: cert })),
|
|
1630
|
+
warning: void 0
|
|
1631
|
+
} : await fetchInstitutionRoster(
|
|
1572
1632
|
state,
|
|
1573
1633
|
institution_filters,
|
|
1574
|
-
active_only
|
|
1634
|
+
active_only,
|
|
1635
|
+
controller.signal
|
|
1575
1636
|
);
|
|
1637
|
+
const roster = rosterResult.records;
|
|
1576
1638
|
const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
|
|
1577
1639
|
if (candidateCerts.length === 0) {
|
|
1578
1640
|
const output2 = {
|
|
@@ -1605,14 +1667,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1605
1667
|
candidateCerts,
|
|
1606
1668
|
start_repdte,
|
|
1607
1669
|
end_repdte,
|
|
1608
|
-
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1670
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
|
|
1671
|
+
controller.signal
|
|
1609
1672
|
),
|
|
1610
1673
|
include_demographics ? fetchSeriesRecords(
|
|
1611
1674
|
ENDPOINTS.DEMOGRAPHICS,
|
|
1612
1675
|
candidateCerts,
|
|
1613
1676
|
start_repdte,
|
|
1614
1677
|
end_repdte,
|
|
1615
|
-
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1678
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
|
|
1679
|
+
controller.signal
|
|
1616
1680
|
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1617
1681
|
]);
|
|
1618
1682
|
comparisons = candidateCerts.map(
|
|
@@ -1633,13 +1697,15 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1633
1697
|
ENDPOINTS.FINANCIALS,
|
|
1634
1698
|
candidateCerts,
|
|
1635
1699
|
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1636
|
-
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1700
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
|
|
1701
|
+
controller.signal
|
|
1637
1702
|
),
|
|
1638
1703
|
include_demographics ? fetchBatchedRecordsForDates(
|
|
1639
1704
|
ENDPOINTS.DEMOGRAPHICS,
|
|
1640
1705
|
candidateCerts,
|
|
1641
1706
|
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1642
|
-
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1707
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
|
|
1708
|
+
controller.signal
|
|
1643
1709
|
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1644
1710
|
]);
|
|
1645
1711
|
const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
@@ -1662,10 +1728,12 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1662
1728
|
);
|
|
1663
1729
|
}).filter((comparison) => comparison !== null);
|
|
1664
1730
|
}
|
|
1665
|
-
const
|
|
1666
|
-
|
|
1667
|
-
|
|
1731
|
+
const sortedComparisons = sortComparisons(
|
|
1732
|
+
comparisons,
|
|
1733
|
+
sort_by,
|
|
1734
|
+
sort_order
|
|
1668
1735
|
);
|
|
1736
|
+
const ranked = sortedComparisons.slice(0, limit);
|
|
1669
1737
|
const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
|
|
1670
1738
|
const output = {
|
|
1671
1739
|
total_candidates: candidateCerts.length,
|
|
@@ -1675,12 +1743,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1675
1743
|
analysis_mode,
|
|
1676
1744
|
sort_by,
|
|
1677
1745
|
sort_order,
|
|
1678
|
-
|
|
1746
|
+
warnings: rosterResult.warning ? [rosterResult.warning] : [],
|
|
1747
|
+
insights: buildTopLevelInsights(sortedComparisons),
|
|
1679
1748
|
...pagination,
|
|
1680
1749
|
comparisons: ranked
|
|
1681
1750
|
};
|
|
1682
1751
|
const text = truncateIfNeeded(
|
|
1683
|
-
|
|
1752
|
+
[
|
|
1753
|
+
rosterResult.warning ? `Warning: ${rosterResult.warning}` : null,
|
|
1754
|
+
formatComparisonText(output)
|
|
1755
|
+
].filter((value) => value !== null).join("\n\n"),
|
|
1684
1756
|
CHARACTER_LIMIT
|
|
1685
1757
|
);
|
|
1686
1758
|
return {
|
|
@@ -1688,7 +1760,593 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1688
1760
|
structuredContent: output
|
|
1689
1761
|
};
|
|
1690
1762
|
} catch (err) {
|
|
1763
|
+
if (controller.signal.aborted) {
|
|
1764
|
+
return formatToolError(
|
|
1765
|
+
new Error(
|
|
1766
|
+
`Analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the comparison set with certs or institution_filters and try again.`
|
|
1767
|
+
)
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
return formatToolError(err);
|
|
1771
|
+
} finally {
|
|
1772
|
+
clearTimeout(timeoutId);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// src/tools/peerGroup.ts
|
|
1779
|
+
var import_zod8 = require("zod");
|
|
1780
|
+
function asNumber2(value) {
|
|
1781
|
+
return typeof value === "number" ? value : null;
|
|
1782
|
+
}
|
|
1783
|
+
function safeRatio(numerator, denominator) {
|
|
1784
|
+
if (numerator === null || denominator === null || denominator === 0) {
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
return numerator / denominator;
|
|
1788
|
+
}
|
|
1789
|
+
function safeRatioPositiveDenom(numerator, denominator) {
|
|
1790
|
+
if (numerator === null || denominator === null || denominator <= 0) {
|
|
1791
|
+
return null;
|
|
1792
|
+
}
|
|
1793
|
+
return numerator / denominator;
|
|
1794
|
+
}
|
|
1795
|
+
function deriveMetrics(raw) {
|
|
1796
|
+
const asset = asNumber2(raw.ASSET);
|
|
1797
|
+
const dep = asNumber2(raw.DEP);
|
|
1798
|
+
const eqtot = asNumber2(raw.EQTOT);
|
|
1799
|
+
const lnlsnet = asNumber2(raw.LNLSNET);
|
|
1800
|
+
const intinc = asNumber2(raw.INTINC);
|
|
1801
|
+
const eintexp = asNumber2(raw.EINTEXP);
|
|
1802
|
+
const nonii = asNumber2(raw.NONII);
|
|
1803
|
+
const nonix = asNumber2(raw.NONIX);
|
|
1804
|
+
const netInterestIncome = intinc !== null && eintexp !== null ? intinc - eintexp : null;
|
|
1805
|
+
const revenueDenominator = netInterestIncome !== null && nonii !== null ? netInterestIncome + nonii : null;
|
|
1806
|
+
const equityRatioRaw = safeRatio(eqtot, asset);
|
|
1807
|
+
const efficiencyRatioRaw = safeRatioPositiveDenom(nonix, revenueDenominator);
|
|
1808
|
+
return {
|
|
1809
|
+
asset,
|
|
1810
|
+
dep,
|
|
1811
|
+
roa: asNumber2(raw.ROA),
|
|
1812
|
+
roe: asNumber2(raw.ROE),
|
|
1813
|
+
netnim: asNumber2(raw.NETNIM),
|
|
1814
|
+
equity_ratio: equityRatioRaw !== null ? equityRatioRaw * 100 : null,
|
|
1815
|
+
efficiency_ratio: efficiencyRatioRaw !== null ? efficiencyRatioRaw * 100 : null,
|
|
1816
|
+
loan_to_deposit: safeRatio(lnlsnet, dep),
|
|
1817
|
+
deposits_to_assets: safeRatio(dep, asset),
|
|
1818
|
+
noninterest_income_share: safeRatioPositiveDenom(nonii, revenueDenominator)
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
function computeMedian(values) {
|
|
1822
|
+
if (values.length === 0) return null;
|
|
1823
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1824
|
+
const mid = Math.floor(sorted.length / 2);
|
|
1825
|
+
if (sorted.length % 2 === 1) return sorted[mid];
|
|
1826
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
1827
|
+
}
|
|
1828
|
+
function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
|
|
1829
|
+
if (peerValues.length === 0) return null;
|
|
1830
|
+
const ascending = higherIsBetter === false;
|
|
1831
|
+
const all = [...peerValues, subjectValue];
|
|
1832
|
+
const sorted = [...all].sort(
|
|
1833
|
+
(a, b) => ascending ? a - b : b - a
|
|
1834
|
+
);
|
|
1835
|
+
const ranks = /* @__PURE__ */ new Map();
|
|
1836
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
1837
|
+
if (!ranks.has(sorted[i])) {
|
|
1838
|
+
ranks.set(sorted[i], i + 1);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const rank = ranks.get(subjectValue);
|
|
1842
|
+
const of = peerValues.length;
|
|
1843
|
+
const percentile = Math.round((1 - (rank - 1) / of) * 100);
|
|
1844
|
+
return { rank, of, percentile };
|
|
1845
|
+
}
|
|
1846
|
+
function formatRepdteHuman(repdte) {
|
|
1847
|
+
if (repdte.length !== 8) return repdte;
|
|
1848
|
+
const year = repdte.slice(0, 4);
|
|
1849
|
+
const month = repdte.slice(4, 6);
|
|
1850
|
+
const day = repdte.slice(6, 8);
|
|
1851
|
+
const date = /* @__PURE__ */ new Date(`${year}-${month}-${day}T00:00:00Z`);
|
|
1852
|
+
if (Number.isNaN(date.getTime())) return repdte;
|
|
1853
|
+
return date.toLocaleDateString("en-US", {
|
|
1854
|
+
year: "numeric",
|
|
1855
|
+
month: "long",
|
|
1856
|
+
day: "numeric",
|
|
1857
|
+
timeZone: "UTC"
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
var METRIC_KEYS = [
|
|
1861
|
+
"asset",
|
|
1862
|
+
"dep",
|
|
1863
|
+
"roa",
|
|
1864
|
+
"roe",
|
|
1865
|
+
"netnim",
|
|
1866
|
+
"equity_ratio",
|
|
1867
|
+
"efficiency_ratio",
|
|
1868
|
+
"loan_to_deposit",
|
|
1869
|
+
"deposits_to_assets",
|
|
1870
|
+
"noninterest_income_share"
|
|
1871
|
+
];
|
|
1872
|
+
var METRIC_DEFINITIONS = {
|
|
1873
|
+
asset: { higher_is_better: true, unit: "$thousands", label: "Total Assets" },
|
|
1874
|
+
dep: { higher_is_better: true, unit: "$thousands", label: "Total Deposits" },
|
|
1875
|
+
roa: { higher_is_better: true, unit: "%", label: "Return on Assets" },
|
|
1876
|
+
roe: { higher_is_better: true, unit: "%", label: "Return on Equity" },
|
|
1877
|
+
netnim: {
|
|
1878
|
+
higher_is_better: true,
|
|
1879
|
+
unit: "%",
|
|
1880
|
+
label: "Net Interest Margin"
|
|
1881
|
+
},
|
|
1882
|
+
equity_ratio: {
|
|
1883
|
+
higher_is_better: true,
|
|
1884
|
+
unit: "%",
|
|
1885
|
+
label: "Equity Capital Ratio"
|
|
1886
|
+
},
|
|
1887
|
+
efficiency_ratio: {
|
|
1888
|
+
higher_is_better: false,
|
|
1889
|
+
unit: "%",
|
|
1890
|
+
label: "Efficiency Ratio"
|
|
1891
|
+
},
|
|
1892
|
+
loan_to_deposit: {
|
|
1893
|
+
higher_is_better: null,
|
|
1894
|
+
unit: "ratio",
|
|
1895
|
+
label: "Loan-to-Deposit Ratio",
|
|
1896
|
+
ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
|
|
1897
|
+
},
|
|
1898
|
+
deposits_to_assets: {
|
|
1899
|
+
higher_is_better: null,
|
|
1900
|
+
unit: "ratio",
|
|
1901
|
+
label: "Deposits-to-Assets Ratio",
|
|
1902
|
+
ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
|
|
1903
|
+
},
|
|
1904
|
+
noninterest_income_share: {
|
|
1905
|
+
higher_is_better: true,
|
|
1906
|
+
unit: "ratio",
|
|
1907
|
+
label: "Non-Interest Income Share"
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1910
|
+
var PeerGroupInputSchema = import_zod8.z.object({
|
|
1911
|
+
cert: import_zod8.z.number().int().positive().optional().describe(
|
|
1912
|
+
"Subject institution CERT number. When provided, auto-derives peer criteria and ranks this bank against peers."
|
|
1913
|
+
),
|
|
1914
|
+
repdte: import_zod8.z.string().regex(/^\d{8}$/).describe("Report date in YYYYMMDD format."),
|
|
1915
|
+
asset_min: import_zod8.z.number().positive().optional().describe(
|
|
1916
|
+
"Minimum total assets ($thousands) for peer selection. Defaults to 50% of subject's report-date assets when cert is provided."
|
|
1917
|
+
),
|
|
1918
|
+
asset_max: import_zod8.z.number().positive().optional().describe(
|
|
1919
|
+
"Maximum total assets ($thousands) for peer selection. Defaults to 200% of subject's report-date assets when cert is provided."
|
|
1920
|
+
),
|
|
1921
|
+
charter_classes: import_zod8.z.array(import_zod8.z.string()).optional().describe(
|
|
1922
|
+
`Charter class codes to include (e.g., ["N", "SM"]). Defaults to the subject's charter class when cert is provided.`
|
|
1923
|
+
),
|
|
1924
|
+
state: import_zod8.z.string().regex(/^[A-Z]{2}$/).optional().describe('Two-letter state code (e.g., "NC", "TX").'),
|
|
1925
|
+
raw_filter: import_zod8.z.string().optional().describe(
|
|
1926
|
+
"Advanced: raw ElasticSearch query string appended to peer selection criteria with AND."
|
|
1927
|
+
),
|
|
1928
|
+
active_only: import_zod8.z.boolean().default(true).describe(
|
|
1929
|
+
"Limit to institutions where ACTIVE:1 (currently operating, FDIC-insured)."
|
|
1930
|
+
),
|
|
1931
|
+
extra_fields: import_zod8.z.array(import_zod8.z.string()).optional().describe(
|
|
1932
|
+
"Additional FDIC field names to include as raw values in the response. Does not affect peer selection."
|
|
1933
|
+
),
|
|
1934
|
+
limit: import_zod8.z.number().int().min(1).max(500).default(50).describe(
|
|
1935
|
+
"Max peer records returned in the response. All matched peers are used for ranking regardless of this limit."
|
|
1936
|
+
)
|
|
1937
|
+
}).superRefine((value, ctx) => {
|
|
1938
|
+
if (!value.cert && value.asset_min === void 0 && value.asset_max === void 0 && !value.charter_classes && !value.state && !value.raw_filter) {
|
|
1939
|
+
ctx.addIssue({
|
|
1940
|
+
code: import_zod8.z.ZodIssueCode.custom,
|
|
1941
|
+
message: "At least one peer-group constructor is required: cert, asset_min, asset_max, charter_classes, state, or raw_filter.",
|
|
1942
|
+
path: ["cert"]
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
if (value.asset_min !== void 0 && value.asset_max !== void 0 && value.asset_min > value.asset_max) {
|
|
1946
|
+
ctx.addIssue({
|
|
1947
|
+
code: import_zod8.z.ZodIssueCode.custom,
|
|
1948
|
+
message: "asset_min must be <= asset_max.",
|
|
1949
|
+
path: ["asset_min"]
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
var CHUNK_SIZE2 = 25;
|
|
1954
|
+
var MAX_CONCURRENCY2 = 4;
|
|
1955
|
+
var ANALYSIS_TIMEOUT_MS2 = 9e4;
|
|
1956
|
+
var FINANCIAL_FIELDS = "CERT,ASSET,DEP,NETINC,ROA,ROE,NETNIM,EQTOT,LNLSNET,INTINC,EINTEXP,NONII,NONIX";
|
|
1957
|
+
function buildCertFilters2(certs) {
|
|
1958
|
+
const filters = [];
|
|
1959
|
+
for (let i = 0; i < certs.length; i += CHUNK_SIZE2) {
|
|
1960
|
+
const chunk = certs.slice(i, i + CHUNK_SIZE2);
|
|
1961
|
+
filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
|
|
1962
|
+
}
|
|
1963
|
+
return filters;
|
|
1964
|
+
}
|
|
1965
|
+
async function mapWithConcurrency2(values, limit, mapper) {
|
|
1966
|
+
const results = new Array(values.length);
|
|
1967
|
+
let nextIndex = 0;
|
|
1968
|
+
async function worker() {
|
|
1969
|
+
while (true) {
|
|
1970
|
+
const currentIndex = nextIndex;
|
|
1971
|
+
nextIndex += 1;
|
|
1972
|
+
if (currentIndex >= values.length) return;
|
|
1973
|
+
results[currentIndex] = await mapper(values[currentIndex], currentIndex);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
await Promise.all(
|
|
1977
|
+
Array.from({ length: Math.min(limit, values.length) }, () => worker())
|
|
1978
|
+
);
|
|
1979
|
+
return results;
|
|
1980
|
+
}
|
|
1981
|
+
function formatMetricValue(key, value) {
|
|
1982
|
+
if (value === null) return "n/a";
|
|
1983
|
+
const def = METRIC_DEFINITIONS[key];
|
|
1984
|
+
if (def.unit === "$thousands")
|
|
1985
|
+
return `$${Math.round(value).toLocaleString()}k`;
|
|
1986
|
+
if (def.unit === "%") return `${value.toFixed(4)}%`;
|
|
1987
|
+
return value.toFixed(4);
|
|
1988
|
+
}
|
|
1989
|
+
function ordinalSuffix(n) {
|
|
1990
|
+
const mod100 = n % 100;
|
|
1991
|
+
if (mod100 >= 11 && mod100 <= 13) return `${n}th`;
|
|
1992
|
+
const mod10 = n % 10;
|
|
1993
|
+
if (mod10 === 1) return `${n}st`;
|
|
1994
|
+
if (mod10 === 2) return `${n}nd`;
|
|
1995
|
+
if (mod10 === 3) return `${n}rd`;
|
|
1996
|
+
return `${n}th`;
|
|
1997
|
+
}
|
|
1998
|
+
function formatPeerGroupText(repdte, subjectProfile, subjectMetrics, rankings, medians, returnedPeers, peerCount, warnings) {
|
|
1999
|
+
const parts = [];
|
|
2000
|
+
for (const warning of warnings) {
|
|
2001
|
+
parts.push(`Warning: ${warning}`);
|
|
2002
|
+
}
|
|
2003
|
+
const dateStr = formatRepdteHuman(repdte);
|
|
2004
|
+
const subjectLabel = subjectProfile ? ` for ${subjectProfile.NAME} (CERT ${subjectProfile.CERT ?? ""})` : "";
|
|
2005
|
+
parts.push(`Peer group analysis${subjectLabel} as of ${dateStr}.`);
|
|
2006
|
+
parts.push(`${peerCount} peers matched.`);
|
|
2007
|
+
if (subjectMetrics && subjectProfile) {
|
|
2008
|
+
parts.push("");
|
|
2009
|
+
parts.push("Subject rankings:");
|
|
2010
|
+
for (const key of METRIC_KEYS) {
|
|
2011
|
+
const def = METRIC_DEFINITIONS[key];
|
|
2012
|
+
const ranking = rankings[key];
|
|
2013
|
+
const value = formatMetricValue(key, subjectMetrics[key]);
|
|
2014
|
+
const medianValue = formatMetricValue(key, medians[key] ?? null);
|
|
2015
|
+
if (ranking) {
|
|
2016
|
+
const pctLabel = `${ordinalSuffix(ranking.percentile)} percentile`;
|
|
2017
|
+
parts.push(
|
|
2018
|
+
` ${def.label.padEnd(28)} rank ${String(ranking.rank).padStart(2)} of ${String(ranking.of).padEnd(4)} (${pctLabel.padEnd(18)}) ${value.padStart(16)} median: ${medianValue}`
|
|
2019
|
+
);
|
|
2020
|
+
} else {
|
|
2021
|
+
parts.push(` ${def.label.padEnd(28)} n/a`);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
} else if (peerCount > 0) {
|
|
2025
|
+
parts.push("");
|
|
2026
|
+
parts.push("Peer group medians:");
|
|
2027
|
+
const medianParts = METRIC_KEYS.filter((k) => medians[k] !== null).map(
|
|
2028
|
+
(k) => `${METRIC_DEFINITIONS[k].label}: ${formatMetricValue(k, medians[k] ?? null)}`
|
|
2029
|
+
);
|
|
2030
|
+
parts.push(` ${medianParts.join(" | ")}`);
|
|
2031
|
+
}
|
|
2032
|
+
if (returnedPeers.length > 0) {
|
|
2033
|
+
parts.push("");
|
|
2034
|
+
parts.push(`Peers (${returnedPeers.length} returned):`);
|
|
2035
|
+
for (let i = 0; i < returnedPeers.length; i++) {
|
|
2036
|
+
const p = returnedPeers[i];
|
|
2037
|
+
const location = [p.city, p.stalp].filter(Boolean).join(" ");
|
|
2038
|
+
const locationStr = location ? `, ${location}` : "";
|
|
2039
|
+
parts.push(
|
|
2040
|
+
`${i + 1}. ${p.name}${locationStr} (CERT ${p.cert}) | Asset: ${formatMetricValue("asset", p.metrics.asset)} | ROA: ${formatMetricValue("roa", p.metrics.roa)} | ROE: ${formatMetricValue("roe", p.metrics.roe)}`
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
return parts.join("\n");
|
|
2045
|
+
}
|
|
2046
|
+
function registerPeerGroupTools(server) {
|
|
2047
|
+
server.registerTool(
|
|
2048
|
+
"fdic_peer_group_analysis",
|
|
2049
|
+
{
|
|
2050
|
+
title: "Peer Group Analysis",
|
|
2051
|
+
description: `Build a peer group for an FDIC-insured institution and rank it against peers on financial and efficiency metrics at a single report date.
|
|
2052
|
+
|
|
2053
|
+
Three usage modes:
|
|
2054
|
+
- Subject-driven: provide cert and repdte \u2014 auto-derives peer criteria from the subject's asset size and charter class
|
|
2055
|
+
- Explicit criteria: provide repdte plus asset_min/asset_max, charter_classes, state, or raw_filter
|
|
2056
|
+
- Subject with overrides: provide cert plus explicit criteria to override auto-derived defaults
|
|
2057
|
+
|
|
2058
|
+
Metrics ranked (fixed order):
|
|
2059
|
+
- Total Assets, Total Deposits, ROA, ROE, Net Interest Margin
|
|
2060
|
+
- Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
|
|
2061
|
+
- Deposits-to-Assets Ratio, Non-Interest Income Share
|
|
2062
|
+
|
|
2063
|
+
Rankings use competition rank (1, 2, 2, 4) with metric-specific denominators. Subject is excluded from peer set.
|
|
2064
|
+
|
|
2065
|
+
Output includes:
|
|
2066
|
+
- Subject rankings and percentiles (when cert provided)
|
|
2067
|
+
- Peer group medians
|
|
2068
|
+
- Peer list with CERTs (pass to fdic_compare_bank_snapshots for trend analysis)
|
|
2069
|
+
- Metric definitions with directionality metadata
|
|
2070
|
+
|
|
2071
|
+
Override precedence: cert derives defaults, then explicit params override them.`,
|
|
2072
|
+
inputSchema: PeerGroupInputSchema,
|
|
2073
|
+
annotations: {
|
|
2074
|
+
readOnlyHint: true,
|
|
2075
|
+
destructiveHint: false,
|
|
2076
|
+
idempotentHint: true,
|
|
2077
|
+
openWorldHint: true
|
|
2078
|
+
}
|
|
2079
|
+
},
|
|
2080
|
+
async (params) => {
|
|
2081
|
+
const controller = new AbortController();
|
|
2082
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS2);
|
|
2083
|
+
try {
|
|
2084
|
+
const warnings = [];
|
|
2085
|
+
let subjectProfile = null;
|
|
2086
|
+
let subjectFinancials = null;
|
|
2087
|
+
if (params.cert) {
|
|
2088
|
+
const [profileResponse, financialsResponse] = await Promise.all([
|
|
2089
|
+
queryEndpoint(
|
|
2090
|
+
ENDPOINTS.INSTITUTIONS,
|
|
2091
|
+
{
|
|
2092
|
+
filters: `CERT:${params.cert}`,
|
|
2093
|
+
fields: "CERT,NAME,CITY,STALP,BKCLASS",
|
|
2094
|
+
limit: 1
|
|
2095
|
+
},
|
|
2096
|
+
{ signal: controller.signal }
|
|
2097
|
+
),
|
|
2098
|
+
queryEndpoint(
|
|
2099
|
+
ENDPOINTS.FINANCIALS,
|
|
2100
|
+
{
|
|
2101
|
+
filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
|
|
2102
|
+
fields: FINANCIAL_FIELDS,
|
|
2103
|
+
limit: 1
|
|
2104
|
+
},
|
|
2105
|
+
{ signal: controller.signal }
|
|
2106
|
+
)
|
|
2107
|
+
]);
|
|
2108
|
+
const profileRecords = extractRecords(profileResponse);
|
|
2109
|
+
if (profileRecords.length === 0) {
|
|
2110
|
+
return formatToolError(
|
|
2111
|
+
new Error(
|
|
2112
|
+
`No institution found with CERT number ${params.cert}.`
|
|
2113
|
+
)
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
subjectProfile = profileRecords[0];
|
|
2117
|
+
const financialRecords = extractRecords(financialsResponse);
|
|
2118
|
+
if (financialRecords.length === 0) {
|
|
2119
|
+
return formatToolError(
|
|
2120
|
+
new Error(
|
|
2121
|
+
`No financial data for CERT ${params.cert} at report date ${params.repdte}. Auto-derivation of peer criteria requires asset data at the specified report date.`
|
|
2122
|
+
)
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
subjectFinancials = financialRecords[0];
|
|
2126
|
+
}
|
|
2127
|
+
const subjectAsset = subjectFinancials && typeof subjectFinancials.ASSET === "number" ? subjectFinancials.ASSET : null;
|
|
2128
|
+
const assetMin = params.asset_min ?? (subjectAsset !== null ? subjectAsset * 0.5 : void 0);
|
|
2129
|
+
const assetMax = params.asset_max ?? (subjectAsset !== null ? subjectAsset * 2 : void 0);
|
|
2130
|
+
const charterClasses = params.charter_classes ?? (subjectProfile && typeof subjectProfile.BKCLASS === "string" ? [subjectProfile.BKCLASS] : void 0);
|
|
2131
|
+
const { state, active_only, raw_filter } = params;
|
|
2132
|
+
const filterParts = [];
|
|
2133
|
+
if (assetMin !== void 0 || assetMax !== void 0) {
|
|
2134
|
+
const min = assetMin ?? 0;
|
|
2135
|
+
const max = assetMax ?? "*";
|
|
2136
|
+
filterParts.push(`ASSET:[${min} TO ${max}]`);
|
|
2137
|
+
}
|
|
2138
|
+
if (charterClasses && charterClasses.length > 0) {
|
|
2139
|
+
const classFilter = charterClasses.map((cls) => `BKCLASS:${cls}`).join(" OR ");
|
|
2140
|
+
filterParts.push(
|
|
2141
|
+
charterClasses.length > 1 ? `(${classFilter})` : classFilter
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
if (state) filterParts.push(`STALP:${state}`);
|
|
2145
|
+
if (active_only) filterParts.push("ACTIVE:1");
|
|
2146
|
+
if (raw_filter) filterParts.push(`(${raw_filter})`);
|
|
2147
|
+
const rosterResponse = await queryEndpoint(
|
|
2148
|
+
ENDPOINTS.INSTITUTIONS,
|
|
2149
|
+
{
|
|
2150
|
+
filters: filterParts.join(" AND "),
|
|
2151
|
+
fields: "CERT,NAME,CITY,STALP,BKCLASS",
|
|
2152
|
+
limit: 1e4,
|
|
2153
|
+
offset: 0,
|
|
2154
|
+
sort_by: "CERT",
|
|
2155
|
+
sort_order: "ASC"
|
|
2156
|
+
},
|
|
2157
|
+
{ signal: controller.signal }
|
|
2158
|
+
);
|
|
2159
|
+
let rosterRecords = extractRecords(rosterResponse);
|
|
2160
|
+
if (rosterResponse.meta.total > rosterRecords.length) {
|
|
2161
|
+
warnings.push(
|
|
2162
|
+
`Institution roster truncated to ${rosterRecords.length.toLocaleString()} records out of ${rosterResponse.meta.total.toLocaleString()} matched institutions. Narrow the peer group criteria for complete analysis.`
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
if (params.cert) {
|
|
2166
|
+
rosterRecords = rosterRecords.filter(
|
|
2167
|
+
(r) => asNumber2(r.CERT) !== params.cert
|
|
2168
|
+
);
|
|
2169
|
+
}
|
|
2170
|
+
const criteriaUsed = {
|
|
2171
|
+
asset_min: assetMin ?? null,
|
|
2172
|
+
asset_max: assetMax ?? null,
|
|
2173
|
+
charter_classes: charterClasses ?? null,
|
|
2174
|
+
state: state ?? null,
|
|
2175
|
+
active_only,
|
|
2176
|
+
raw_filter: raw_filter ?? null
|
|
2177
|
+
};
|
|
2178
|
+
if (rosterRecords.length === 0) {
|
|
2179
|
+
const subjectMetrics2 = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
|
|
2180
|
+
const output2 = {};
|
|
2181
|
+
if (subjectProfile) {
|
|
2182
|
+
output2.subject = {
|
|
2183
|
+
cert: params.cert,
|
|
2184
|
+
name: subjectProfile.NAME,
|
|
2185
|
+
city: subjectProfile.CITY,
|
|
2186
|
+
stalp: subjectProfile.STALP,
|
|
2187
|
+
bkclass: subjectProfile.BKCLASS,
|
|
2188
|
+
metrics: subjectMetrics2,
|
|
2189
|
+
rankings: null
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
output2.peer_group = {
|
|
2193
|
+
repdte: params.repdte,
|
|
2194
|
+
criteria_used: criteriaUsed,
|
|
2195
|
+
medians: {}
|
|
2196
|
+
};
|
|
2197
|
+
output2.metric_definitions = METRIC_DEFINITIONS;
|
|
2198
|
+
output2.peers = [];
|
|
2199
|
+
output2.peer_count = 0;
|
|
2200
|
+
output2.returned_count = 0;
|
|
2201
|
+
output2.has_more = false;
|
|
2202
|
+
output2.message = "No peers matched the specified criteria.";
|
|
2203
|
+
output2.warnings = warnings;
|
|
2204
|
+
const text2 = formatPeerGroupText(
|
|
2205
|
+
params.repdte,
|
|
2206
|
+
subjectProfile,
|
|
2207
|
+
subjectMetrics2,
|
|
2208
|
+
{},
|
|
2209
|
+
{},
|
|
2210
|
+
[],
|
|
2211
|
+
0,
|
|
2212
|
+
warnings
|
|
2213
|
+
);
|
|
2214
|
+
return {
|
|
2215
|
+
content: [{ type: "text", text: text2 }],
|
|
2216
|
+
structuredContent: output2
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
const peerCerts = rosterRecords.map((r) => asNumber2(r.CERT)).filter((c) => c !== null);
|
|
2220
|
+
const certFilters = buildCertFilters2(peerCerts);
|
|
2221
|
+
const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : "";
|
|
2222
|
+
const financialResponses = await mapWithConcurrency2(
|
|
2223
|
+
certFilters,
|
|
2224
|
+
MAX_CONCURRENCY2,
|
|
2225
|
+
async (certFilter) => queryEndpoint(
|
|
2226
|
+
ENDPOINTS.FINANCIALS,
|
|
2227
|
+
{
|
|
2228
|
+
filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
|
|
2229
|
+
fields: FINANCIAL_FIELDS + extraFieldsCsv,
|
|
2230
|
+
limit: 1e4,
|
|
2231
|
+
offset: 0,
|
|
2232
|
+
sort_by: "CERT",
|
|
2233
|
+
sort_order: "ASC"
|
|
2234
|
+
},
|
|
2235
|
+
{ signal: controller.signal }
|
|
2236
|
+
)
|
|
2237
|
+
);
|
|
2238
|
+
const peerFinancialsByCert = /* @__PURE__ */ new Map();
|
|
2239
|
+
for (const response of financialResponses) {
|
|
2240
|
+
for (const record of extractRecords(response)) {
|
|
2241
|
+
const cert = asNumber2(record.CERT);
|
|
2242
|
+
if (cert !== null) peerFinancialsByCert.set(cert, record);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
const rosterByCert = new Map(
|
|
2246
|
+
rosterRecords.map((r) => [asNumber2(r.CERT), r]).filter(
|
|
2247
|
+
(e) => e[0] !== null
|
|
2248
|
+
)
|
|
2249
|
+
);
|
|
2250
|
+
const peers = [];
|
|
2251
|
+
for (const [cert, financials] of peerFinancialsByCert) {
|
|
2252
|
+
const roster = rosterByCert.get(cert);
|
|
2253
|
+
const metrics = deriveMetrics(financials);
|
|
2254
|
+
const extraFields = {};
|
|
2255
|
+
if (params.extra_fields) {
|
|
2256
|
+
for (const field of params.extra_fields) {
|
|
2257
|
+
extraFields[field] = financials[field] ?? null;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
peers.push({
|
|
2261
|
+
cert,
|
|
2262
|
+
name: String(roster?.NAME ?? financials.NAME ?? cert),
|
|
2263
|
+
city: roster?.CITY != null ? String(roster.CITY) : null,
|
|
2264
|
+
stalp: roster?.STALP != null ? String(roster.STALP) : null,
|
|
2265
|
+
bkclass: roster?.BKCLASS != null ? String(roster.BKCLASS) : null,
|
|
2266
|
+
metrics,
|
|
2267
|
+
extraFields
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
const peerCount = peers.length;
|
|
2271
|
+
const subjectMetrics = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
|
|
2272
|
+
const rankings = {};
|
|
2273
|
+
const medians = {};
|
|
2274
|
+
for (const key of METRIC_KEYS) {
|
|
2275
|
+
const peerValues = peers.map((p) => p.metrics[key]).filter((v) => v !== null);
|
|
2276
|
+
medians[key] = computeMedian(peerValues);
|
|
2277
|
+
if (subjectMetrics && subjectMetrics[key] !== null) {
|
|
2278
|
+
rankings[key] = computeCompetitionRank(
|
|
2279
|
+
subjectMetrics[key],
|
|
2280
|
+
peerValues,
|
|
2281
|
+
METRIC_DEFINITIONS[key].higher_is_better
|
|
2282
|
+
);
|
|
2283
|
+
} else {
|
|
2284
|
+
rankings[key] = null;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
peers.sort((a, b) => (b.metrics.asset ?? 0) - (a.metrics.asset ?? 0));
|
|
2288
|
+
const returnedPeers = peers.slice(0, params.limit);
|
|
2289
|
+
const returnedCount = returnedPeers.length;
|
|
2290
|
+
const hasMore = peerCount > returnedCount;
|
|
2291
|
+
const output = {};
|
|
2292
|
+
if (subjectProfile && subjectMetrics) {
|
|
2293
|
+
output.subject = {
|
|
2294
|
+
cert: params.cert,
|
|
2295
|
+
name: subjectProfile.NAME,
|
|
2296
|
+
city: subjectProfile.CITY,
|
|
2297
|
+
stalp: subjectProfile.STALP,
|
|
2298
|
+
bkclass: subjectProfile.BKCLASS,
|
|
2299
|
+
metrics: subjectMetrics,
|
|
2300
|
+
rankings
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
output.peer_group = {
|
|
2304
|
+
repdte: params.repdte,
|
|
2305
|
+
criteria_used: criteriaUsed,
|
|
2306
|
+
medians
|
|
2307
|
+
};
|
|
2308
|
+
output.metric_definitions = METRIC_DEFINITIONS;
|
|
2309
|
+
output.peers = returnedPeers.map((p) => ({
|
|
2310
|
+
cert: p.cert,
|
|
2311
|
+
name: p.name,
|
|
2312
|
+
city: p.city,
|
|
2313
|
+
stalp: p.stalp,
|
|
2314
|
+
metrics: p.metrics,
|
|
2315
|
+
...p.extraFields
|
|
2316
|
+
}));
|
|
2317
|
+
output.peer_count = peerCount;
|
|
2318
|
+
output.returned_count = returnedCount;
|
|
2319
|
+
output.has_more = hasMore;
|
|
2320
|
+
output.message = null;
|
|
2321
|
+
output.warnings = warnings;
|
|
2322
|
+
const text = truncateIfNeeded(
|
|
2323
|
+
formatPeerGroupText(
|
|
2324
|
+
params.repdte,
|
|
2325
|
+
subjectProfile,
|
|
2326
|
+
subjectMetrics,
|
|
2327
|
+
rankings,
|
|
2328
|
+
medians,
|
|
2329
|
+
returnedPeers,
|
|
2330
|
+
peerCount,
|
|
2331
|
+
warnings
|
|
2332
|
+
),
|
|
2333
|
+
CHARACTER_LIMIT
|
|
2334
|
+
);
|
|
2335
|
+
return {
|
|
2336
|
+
content: [{ type: "text", text }],
|
|
2337
|
+
structuredContent: output
|
|
2338
|
+
};
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
if (controller.signal.aborted) {
|
|
2341
|
+
return formatToolError(
|
|
2342
|
+
new Error(
|
|
2343
|
+
`Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS2 / 1e3)} seconds. Narrow the peer group criteria and try again.`
|
|
2344
|
+
)
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
1691
2347
|
return formatToolError(err);
|
|
2348
|
+
} finally {
|
|
2349
|
+
clearTimeout(timeoutId);
|
|
1692
2350
|
}
|
|
1693
2351
|
}
|
|
1694
2352
|
);
|
|
@@ -1708,6 +2366,7 @@ function createServer() {
|
|
|
1708
2366
|
registerSodTools(server);
|
|
1709
2367
|
registerDemographicsTools(server);
|
|
1710
2368
|
registerAnalysisTools(server);
|
|
2369
|
+
registerPeerGroupTools(server);
|
|
1711
2370
|
return server;
|
|
1712
2371
|
}
|
|
1713
2372
|
async function runStdio() {
|