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