fdic-mcp-server 1.0.7 → 1.0.8
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 +112 -31
- package/dist/server.js +112 -31
- package/package.json +1 -1
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.0.8" : 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,16 @@ 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
|
+
}
|
|
1691
1770
|
return formatToolError(err);
|
|
1771
|
+
} finally {
|
|
1772
|
+
clearTimeout(timeoutId);
|
|
1692
1773
|
}
|
|
1693
1774
|
}
|
|
1694
1775
|
);
|
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.0.8" : 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,16 @@ 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
|
+
}
|
|
1702
1781
|
return formatToolError(err);
|
|
1782
|
+
} finally {
|
|
1783
|
+
clearTimeout(timeoutId);
|
|
1703
1784
|
}
|
|
1704
1785
|
}
|
|
1705
1786
|
);
|