fdic-mcp-server 1.0.5 → 1.0.7
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 +53 -20
- package/dist/index.js +835 -54
- package/dist/server.js +835 -54
- package/package.json +1 -1
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.7" : 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 = {
|
|
@@ -65,42 +65,73 @@ var apiClient = import_axios.default.create({
|
|
|
65
65
|
"User-Agent": `fdic-mcp-server/${VERSION}`
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
|
+
var QUERY_CACHE_TTL_MS = 6e4;
|
|
69
|
+
var queryCache = /* @__PURE__ */ new Map();
|
|
70
|
+
function getCacheKey(endpoint, params) {
|
|
71
|
+
return JSON.stringify([
|
|
72
|
+
endpoint,
|
|
73
|
+
params.filters ?? null,
|
|
74
|
+
params.fields ?? null,
|
|
75
|
+
params.limit ?? null,
|
|
76
|
+
params.offset ?? null,
|
|
77
|
+
params.sort_by ?? null,
|
|
78
|
+
params.sort_order ?? null
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
68
81
|
async function queryEndpoint(endpoint, params) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
const cacheKey = getCacheKey(endpoint, params);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const cached = queryCache.get(cacheKey);
|
|
85
|
+
if (cached && cached.expiresAt > now) {
|
|
86
|
+
return cached.value;
|
|
87
|
+
}
|
|
88
|
+
const requestPromise = (async () => {
|
|
89
|
+
try {
|
|
90
|
+
const queryParams = {
|
|
91
|
+
limit: params.limit ?? 20,
|
|
92
|
+
offset: params.offset ?? 0,
|
|
93
|
+
output: "json"
|
|
94
|
+
};
|
|
95
|
+
if (params.filters) queryParams.filters = params.filters;
|
|
96
|
+
if (params.fields) queryParams.fields = params.fields;
|
|
97
|
+
if (params.sort_by) queryParams.sort_by = params.sort_by;
|
|
98
|
+
if (params.sort_order) queryParams.sort_order = params.sort_order;
|
|
99
|
+
const response = await apiClient.get(`/${endpoint}`, {
|
|
100
|
+
params: queryParams
|
|
101
|
+
});
|
|
102
|
+
return response.data;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err instanceof import_axios.AxiosError) {
|
|
105
|
+
const status = err.response?.status;
|
|
106
|
+
const detail = err.response?.data?.message ?? err.message;
|
|
107
|
+
if (status === 400) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Bad request to FDIC API: ${detail}. Check your filter syntax (use ElasticSearch query string syntax, e.g. STNAME:"California" AND ACTIVE:1).`
|
|
110
|
+
);
|
|
111
|
+
} else if (status === 429) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"FDIC API rate limit exceeded. Please wait a moment and try again."
|
|
114
|
+
);
|
|
115
|
+
} else if (status === 500) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"FDIC API server error. The service may be temporarily unavailable. Try again later."
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error(`FDIC API error (HTTP ${status}): ${detail}`);
|
|
121
|
+
}
|
|
101
122
|
}
|
|
123
|
+
throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
|
|
102
124
|
}
|
|
103
|
-
|
|
125
|
+
})();
|
|
126
|
+
queryCache.set(cacheKey, {
|
|
127
|
+
expiresAt: now + QUERY_CACHE_TTL_MS,
|
|
128
|
+
value: requestPromise
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
return await requestPromise;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
queryCache.delete(cacheKey);
|
|
134
|
+
throw error;
|
|
104
135
|
}
|
|
105
136
|
}
|
|
106
137
|
function extractRecords(response) {
|
|
@@ -122,6 +153,37 @@ function truncateIfNeeded(text, charLimit) {
|
|
|
122
153
|
|
|
123
154
|
[Response truncated at ${charLimit} characters. Use limit/offset parameters to paginate or narrow your query with filters.]`;
|
|
124
155
|
}
|
|
156
|
+
function formatValue(value) {
|
|
157
|
+
if (value === null || value === void 0) return "n/a";
|
|
158
|
+
if (typeof value === "number") return Number.isInteger(value) ? `${value}` : value.toFixed(4);
|
|
159
|
+
if (typeof value === "string") return value;
|
|
160
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
161
|
+
return JSON.stringify(value);
|
|
162
|
+
}
|
|
163
|
+
function summarizeRecord(record, preferredKeys, maxFields = 4) {
|
|
164
|
+
const orderedKeys = [
|
|
165
|
+
...preferredKeys.filter((key) => key in record),
|
|
166
|
+
...Object.keys(record).filter(
|
|
167
|
+
(key) => key !== "ID" && !preferredKeys.includes(key)
|
|
168
|
+
)
|
|
169
|
+
];
|
|
170
|
+
return orderedKeys.slice(0, maxFields).map((key) => `${key}: ${formatValue(record[key])}`).join(" | ");
|
|
171
|
+
}
|
|
172
|
+
function formatSearchResultText(label, records, pagination, preferredKeys) {
|
|
173
|
+
const header = `Found ${pagination.total} ${label} (showing ${pagination.count}, offset ${pagination.offset}).`;
|
|
174
|
+
if (records.length === 0) {
|
|
175
|
+
return header;
|
|
176
|
+
}
|
|
177
|
+
const rows = records.map((record, index) => `${index + 1}. ${summarizeRecord(record, preferredKeys)}`).join("\n");
|
|
178
|
+
const footer = pagination.has_more ? `
|
|
179
|
+
More results available. Use offset ${pagination.next_offset} to continue.` : "";
|
|
180
|
+
return `${header}
|
|
181
|
+
${rows}${footer}`;
|
|
182
|
+
}
|
|
183
|
+
function formatLookupResultText(label, record, preferredKeys) {
|
|
184
|
+
return `${label}
|
|
185
|
+
${summarizeRecord(record, preferredKeys, 8)}`;
|
|
186
|
+
}
|
|
125
187
|
function formatToolError(err) {
|
|
126
188
|
const message = err instanceof Error ? err.message : String(err);
|
|
127
189
|
return {
|
|
@@ -194,7 +256,7 @@ Args:
|
|
|
194
256
|
- sort_by (string, optional): Field to sort by
|
|
195
257
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
196
258
|
|
|
197
|
-
|
|
259
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and institution records.`,
|
|
198
260
|
inputSchema: CommonQuerySchema,
|
|
199
261
|
annotations: {
|
|
200
262
|
readOnlyHint: true,
|
|
@@ -214,7 +276,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, institutions[]
|
|
|
214
276
|
);
|
|
215
277
|
const output = { ...pagination, institutions: records };
|
|
216
278
|
const text = truncateIfNeeded(
|
|
217
|
-
|
|
279
|
+
formatSearchResultText("institutions", records, pagination, [
|
|
280
|
+
"CERT",
|
|
281
|
+
"NAME",
|
|
282
|
+
"CITY",
|
|
283
|
+
"STALP",
|
|
284
|
+
"ASSET",
|
|
285
|
+
"ACTIVE"
|
|
286
|
+
]),
|
|
218
287
|
CHARACTER_LIMIT
|
|
219
288
|
);
|
|
220
289
|
return {
|
|
@@ -238,7 +307,7 @@ Args:
|
|
|
238
307
|
- cert (number): FDIC Certificate Number (e.g., 3511 for Bank of America)
|
|
239
308
|
- fields (string, optional): Comma-separated list of fields to return
|
|
240
309
|
|
|
241
|
-
Returns
|
|
310
|
+
Returns a detailed institution profile suitable for concise summaries, with structured fields available for exact values when needed.`,
|
|
242
311
|
inputSchema: CertSchema,
|
|
243
312
|
annotations: {
|
|
244
313
|
readOnlyHint: true,
|
|
@@ -267,7 +336,16 @@ Returns full institution profile including financial metrics, charter info, loca
|
|
|
267
336
|
};
|
|
268
337
|
}
|
|
269
338
|
const output = records[0];
|
|
270
|
-
const text =
|
|
339
|
+
const text = formatLookupResultText("Institution details", output, [
|
|
340
|
+
"CERT",
|
|
341
|
+
"NAME",
|
|
342
|
+
"CITY",
|
|
343
|
+
"STALP",
|
|
344
|
+
"ASSET",
|
|
345
|
+
"DEP",
|
|
346
|
+
"ACTIVE",
|
|
347
|
+
"REGAGNT"
|
|
348
|
+
]);
|
|
271
349
|
return {
|
|
272
350
|
content: [{ type: "text", text }],
|
|
273
351
|
structuredContent: output
|
|
@@ -315,7 +393,7 @@ Args:
|
|
|
315
393
|
- sort_by (string, optional): Field to sort by (e.g., FAILDATE, COST)
|
|
316
394
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
317
395
|
|
|
318
|
-
|
|
396
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and failure records.`,
|
|
319
397
|
inputSchema: CommonQuerySchema,
|
|
320
398
|
annotations: {
|
|
321
399
|
readOnlyHint: true,
|
|
@@ -335,7 +413,15 @@ Returns JSON with { total, offset, count, has_more, next_offset?, failures[] }`,
|
|
|
335
413
|
);
|
|
336
414
|
const output = { ...pagination, failures: records };
|
|
337
415
|
const text = truncateIfNeeded(
|
|
338
|
-
|
|
416
|
+
formatSearchResultText("failures", records, pagination, [
|
|
417
|
+
"CERT",
|
|
418
|
+
"NAME",
|
|
419
|
+
"CITY",
|
|
420
|
+
"STALP",
|
|
421
|
+
"FAILDATE",
|
|
422
|
+
"COST",
|
|
423
|
+
"RESTYPE"
|
|
424
|
+
]),
|
|
339
425
|
CHARACTER_LIMIT
|
|
340
426
|
);
|
|
341
427
|
return {
|
|
@@ -359,7 +445,7 @@ Args:
|
|
|
359
445
|
- cert (number): FDIC Certificate Number of the failed institution
|
|
360
446
|
- fields (string, optional): Comma-separated list of fields to return
|
|
361
447
|
|
|
362
|
-
Returns failure
|
|
448
|
+
Returns detailed failure information suitable for concise summaries, with structured fields available for exact values when needed.`,
|
|
363
449
|
inputSchema: CertSchema,
|
|
364
450
|
annotations: {
|
|
365
451
|
readOnlyHint: true,
|
|
@@ -388,7 +474,16 @@ Returns failure details including failure date, resolution method, and cost to F
|
|
|
388
474
|
};
|
|
389
475
|
}
|
|
390
476
|
const output = records[0];
|
|
391
|
-
const text =
|
|
477
|
+
const text = formatLookupResultText("Failure details", output, [
|
|
478
|
+
"CERT",
|
|
479
|
+
"NAME",
|
|
480
|
+
"FAILDATE",
|
|
481
|
+
"RESTYPE",
|
|
482
|
+
"COST",
|
|
483
|
+
"QBFASSET",
|
|
484
|
+
"CITY",
|
|
485
|
+
"STALP"
|
|
486
|
+
]);
|
|
392
487
|
return {
|
|
393
488
|
content: [{ type: "text", text }],
|
|
394
489
|
structuredContent: output
|
|
@@ -446,7 +541,7 @@ Args:
|
|
|
446
541
|
- sort_by (string, optional): Field to sort by
|
|
447
542
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
448
543
|
|
|
449
|
-
|
|
544
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and branch location records.`,
|
|
450
545
|
inputSchema: LocationQuerySchema,
|
|
451
546
|
annotations: {
|
|
452
547
|
readOnlyHint: true,
|
|
@@ -473,7 +568,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, locations[] }`
|
|
|
473
568
|
);
|
|
474
569
|
const output = { ...pagination, locations: records };
|
|
475
570
|
const text = truncateIfNeeded(
|
|
476
|
-
|
|
571
|
+
formatSearchResultText("locations", records, pagination, [
|
|
572
|
+
"CERT",
|
|
573
|
+
"UNINAME",
|
|
574
|
+
"NAMEFULL",
|
|
575
|
+
"CITY",
|
|
576
|
+
"STALP",
|
|
577
|
+
"BRNUM"
|
|
578
|
+
]),
|
|
477
579
|
CHARACTER_LIMIT
|
|
478
580
|
);
|
|
479
581
|
return {
|
|
@@ -534,7 +636,7 @@ Args:
|
|
|
534
636
|
- sort_by (string, optional): Field to sort by (e.g., PROCDATE)
|
|
535
637
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
536
638
|
|
|
537
|
-
|
|
639
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and event records.`,
|
|
538
640
|
inputSchema: HistoryQuerySchema,
|
|
539
641
|
annotations: {
|
|
540
642
|
readOnlyHint: true,
|
|
@@ -561,7 +663,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, events[] }`,
|
|
|
561
663
|
);
|
|
562
664
|
const output = { ...pagination, events: records };
|
|
563
665
|
const text = truncateIfNeeded(
|
|
564
|
-
|
|
666
|
+
formatSearchResultText("events", records, pagination, [
|
|
667
|
+
"CERT",
|
|
668
|
+
"INSTNAME",
|
|
669
|
+
"TYPE",
|
|
670
|
+
"PROCDATE",
|
|
671
|
+
"PCITY",
|
|
672
|
+
"PSTALP"
|
|
673
|
+
]),
|
|
565
674
|
CHARACTER_LIMIT
|
|
566
675
|
);
|
|
567
676
|
return {
|
|
@@ -631,7 +740,7 @@ Args:
|
|
|
631
740
|
- sort_by (string, optional): Field to sort by
|
|
632
741
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'DESC' recommended for most recent first)
|
|
633
742
|
|
|
634
|
-
|
|
743
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and quarterly financial records.`,
|
|
635
744
|
inputSchema: FinancialQuerySchema,
|
|
636
745
|
annotations: {
|
|
637
746
|
readOnlyHint: true,
|
|
@@ -658,7 +767,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, financials[] }
|
|
|
658
767
|
);
|
|
659
768
|
const output = { ...pagination, financials: records };
|
|
660
769
|
const text = truncateIfNeeded(
|
|
661
|
-
|
|
770
|
+
formatSearchResultText("financial records", records, pagination, [
|
|
771
|
+
"CERT",
|
|
772
|
+
"NAME",
|
|
773
|
+
"REPDTE",
|
|
774
|
+
"ASSET",
|
|
775
|
+
"DEP",
|
|
776
|
+
"NETINC"
|
|
777
|
+
]),
|
|
662
778
|
CHARACTER_LIMIT
|
|
663
779
|
);
|
|
664
780
|
return {
|
|
@@ -706,7 +822,7 @@ Args:
|
|
|
706
822
|
- sort_by (string, optional): Field to sort by (e.g., YEAR, ASSET)
|
|
707
823
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
708
824
|
|
|
709
|
-
|
|
825
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and annual summary records.`,
|
|
710
826
|
inputSchema: SummaryQuerySchema,
|
|
711
827
|
annotations: {
|
|
712
828
|
readOnlyHint: true,
|
|
@@ -733,7 +849,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, summary[] }`,
|
|
|
733
849
|
);
|
|
734
850
|
const output = { ...pagination, summary: records };
|
|
735
851
|
const text = truncateIfNeeded(
|
|
736
|
-
|
|
852
|
+
formatSearchResultText("annual summary records", records, pagination, [
|
|
853
|
+
"CERT",
|
|
854
|
+
"YEAR",
|
|
855
|
+
"ASSET",
|
|
856
|
+
"DEP",
|
|
857
|
+
"NETINC",
|
|
858
|
+
"ROA"
|
|
859
|
+
]),
|
|
737
860
|
CHARACTER_LIMIT
|
|
738
861
|
);
|
|
739
862
|
return {
|
|
@@ -795,7 +918,7 @@ Args:
|
|
|
795
918
|
- sort_by (string, optional): Field to sort by (e.g., DEPSUMBR, YEAR)
|
|
796
919
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
797
920
|
|
|
798
|
-
|
|
921
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and deposit records.`,
|
|
799
922
|
inputSchema: SodQuerySchema,
|
|
800
923
|
annotations: {
|
|
801
924
|
readOnlyHint: true,
|
|
@@ -822,7 +945,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, deposits[] }`,
|
|
|
822
945
|
);
|
|
823
946
|
const output = { ...pagination, deposits: records };
|
|
824
947
|
const text = truncateIfNeeded(
|
|
825
|
-
|
|
948
|
+
formatSearchResultText("deposit records", records, pagination, [
|
|
949
|
+
"CERT",
|
|
950
|
+
"YEAR",
|
|
951
|
+
"UNINAME",
|
|
952
|
+
"NAMEFULL",
|
|
953
|
+
"CITYBR",
|
|
954
|
+
"DEPSUMBR"
|
|
955
|
+
]),
|
|
826
956
|
CHARACTER_LIMIT
|
|
827
957
|
);
|
|
828
958
|
return {
|
|
@@ -884,7 +1014,7 @@ Args:
|
|
|
884
1014
|
- sort_by (string, optional): Field to sort by
|
|
885
1015
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
886
1016
|
|
|
887
|
-
|
|
1017
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and demographic records.`,
|
|
888
1018
|
inputSchema: DemographicsQuerySchema,
|
|
889
1019
|
annotations: {
|
|
890
1020
|
readOnlyHint: true,
|
|
@@ -911,7 +1041,657 @@ Returns JSON with { total, offset, count, has_more, next_offset?, demographics[]
|
|
|
911
1041
|
);
|
|
912
1042
|
const output = { ...pagination, demographics: records };
|
|
913
1043
|
const text = truncateIfNeeded(
|
|
914
|
-
|
|
1044
|
+
formatSearchResultText("demographic records", records, pagination, [
|
|
1045
|
+
"CERT",
|
|
1046
|
+
"REPDTE",
|
|
1047
|
+
"OFFTOT",
|
|
1048
|
+
"OFFSTATE",
|
|
1049
|
+
"METRO",
|
|
1050
|
+
"CBSANAME"
|
|
1051
|
+
]),
|
|
1052
|
+
CHARACTER_LIMIT
|
|
1053
|
+
);
|
|
1054
|
+
return {
|
|
1055
|
+
content: [{ type: "text", text }],
|
|
1056
|
+
structuredContent: output
|
|
1057
|
+
};
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
return formatToolError(err);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/tools/analysis.ts
|
|
1066
|
+
var import_zod7 = require("zod");
|
|
1067
|
+
var CHUNK_SIZE = 25;
|
|
1068
|
+
var MAX_CONCURRENCY = 4;
|
|
1069
|
+
var SortFieldSchema = import_zod7.z.enum([
|
|
1070
|
+
"asset_growth",
|
|
1071
|
+
"asset_growth_pct",
|
|
1072
|
+
"dep_growth",
|
|
1073
|
+
"dep_growth_pct",
|
|
1074
|
+
"netinc_change",
|
|
1075
|
+
"netinc_change_pct",
|
|
1076
|
+
"roa_change",
|
|
1077
|
+
"roe_change",
|
|
1078
|
+
"offices_change",
|
|
1079
|
+
"assets_per_office_change",
|
|
1080
|
+
"deposits_per_office_change",
|
|
1081
|
+
"deposits_to_assets_change"
|
|
1082
|
+
]);
|
|
1083
|
+
var AnalysisModeSchema = import_zod7.z.enum(["snapshot", "timeseries"]);
|
|
1084
|
+
var SnapshotAnalysisSchema = import_zod7.z.object({
|
|
1085
|
+
state: import_zod7.z.string().optional().describe(
|
|
1086
|
+
'State name for the institution roster filter. Example: "North Carolina"'
|
|
1087
|
+
),
|
|
1088
|
+
certs: import_zod7.z.array(import_zod7.z.number().int().positive()).max(100).optional().describe(
|
|
1089
|
+
"Optional list of FDIC certificate numbers to compare directly. Max 100."
|
|
1090
|
+
),
|
|
1091
|
+
institution_filters: import_zod7.z.string().optional().describe(
|
|
1092
|
+
'Additional institution-level filter used when building the comparison set. Example: BKCLASS:N or CITY:"Charlotte"'
|
|
1093
|
+
),
|
|
1094
|
+
active_only: import_zod7.z.boolean().default(true).describe("Limit the comparison set to currently active institutions."),
|
|
1095
|
+
start_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Starting report date in YYYYMMDD format."),
|
|
1096
|
+
end_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Ending report date in YYYYMMDD format."),
|
|
1097
|
+
analysis_mode: AnalysisModeSchema.default("snapshot").describe(
|
|
1098
|
+
"Use snapshot for two-point comparison or timeseries for quarterly trend analysis across the date range."
|
|
1099
|
+
),
|
|
1100
|
+
include_demographics: import_zod7.z.boolean().default(true).describe(
|
|
1101
|
+
"Include office-count changes from the demographics dataset when available."
|
|
1102
|
+
),
|
|
1103
|
+
limit: import_zod7.z.number().int().min(1).max(100).default(10).describe("Maximum number of ranked comparisons to return."),
|
|
1104
|
+
sort_by: SortFieldSchema.default("asset_growth").describe(
|
|
1105
|
+
"Comparison field used to rank institutions."
|
|
1106
|
+
),
|
|
1107
|
+
sort_order: import_zod7.z.enum(["ASC", "DESC"]).default("DESC").describe("Sort direction for the ranked comparisons.")
|
|
1108
|
+
}).superRefine((value, ctx) => {
|
|
1109
|
+
if (!value.state && (!value.certs || value.certs.length === 0)) {
|
|
1110
|
+
ctx.addIssue({
|
|
1111
|
+
code: import_zod7.z.ZodIssueCode.custom,
|
|
1112
|
+
message: "Provide either state or certs.",
|
|
1113
|
+
path: ["state"]
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
function asNumber(value) {
|
|
1118
|
+
return typeof value === "number" ? value : null;
|
|
1119
|
+
}
|
|
1120
|
+
function buildCertFilters(certs) {
|
|
1121
|
+
const filters = [];
|
|
1122
|
+
for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
|
|
1123
|
+
const chunk = certs.slice(i, i + CHUNK_SIZE);
|
|
1124
|
+
filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
|
|
1125
|
+
}
|
|
1126
|
+
return filters;
|
|
1127
|
+
}
|
|
1128
|
+
async function mapWithConcurrency(values, limit, mapper) {
|
|
1129
|
+
const results = new Array(values.length);
|
|
1130
|
+
let nextIndex = 0;
|
|
1131
|
+
async function worker() {
|
|
1132
|
+
while (true) {
|
|
1133
|
+
const currentIndex = nextIndex;
|
|
1134
|
+
nextIndex += 1;
|
|
1135
|
+
if (currentIndex >= values.length) return;
|
|
1136
|
+
results[currentIndex] = await mapper(values[currentIndex], currentIndex);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
await Promise.all(
|
|
1140
|
+
Array.from({ length: Math.min(limit, values.length) }, () => worker())
|
|
1141
|
+
);
|
|
1142
|
+
return results;
|
|
1143
|
+
}
|
|
1144
|
+
function ratio(numerator, denominator) {
|
|
1145
|
+
if (numerator === null || denominator === null || denominator === 0) {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
return numerator / denominator;
|
|
1149
|
+
}
|
|
1150
|
+
function pctChange(start, end) {
|
|
1151
|
+
if (start === null || end === null || start === 0) return null;
|
|
1152
|
+
return (end - start) / start * 100;
|
|
1153
|
+
}
|
|
1154
|
+
function change(start, end) {
|
|
1155
|
+
if (start === null || end === null) return null;
|
|
1156
|
+
return end - start;
|
|
1157
|
+
}
|
|
1158
|
+
function yearsBetween(startRepdte, endRepdte) {
|
|
1159
|
+
const start = /* @__PURE__ */ new Date(
|
|
1160
|
+
`${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
|
|
1161
|
+
);
|
|
1162
|
+
const end = /* @__PURE__ */ new Date(
|
|
1163
|
+
`${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
|
|
1164
|
+
);
|
|
1165
|
+
return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
|
|
1166
|
+
}
|
|
1167
|
+
function cagr(start, end, years) {
|
|
1168
|
+
if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
return (Math.pow(end / start, 1 / years) - 1) * 100;
|
|
1172
|
+
}
|
|
1173
|
+
function formatPercent(value) {
|
|
1174
|
+
return value === null ? "n/a" : `${value.toFixed(1)}%`;
|
|
1175
|
+
}
|
|
1176
|
+
function formatChange(value, digits = 4) {
|
|
1177
|
+
if (value === null) return "n/a";
|
|
1178
|
+
return value.toFixed(digits);
|
|
1179
|
+
}
|
|
1180
|
+
function formatInteger(value) {
|
|
1181
|
+
return value === null ? "n/a" : `${Math.round(value).toLocaleString()}`;
|
|
1182
|
+
}
|
|
1183
|
+
function sortComparisons(comparisons, sortBy, sortOrder) {
|
|
1184
|
+
const direction = sortOrder === "ASC" ? 1 : -1;
|
|
1185
|
+
return [...comparisons].sort((left, right) => {
|
|
1186
|
+
const leftValue = asNumber(left[sortBy]) ?? Number.NEGATIVE_INFINITY;
|
|
1187
|
+
const rightValue = asNumber(right[sortBy]) ?? Number.NEGATIVE_INFINITY;
|
|
1188
|
+
return (leftValue - rightValue) * direction;
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
function classifyInsights(comparison) {
|
|
1192
|
+
const insights = [];
|
|
1193
|
+
const assetGrowthPct = asNumber(comparison.asset_growth_pct);
|
|
1194
|
+
const depGrowthPct = asNumber(comparison.dep_growth_pct);
|
|
1195
|
+
const roaChange = asNumber(comparison.roa_change);
|
|
1196
|
+
const roeChange = asNumber(comparison.roe_change);
|
|
1197
|
+
const officesChange = asNumber(comparison.offices_change);
|
|
1198
|
+
const depositsToAssetsChange = asNumber(comparison.deposits_to_assets_change);
|
|
1199
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && roaChange !== null && roaChange > 0) {
|
|
1200
|
+
insights.push("growth_with_better_profitability");
|
|
1201
|
+
}
|
|
1202
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && officesChange !== null && officesChange > 0) {
|
|
1203
|
+
insights.push("growth_with_branch_expansion");
|
|
1204
|
+
}
|
|
1205
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 20 && (roaChange === null || roaChange <= 0) && (roeChange === null || roeChange <= 0)) {
|
|
1206
|
+
insights.push("balance_sheet_growth_without_profitability");
|
|
1207
|
+
}
|
|
1208
|
+
if (assetGrowthPct !== null && assetGrowthPct > 0 && officesChange !== null && officesChange < 0) {
|
|
1209
|
+
insights.push("growth_with_branch_consolidation");
|
|
1210
|
+
}
|
|
1211
|
+
if (depositsToAssetsChange !== null && depositsToAssetsChange < 0 && depGrowthPct !== null && depGrowthPct < 0) {
|
|
1212
|
+
insights.push("deposit_mix_softening");
|
|
1213
|
+
}
|
|
1214
|
+
return insights;
|
|
1215
|
+
}
|
|
1216
|
+
function buildTopLevelInsights(comparisons) {
|
|
1217
|
+
return {
|
|
1218
|
+
growth_with_better_profitability: comparisons.filter(
|
|
1219
|
+
(comparison) => comparison.insights?.includes(
|
|
1220
|
+
"growth_with_better_profitability"
|
|
1221
|
+
)
|
|
1222
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1223
|
+
growth_with_branch_expansion: comparisons.filter(
|
|
1224
|
+
(comparison) => comparison.insights?.includes(
|
|
1225
|
+
"growth_with_branch_expansion"
|
|
1226
|
+
)
|
|
1227
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1228
|
+
balance_sheet_growth_without_profitability: comparisons.filter(
|
|
1229
|
+
(comparison) => comparison.insights?.includes(
|
|
1230
|
+
"balance_sheet_growth_without_profitability"
|
|
1231
|
+
)
|
|
1232
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1233
|
+
growth_with_branch_consolidation: comparisons.filter(
|
|
1234
|
+
(comparison) => comparison.insights?.includes(
|
|
1235
|
+
"growth_with_branch_consolidation"
|
|
1236
|
+
)
|
|
1237
|
+
).slice(0, 5).map((comparison) => String(comparison.name))
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function longestMonotonicStreak(values, direction) {
|
|
1241
|
+
let longest = 0;
|
|
1242
|
+
let current = 0;
|
|
1243
|
+
for (let i = 1; i < values.length; i += 1) {
|
|
1244
|
+
const previous = values[i - 1];
|
|
1245
|
+
const next = values[i];
|
|
1246
|
+
if (previous === null || next === null) {
|
|
1247
|
+
current = 0;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
const matches = direction === "up" ? next > previous : next < previous;
|
|
1251
|
+
if (matches) {
|
|
1252
|
+
current += 1;
|
|
1253
|
+
longest = Math.max(longest, current);
|
|
1254
|
+
} else {
|
|
1255
|
+
current = 0;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return longest;
|
|
1259
|
+
}
|
|
1260
|
+
function summarizeTimeSeries(records, demographicsByDate, institution) {
|
|
1261
|
+
if (records.length < 2) return null;
|
|
1262
|
+
const sorted = [...records].sort(
|
|
1263
|
+
(left, right) => String(left.REPDTE).localeCompare(String(right.REPDTE))
|
|
1264
|
+
);
|
|
1265
|
+
const start = sorted[0];
|
|
1266
|
+
const end = sorted[sorted.length - 1];
|
|
1267
|
+
const startRepdte = String(start.REPDTE);
|
|
1268
|
+
const endRepdte = String(end.REPDTE);
|
|
1269
|
+
const years = yearsBetween(startRepdte, endRepdte);
|
|
1270
|
+
const assetSeries = sorted.map((record) => asNumber(record.ASSET));
|
|
1271
|
+
const roaSeries = sorted.map((record) => asNumber(record.ROA));
|
|
1272
|
+
const roeSeries = sorted.map((record) => asNumber(record.ROE));
|
|
1273
|
+
const officesSeries = sorted.map(
|
|
1274
|
+
(record) => asNumber(demographicsByDate.get(String(record.REPDTE))?.OFFTOT)
|
|
1275
|
+
);
|
|
1276
|
+
const assetStart = asNumber(start.ASSET);
|
|
1277
|
+
const assetEnd = asNumber(end.ASSET);
|
|
1278
|
+
const depStart = asNumber(start.DEP);
|
|
1279
|
+
const depEnd = asNumber(end.DEP);
|
|
1280
|
+
const roaStart = asNumber(start.ROA);
|
|
1281
|
+
const roaEnd = asNumber(end.ROA);
|
|
1282
|
+
const roeStart = asNumber(start.ROE);
|
|
1283
|
+
const roeEnd = asNumber(end.ROE);
|
|
1284
|
+
const netIncStart = asNumber(start.NETINC);
|
|
1285
|
+
const netIncEnd = asNumber(end.NETINC);
|
|
1286
|
+
const officesStart = officesSeries[0] ?? null;
|
|
1287
|
+
const officesEnd = officesSeries[officesSeries.length - 1] ?? null;
|
|
1288
|
+
const assetsPerOfficeStart = ratio(assetStart, officesStart);
|
|
1289
|
+
const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
|
|
1290
|
+
const depositsPerOfficeStart = ratio(depStart, officesStart);
|
|
1291
|
+
const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
|
|
1292
|
+
const depositsToAssetsStart = ratio(depStart, assetStart);
|
|
1293
|
+
const depositsToAssetsEnd = ratio(depEnd, assetEnd);
|
|
1294
|
+
const peakAsset = Math.max(...assetSeries.filter((value) => value !== null));
|
|
1295
|
+
const troughRoaValues = roaSeries.filter((value) => value !== null);
|
|
1296
|
+
const troughRoa = troughRoaValues.length > 0 ? Math.min(...troughRoaValues) : null;
|
|
1297
|
+
const comparison = {
|
|
1298
|
+
cert: asNumber(start.CERT),
|
|
1299
|
+
name: end.NAME ?? start.NAME ?? institution.NAME,
|
|
1300
|
+
city: institution.CITY,
|
|
1301
|
+
stalp: institution.STALP,
|
|
1302
|
+
analysis_mode: "timeseries",
|
|
1303
|
+
start_repdte: startRepdte,
|
|
1304
|
+
end_repdte: endRepdte,
|
|
1305
|
+
periods_analyzed: sorted.length,
|
|
1306
|
+
asset_start: assetStart,
|
|
1307
|
+
asset_end: assetEnd,
|
|
1308
|
+
asset_growth: change(assetStart, assetEnd),
|
|
1309
|
+
asset_growth_pct: pctChange(assetStart, assetEnd),
|
|
1310
|
+
asset_cagr: cagr(assetStart, assetEnd, years),
|
|
1311
|
+
dep_start: depStart,
|
|
1312
|
+
dep_end: depEnd,
|
|
1313
|
+
dep_growth: change(depStart, depEnd),
|
|
1314
|
+
dep_growth_pct: pctChange(depStart, depEnd),
|
|
1315
|
+
netinc_start: netIncStart,
|
|
1316
|
+
netinc_end: netIncEnd,
|
|
1317
|
+
netinc_change: change(netIncStart, netIncEnd),
|
|
1318
|
+
netinc_change_pct: pctChange(netIncStart, netIncEnd),
|
|
1319
|
+
roa_start: roaStart,
|
|
1320
|
+
roa_end: roaEnd,
|
|
1321
|
+
roa_change: change(roaStart, roaEnd),
|
|
1322
|
+
roe_start: roeStart,
|
|
1323
|
+
roe_end: roeEnd,
|
|
1324
|
+
roe_change: change(roeStart, roeEnd),
|
|
1325
|
+
offices_start: officesStart,
|
|
1326
|
+
offices_end: officesEnd,
|
|
1327
|
+
offices_change: change(officesStart, officesEnd),
|
|
1328
|
+
assets_per_office_start: assetsPerOfficeStart,
|
|
1329
|
+
assets_per_office_end: assetsPerOfficeEnd,
|
|
1330
|
+
assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
|
|
1331
|
+
deposits_per_office_start: depositsPerOfficeStart,
|
|
1332
|
+
deposits_per_office_end: depositsPerOfficeEnd,
|
|
1333
|
+
deposits_per_office_change: change(
|
|
1334
|
+
depositsPerOfficeStart,
|
|
1335
|
+
depositsPerOfficeEnd
|
|
1336
|
+
),
|
|
1337
|
+
deposits_to_assets_start: depositsToAssetsStart,
|
|
1338
|
+
deposits_to_assets_end: depositsToAssetsEnd,
|
|
1339
|
+
deposits_to_assets_change: change(
|
|
1340
|
+
depositsToAssetsStart,
|
|
1341
|
+
depositsToAssetsEnd
|
|
1342
|
+
),
|
|
1343
|
+
asset_peak: peakAsset,
|
|
1344
|
+
roa_trough: troughRoa,
|
|
1345
|
+
asset_growth_streak: longestMonotonicStreak(assetSeries, "up"),
|
|
1346
|
+
roa_decline_streak: longestMonotonicStreak(roaSeries, "down"),
|
|
1347
|
+
roe_decline_streak: longestMonotonicStreak(roeSeries, "down"),
|
|
1348
|
+
time_series: sorted.map((record) => {
|
|
1349
|
+
const repdte = String(record.REPDTE);
|
|
1350
|
+
const demo = demographicsByDate.get(repdte);
|
|
1351
|
+
return {
|
|
1352
|
+
repdte,
|
|
1353
|
+
asset: asNumber(record.ASSET),
|
|
1354
|
+
dep: asNumber(record.DEP),
|
|
1355
|
+
netinc: asNumber(record.NETINC),
|
|
1356
|
+
roa: asNumber(record.ROA),
|
|
1357
|
+
roe: asNumber(record.ROE),
|
|
1358
|
+
offices: asNumber(demo?.OFFTOT)
|
|
1359
|
+
};
|
|
1360
|
+
})
|
|
1361
|
+
};
|
|
1362
|
+
comparison.insights = classifyInsights(comparison);
|
|
1363
|
+
if (comparison.asset_growth_streak >= 3) {
|
|
1364
|
+
comparison.insights.push("sustained_asset_growth");
|
|
1365
|
+
}
|
|
1366
|
+
if (comparison.roa_decline_streak >= 2) {
|
|
1367
|
+
comparison.insights.push("multi_quarter_roa_decline");
|
|
1368
|
+
}
|
|
1369
|
+
return comparison;
|
|
1370
|
+
}
|
|
1371
|
+
function formatComparisonText(output) {
|
|
1372
|
+
const header = `Compared ${output.analyzed_count} institutions from ${output.start_repdte} to ${output.end_repdte} (from ${output.total_candidates} candidates), ranked by ${output.sort_by} using ${output.analysis_mode} analysis.`;
|
|
1373
|
+
if (output.comparisons.length === 0) {
|
|
1374
|
+
return header;
|
|
1375
|
+
}
|
|
1376
|
+
const rows = output.comparisons.map((comparison, index) => {
|
|
1377
|
+
const name = String(comparison.name ?? comparison.cert);
|
|
1378
|
+
const city = comparison.city ? `, ${comparison.city}` : "";
|
|
1379
|
+
const base = `${index + 1}. ${name}${city} | Asset growth: ${formatInteger(asNumber(comparison.asset_growth))} (${formatPercent(asNumber(comparison.asset_growth_pct))}) | Deposit growth: ${formatPercent(asNumber(comparison.dep_growth_pct))} | Offices: ${formatInteger(asNumber(comparison.offices_change))} | ROA: ${formatChange(asNumber(comparison.roa_change))} | ROE: ${formatChange(asNumber(comparison.roe_change))}`;
|
|
1380
|
+
if (output.analysis_mode === "timeseries") {
|
|
1381
|
+
return `${base} | Asset CAGR: ${formatPercent(asNumber(comparison.asset_cagr))} | Streaks: asset ${formatInteger(asNumber(comparison.asset_growth_streak))}, ROA decline ${formatInteger(asNumber(comparison.roa_decline_streak))}`;
|
|
1382
|
+
}
|
|
1383
|
+
return base;
|
|
1384
|
+
});
|
|
1385
|
+
const insights = output.insights ? Object.entries(output.insights).filter(([, names]) => names.length > 0).map(([label, names]) => `${label}: ${names.join(", ")}`).join("\n") : "";
|
|
1386
|
+
return insights ? `${header}
|
|
1387
|
+
${rows.join("\n")}
|
|
1388
|
+
Insights
|
|
1389
|
+
${insights}` : `${header}
|
|
1390
|
+
${rows.join("\n")}`;
|
|
1391
|
+
}
|
|
1392
|
+
async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
|
|
1393
|
+
const filterParts = [];
|
|
1394
|
+
if (state) filterParts.push(`STNAME:"${state}"`);
|
|
1395
|
+
if (activeOnly) filterParts.push("ACTIVE:1");
|
|
1396
|
+
if (institutionFilters) filterParts.push(`(${institutionFilters})`);
|
|
1397
|
+
const response = await queryEndpoint(ENDPOINTS.INSTITUTIONS, {
|
|
1398
|
+
filters: filterParts.join(" AND "),
|
|
1399
|
+
fields: "CERT,NAME,CITY,STALP,ACTIVE",
|
|
1400
|
+
limit: 1e4,
|
|
1401
|
+
offset: 0,
|
|
1402
|
+
sort_by: "CERT",
|
|
1403
|
+
sort_order: "ASC"
|
|
1404
|
+
});
|
|
1405
|
+
return extractRecords(response);
|
|
1406
|
+
}
|
|
1407
|
+
async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
|
|
1408
|
+
const certFilters = buildCertFilters(certs);
|
|
1409
|
+
const tasks = repdteFilters.flatMap(
|
|
1410
|
+
(repdteFilter) => certFilters.map((certFilter) => ({
|
|
1411
|
+
repdteFilter,
|
|
1412
|
+
certFilter
|
|
1413
|
+
}))
|
|
1414
|
+
);
|
|
1415
|
+
const responses = await mapWithConcurrency(tasks, MAX_CONCURRENCY, async (task) => {
|
|
1416
|
+
const response = await queryEndpoint(endpoint, {
|
|
1417
|
+
filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
|
|
1418
|
+
fields,
|
|
1419
|
+
limit: 1e4,
|
|
1420
|
+
offset: 0,
|
|
1421
|
+
sort_by: "CERT",
|
|
1422
|
+
sort_order: "ASC"
|
|
1423
|
+
});
|
|
1424
|
+
return { repdteFilter: task.repdteFilter, response };
|
|
1425
|
+
});
|
|
1426
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
1427
|
+
for (const { repdteFilter, response } of responses) {
|
|
1428
|
+
if (!byDate.has(repdteFilter)) {
|
|
1429
|
+
byDate.set(repdteFilter, /* @__PURE__ */ new Map());
|
|
1430
|
+
}
|
|
1431
|
+
const target = byDate.get(repdteFilter);
|
|
1432
|
+
for (const record of extractRecords(response)) {
|
|
1433
|
+
const cert = asNumber(record.CERT);
|
|
1434
|
+
if (cert !== null) target.set(cert, record);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return byDate;
|
|
1438
|
+
}
|
|
1439
|
+
async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
|
|
1440
|
+
const certFilters = buildCertFilters(certs);
|
|
1441
|
+
const responses = await mapWithConcurrency(
|
|
1442
|
+
certFilters,
|
|
1443
|
+
MAX_CONCURRENCY,
|
|
1444
|
+
async (certFilter) => queryEndpoint(endpoint, {
|
|
1445
|
+
filters: `(${certFilter}) AND REPDTE:[${startRepdte} TO ${endRepdte}]`,
|
|
1446
|
+
fields,
|
|
1447
|
+
limit: 1e4,
|
|
1448
|
+
offset: 0,
|
|
1449
|
+
sort_by: "REPDTE",
|
|
1450
|
+
sort_order: "ASC"
|
|
1451
|
+
})
|
|
1452
|
+
);
|
|
1453
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1454
|
+
for (const response of responses) {
|
|
1455
|
+
for (const record of extractRecords(response)) {
|
|
1456
|
+
const cert = asNumber(record.CERT);
|
|
1457
|
+
if (cert === null) continue;
|
|
1458
|
+
if (!grouped.has(cert)) grouped.set(cert, []);
|
|
1459
|
+
grouped.get(cert).push(record);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return grouped;
|
|
1463
|
+
}
|
|
1464
|
+
function buildSnapshotComparison(cert, institution, startFinancial, endFinancial, startDemo, endDemo, startRepdte, endRepdte) {
|
|
1465
|
+
const assetStart = asNumber(startFinancial.ASSET);
|
|
1466
|
+
const assetEnd = asNumber(endFinancial.ASSET);
|
|
1467
|
+
const depStart = asNumber(startFinancial.DEP);
|
|
1468
|
+
const depEnd = asNumber(endFinancial.DEP);
|
|
1469
|
+
const netIncStart = asNumber(startFinancial.NETINC);
|
|
1470
|
+
const netIncEnd = asNumber(endFinancial.NETINC);
|
|
1471
|
+
const roaStart = asNumber(startFinancial.ROA);
|
|
1472
|
+
const roaEnd = asNumber(endFinancial.ROA);
|
|
1473
|
+
const roeStart = asNumber(startFinancial.ROE);
|
|
1474
|
+
const roeEnd = asNumber(endFinancial.ROE);
|
|
1475
|
+
const officesStart = asNumber(startDemo?.OFFTOT);
|
|
1476
|
+
const officesEnd = asNumber(endDemo?.OFFTOT);
|
|
1477
|
+
const assetsPerOfficeStart = ratio(assetStart, officesStart);
|
|
1478
|
+
const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
|
|
1479
|
+
const depositsPerOfficeStart = ratio(depStart, officesStart);
|
|
1480
|
+
const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
|
|
1481
|
+
const depositsToAssetsStart = ratio(depStart, assetStart);
|
|
1482
|
+
const depositsToAssetsEnd = ratio(depEnd, assetEnd);
|
|
1483
|
+
const comparison = {
|
|
1484
|
+
cert,
|
|
1485
|
+
name: endFinancial.NAME ?? startFinancial.NAME ?? institution.NAME,
|
|
1486
|
+
city: institution.CITY,
|
|
1487
|
+
stalp: institution.STALP,
|
|
1488
|
+
analysis_mode: "snapshot",
|
|
1489
|
+
start_repdte: startRepdte,
|
|
1490
|
+
end_repdte: endRepdte,
|
|
1491
|
+
asset_start: assetStart,
|
|
1492
|
+
asset_end: assetEnd,
|
|
1493
|
+
asset_growth: change(assetStart, assetEnd),
|
|
1494
|
+
asset_growth_pct: pctChange(assetStart, assetEnd),
|
|
1495
|
+
dep_start: depStart,
|
|
1496
|
+
dep_end: depEnd,
|
|
1497
|
+
dep_growth: change(depStart, depEnd),
|
|
1498
|
+
dep_growth_pct: pctChange(depStart, depEnd),
|
|
1499
|
+
netinc_start: netIncStart,
|
|
1500
|
+
netinc_end: netIncEnd,
|
|
1501
|
+
netinc_change: change(netIncStart, netIncEnd),
|
|
1502
|
+
netinc_change_pct: pctChange(netIncStart, netIncEnd),
|
|
1503
|
+
roa_start: roaStart,
|
|
1504
|
+
roa_end: roaEnd,
|
|
1505
|
+
roa_change: change(roaStart, roaEnd),
|
|
1506
|
+
roe_start: roeStart,
|
|
1507
|
+
roe_end: roeEnd,
|
|
1508
|
+
roe_change: change(roeStart, roeEnd),
|
|
1509
|
+
offices_start: officesStart,
|
|
1510
|
+
offices_end: officesEnd,
|
|
1511
|
+
offices_change: change(officesStart, officesEnd),
|
|
1512
|
+
assets_per_office_start: assetsPerOfficeStart,
|
|
1513
|
+
assets_per_office_end: assetsPerOfficeEnd,
|
|
1514
|
+
assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
|
|
1515
|
+
deposits_per_office_start: depositsPerOfficeStart,
|
|
1516
|
+
deposits_per_office_end: depositsPerOfficeEnd,
|
|
1517
|
+
deposits_per_office_change: change(
|
|
1518
|
+
depositsPerOfficeStart,
|
|
1519
|
+
depositsPerOfficeEnd
|
|
1520
|
+
),
|
|
1521
|
+
deposits_to_assets_start: depositsToAssetsStart,
|
|
1522
|
+
deposits_to_assets_end: depositsToAssetsEnd,
|
|
1523
|
+
deposits_to_assets_change: change(
|
|
1524
|
+
depositsToAssetsStart,
|
|
1525
|
+
depositsToAssetsEnd
|
|
1526
|
+
),
|
|
1527
|
+
cbsa_start: startDemo?.CBSANAME,
|
|
1528
|
+
cbsa_end: endDemo?.CBSANAME
|
|
1529
|
+
};
|
|
1530
|
+
comparison.insights = classifyInsights(comparison);
|
|
1531
|
+
return comparison;
|
|
1532
|
+
}
|
|
1533
|
+
function registerAnalysisTools(server) {
|
|
1534
|
+
server.registerTool(
|
|
1535
|
+
"fdic_compare_bank_snapshots",
|
|
1536
|
+
{
|
|
1537
|
+
title: "Compare Bank Snapshot Trends",
|
|
1538
|
+
description: `Compare FDIC reporting snapshots across a set of institutions and rank the results by growth, profitability, or efficiency changes.
|
|
1539
|
+
|
|
1540
|
+
This tool is designed for heavier analytical prompts that would otherwise require many separate MCP calls. It batches institution roster lookup, financial snapshots, optional office-count snapshots, and can also fetch a quarterly time series inside the server.
|
|
1541
|
+
|
|
1542
|
+
Good uses:
|
|
1543
|
+
- Identify North Carolina banks with the strongest asset growth from 2021 to 2025
|
|
1544
|
+
- Compare whether deposit growth came with branch expansion or profitability improvement
|
|
1545
|
+
- Rank a specific cert list by ROA, ROE, asset-per-office, or deposit-to-asset changes
|
|
1546
|
+
- Pull a quarterly trend series and highlight inflection points, streaks, and structural shifts
|
|
1547
|
+
|
|
1548
|
+
Inputs:
|
|
1549
|
+
- state or certs: choose a geographic roster or provide a direct comparison set
|
|
1550
|
+
- start_repdte, end_repdte: report dates in YYYYMMDD format
|
|
1551
|
+
- analysis_mode: snapshot or timeseries
|
|
1552
|
+
- institution_filters: optional extra institution filter when building the roster
|
|
1553
|
+
- active_only: default true
|
|
1554
|
+
- include_demographics: default true, adds office-count comparisons when available
|
|
1555
|
+
- sort_by: ranking field such as asset_growth, dep_growth_pct, roa_change, assets_per_office_change
|
|
1556
|
+
- sort_order: ASC or DESC
|
|
1557
|
+
- limit: maximum ranked results to return
|
|
1558
|
+
|
|
1559
|
+
Returns concise comparison text plus structured deltas, derived metrics, and insight tags for each institution.`,
|
|
1560
|
+
inputSchema: SnapshotAnalysisSchema,
|
|
1561
|
+
annotations: {
|
|
1562
|
+
readOnlyHint: true,
|
|
1563
|
+
destructiveHint: false,
|
|
1564
|
+
idempotentHint: true,
|
|
1565
|
+
openWorldHint: true
|
|
1566
|
+
}
|
|
1567
|
+
},
|
|
1568
|
+
async ({
|
|
1569
|
+
state,
|
|
1570
|
+
certs,
|
|
1571
|
+
institution_filters,
|
|
1572
|
+
active_only,
|
|
1573
|
+
start_repdte,
|
|
1574
|
+
end_repdte,
|
|
1575
|
+
analysis_mode,
|
|
1576
|
+
include_demographics,
|
|
1577
|
+
limit,
|
|
1578
|
+
sort_by,
|
|
1579
|
+
sort_order
|
|
1580
|
+
}) => {
|
|
1581
|
+
try {
|
|
1582
|
+
const roster = certs && certs.length > 0 ? certs.map((cert) => ({ CERT: cert })) : await fetchInstitutionRoster(
|
|
1583
|
+
state,
|
|
1584
|
+
institution_filters,
|
|
1585
|
+
active_only
|
|
1586
|
+
);
|
|
1587
|
+
const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
|
|
1588
|
+
if (candidateCerts.length === 0) {
|
|
1589
|
+
const output2 = {
|
|
1590
|
+
total_candidates: 0,
|
|
1591
|
+
analyzed_count: 0,
|
|
1592
|
+
start_repdte,
|
|
1593
|
+
end_repdte,
|
|
1594
|
+
analysis_mode,
|
|
1595
|
+
sort_by,
|
|
1596
|
+
sort_order,
|
|
1597
|
+
comparisons: []
|
|
1598
|
+
};
|
|
1599
|
+
return {
|
|
1600
|
+
content: [
|
|
1601
|
+
{ type: "text", text: "No institutions matched the comparison set." }
|
|
1602
|
+
],
|
|
1603
|
+
structuredContent: output2
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
const rosterByCert = new Map(
|
|
1607
|
+
roster.map((record) => [asNumber(record.CERT), record]).filter(
|
|
1608
|
+
(entry) => entry[0] !== null
|
|
1609
|
+
)
|
|
1610
|
+
);
|
|
1611
|
+
let comparisons = [];
|
|
1612
|
+
if (analysis_mode === "timeseries") {
|
|
1613
|
+
const [financialSeries, demographicsSeries] = await Promise.all([
|
|
1614
|
+
fetchSeriesRecords(
|
|
1615
|
+
ENDPOINTS.FINANCIALS,
|
|
1616
|
+
candidateCerts,
|
|
1617
|
+
start_repdte,
|
|
1618
|
+
end_repdte,
|
|
1619
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1620
|
+
),
|
|
1621
|
+
include_demographics ? fetchSeriesRecords(
|
|
1622
|
+
ENDPOINTS.DEMOGRAPHICS,
|
|
1623
|
+
candidateCerts,
|
|
1624
|
+
start_repdte,
|
|
1625
|
+
end_repdte,
|
|
1626
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1627
|
+
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1628
|
+
]);
|
|
1629
|
+
comparisons = candidateCerts.map(
|
|
1630
|
+
(cert) => summarizeTimeSeries(
|
|
1631
|
+
financialSeries.get(cert) ?? [],
|
|
1632
|
+
new Map(
|
|
1633
|
+
(demographicsSeries.get(cert) ?? []).map((record) => [
|
|
1634
|
+
String(record.REPDTE),
|
|
1635
|
+
record
|
|
1636
|
+
])
|
|
1637
|
+
),
|
|
1638
|
+
rosterByCert.get(cert) ?? {}
|
|
1639
|
+
)
|
|
1640
|
+
).filter((comparison) => comparison !== null);
|
|
1641
|
+
} else {
|
|
1642
|
+
const [financialSnapshots, demographicSnapshots] = await Promise.all([
|
|
1643
|
+
fetchBatchedRecordsForDates(
|
|
1644
|
+
ENDPOINTS.FINANCIALS,
|
|
1645
|
+
candidateCerts,
|
|
1646
|
+
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1647
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1648
|
+
),
|
|
1649
|
+
include_demographics ? fetchBatchedRecordsForDates(
|
|
1650
|
+
ENDPOINTS.DEMOGRAPHICS,
|
|
1651
|
+
candidateCerts,
|
|
1652
|
+
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1653
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1654
|
+
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1655
|
+
]);
|
|
1656
|
+
const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1657
|
+
const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1658
|
+
const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1659
|
+
const endDemographics = demographicSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1660
|
+
comparisons = candidateCerts.map((cert) => {
|
|
1661
|
+
const startFinancial = startFinancials.get(cert);
|
|
1662
|
+
const endFinancial = endFinancials.get(cert);
|
|
1663
|
+
if (!startFinancial || !endFinancial) return null;
|
|
1664
|
+
return buildSnapshotComparison(
|
|
1665
|
+
cert,
|
|
1666
|
+
rosterByCert.get(cert) ?? {},
|
|
1667
|
+
startFinancial,
|
|
1668
|
+
endFinancial,
|
|
1669
|
+
startDemographics.get(cert),
|
|
1670
|
+
endDemographics.get(cert),
|
|
1671
|
+
start_repdte,
|
|
1672
|
+
end_repdte
|
|
1673
|
+
);
|
|
1674
|
+
}).filter((comparison) => comparison !== null);
|
|
1675
|
+
}
|
|
1676
|
+
const ranked = sortComparisons(comparisons, sort_by, sort_order).slice(
|
|
1677
|
+
0,
|
|
1678
|
+
limit
|
|
1679
|
+
);
|
|
1680
|
+
const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
|
|
1681
|
+
const output = {
|
|
1682
|
+
total_candidates: candidateCerts.length,
|
|
1683
|
+
analyzed_count: comparisons.length,
|
|
1684
|
+
start_repdte,
|
|
1685
|
+
end_repdte,
|
|
1686
|
+
analysis_mode,
|
|
1687
|
+
sort_by,
|
|
1688
|
+
sort_order,
|
|
1689
|
+
insights: buildTopLevelInsights(comparisons),
|
|
1690
|
+
...pagination,
|
|
1691
|
+
comparisons: ranked
|
|
1692
|
+
};
|
|
1693
|
+
const text = truncateIfNeeded(
|
|
1694
|
+
formatComparisonText(output),
|
|
915
1695
|
CHARACTER_LIMIT
|
|
916
1696
|
);
|
|
917
1697
|
return {
|
|
@@ -938,6 +1718,7 @@ function createServer() {
|
|
|
938
1718
|
registerFinancialTools(server);
|
|
939
1719
|
registerSodTools(server);
|
|
940
1720
|
registerDemographicsTools(server);
|
|
1721
|
+
registerAnalysisTools(server);
|
|
941
1722
|
return server;
|
|
942
1723
|
}
|
|
943
1724
|
async function runStdio() {
|