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/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.7" : 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 = {
|
|
@@ -54,42 +54,73 @@ var apiClient = import_axios.default.create({
|
|
|
54
54
|
"User-Agent": `fdic-mcp-server/${VERSION}`
|
|
55
55
|
}
|
|
56
56
|
});
|
|
57
|
+
var QUERY_CACHE_TTL_MS = 6e4;
|
|
58
|
+
var queryCache = /* @__PURE__ */ new Map();
|
|
59
|
+
function getCacheKey(endpoint, params) {
|
|
60
|
+
return JSON.stringify([
|
|
61
|
+
endpoint,
|
|
62
|
+
params.filters ?? null,
|
|
63
|
+
params.fields ?? null,
|
|
64
|
+
params.limit ?? null,
|
|
65
|
+
params.offset ?? null,
|
|
66
|
+
params.sort_by ?? null,
|
|
67
|
+
params.sort_order ?? null
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
57
70
|
async function queryEndpoint(endpoint, params) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
71
|
+
const cacheKey = getCacheKey(endpoint, params);
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const cached = queryCache.get(cacheKey);
|
|
74
|
+
if (cached && cached.expiresAt > now) {
|
|
75
|
+
return cached.value;
|
|
76
|
+
}
|
|
77
|
+
const requestPromise = (async () => {
|
|
78
|
+
try {
|
|
79
|
+
const queryParams = {
|
|
80
|
+
limit: params.limit ?? 20,
|
|
81
|
+
offset: params.offset ?? 0,
|
|
82
|
+
output: "json"
|
|
83
|
+
};
|
|
84
|
+
if (params.filters) queryParams.filters = params.filters;
|
|
85
|
+
if (params.fields) queryParams.fields = params.fields;
|
|
86
|
+
if (params.sort_by) queryParams.sort_by = params.sort_by;
|
|
87
|
+
if (params.sort_order) queryParams.sort_order = params.sort_order;
|
|
88
|
+
const response = await apiClient.get(`/${endpoint}`, {
|
|
89
|
+
params: queryParams
|
|
90
|
+
});
|
|
91
|
+
return response.data;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err instanceof import_axios.AxiosError) {
|
|
94
|
+
const status = err.response?.status;
|
|
95
|
+
const detail = err.response?.data?.message ?? err.message;
|
|
96
|
+
if (status === 400) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Bad request to FDIC API: ${detail}. Check your filter syntax (use ElasticSearch query string syntax, e.g. STNAME:"California" AND ACTIVE:1).`
|
|
99
|
+
);
|
|
100
|
+
} else if (status === 429) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"FDIC API rate limit exceeded. Please wait a moment and try again."
|
|
103
|
+
);
|
|
104
|
+
} else if (status === 500) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"FDIC API server error. The service may be temporarily unavailable. Try again later."
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
throw new Error(`FDIC API error (HTTP ${status}): ${detail}`);
|
|
110
|
+
}
|
|
90
111
|
}
|
|
112
|
+
throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
|
|
91
113
|
}
|
|
92
|
-
|
|
114
|
+
})();
|
|
115
|
+
queryCache.set(cacheKey, {
|
|
116
|
+
expiresAt: now + QUERY_CACHE_TTL_MS,
|
|
117
|
+
value: requestPromise
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
return await requestPromise;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
queryCache.delete(cacheKey);
|
|
123
|
+
throw error;
|
|
93
124
|
}
|
|
94
125
|
}
|
|
95
126
|
function extractRecords(response) {
|
|
@@ -111,6 +142,37 @@ function truncateIfNeeded(text, charLimit) {
|
|
|
111
142
|
|
|
112
143
|
[Response truncated at ${charLimit} characters. Use limit/offset parameters to paginate or narrow your query with filters.]`;
|
|
113
144
|
}
|
|
145
|
+
function formatValue(value) {
|
|
146
|
+
if (value === null || value === void 0) return "n/a";
|
|
147
|
+
if (typeof value === "number") return Number.isInteger(value) ? `${value}` : value.toFixed(4);
|
|
148
|
+
if (typeof value === "string") return value;
|
|
149
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
150
|
+
return JSON.stringify(value);
|
|
151
|
+
}
|
|
152
|
+
function summarizeRecord(record, preferredKeys, maxFields = 4) {
|
|
153
|
+
const orderedKeys = [
|
|
154
|
+
...preferredKeys.filter((key) => key in record),
|
|
155
|
+
...Object.keys(record).filter(
|
|
156
|
+
(key) => key !== "ID" && !preferredKeys.includes(key)
|
|
157
|
+
)
|
|
158
|
+
];
|
|
159
|
+
return orderedKeys.slice(0, maxFields).map((key) => `${key}: ${formatValue(record[key])}`).join(" | ");
|
|
160
|
+
}
|
|
161
|
+
function formatSearchResultText(label, records, pagination, preferredKeys) {
|
|
162
|
+
const header = `Found ${pagination.total} ${label} (showing ${pagination.count}, offset ${pagination.offset}).`;
|
|
163
|
+
if (records.length === 0) {
|
|
164
|
+
return header;
|
|
165
|
+
}
|
|
166
|
+
const rows = records.map((record, index) => `${index + 1}. ${summarizeRecord(record, preferredKeys)}`).join("\n");
|
|
167
|
+
const footer = pagination.has_more ? `
|
|
168
|
+
More results available. Use offset ${pagination.next_offset} to continue.` : "";
|
|
169
|
+
return `${header}
|
|
170
|
+
${rows}${footer}`;
|
|
171
|
+
}
|
|
172
|
+
function formatLookupResultText(label, record, preferredKeys) {
|
|
173
|
+
return `${label}
|
|
174
|
+
${summarizeRecord(record, preferredKeys, 8)}`;
|
|
175
|
+
}
|
|
114
176
|
function formatToolError(err) {
|
|
115
177
|
const message = err instanceof Error ? err.message : String(err);
|
|
116
178
|
return {
|
|
@@ -183,7 +245,7 @@ Args:
|
|
|
183
245
|
- sort_by (string, optional): Field to sort by
|
|
184
246
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
185
247
|
|
|
186
|
-
|
|
248
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and institution records.`,
|
|
187
249
|
inputSchema: CommonQuerySchema,
|
|
188
250
|
annotations: {
|
|
189
251
|
readOnlyHint: true,
|
|
@@ -203,7 +265,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, institutions[]
|
|
|
203
265
|
);
|
|
204
266
|
const output = { ...pagination, institutions: records };
|
|
205
267
|
const text = truncateIfNeeded(
|
|
206
|
-
|
|
268
|
+
formatSearchResultText("institutions", records, pagination, [
|
|
269
|
+
"CERT",
|
|
270
|
+
"NAME",
|
|
271
|
+
"CITY",
|
|
272
|
+
"STALP",
|
|
273
|
+
"ASSET",
|
|
274
|
+
"ACTIVE"
|
|
275
|
+
]),
|
|
207
276
|
CHARACTER_LIMIT
|
|
208
277
|
);
|
|
209
278
|
return {
|
|
@@ -227,7 +296,7 @@ Args:
|
|
|
227
296
|
- cert (number): FDIC Certificate Number (e.g., 3511 for Bank of America)
|
|
228
297
|
- fields (string, optional): Comma-separated list of fields to return
|
|
229
298
|
|
|
230
|
-
Returns
|
|
299
|
+
Returns a detailed institution profile suitable for concise summaries, with structured fields available for exact values when needed.`,
|
|
231
300
|
inputSchema: CertSchema,
|
|
232
301
|
annotations: {
|
|
233
302
|
readOnlyHint: true,
|
|
@@ -256,7 +325,16 @@ Returns full institution profile including financial metrics, charter info, loca
|
|
|
256
325
|
};
|
|
257
326
|
}
|
|
258
327
|
const output = records[0];
|
|
259
|
-
const text =
|
|
328
|
+
const text = formatLookupResultText("Institution details", output, [
|
|
329
|
+
"CERT",
|
|
330
|
+
"NAME",
|
|
331
|
+
"CITY",
|
|
332
|
+
"STALP",
|
|
333
|
+
"ASSET",
|
|
334
|
+
"DEP",
|
|
335
|
+
"ACTIVE",
|
|
336
|
+
"REGAGNT"
|
|
337
|
+
]);
|
|
260
338
|
return {
|
|
261
339
|
content: [{ type: "text", text }],
|
|
262
340
|
structuredContent: output
|
|
@@ -304,7 +382,7 @@ Args:
|
|
|
304
382
|
- sort_by (string, optional): Field to sort by (e.g., FAILDATE, COST)
|
|
305
383
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
306
384
|
|
|
307
|
-
|
|
385
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and failure records.`,
|
|
308
386
|
inputSchema: CommonQuerySchema,
|
|
309
387
|
annotations: {
|
|
310
388
|
readOnlyHint: true,
|
|
@@ -324,7 +402,15 @@ Returns JSON with { total, offset, count, has_more, next_offset?, failures[] }`,
|
|
|
324
402
|
);
|
|
325
403
|
const output = { ...pagination, failures: records };
|
|
326
404
|
const text = truncateIfNeeded(
|
|
327
|
-
|
|
405
|
+
formatSearchResultText("failures", records, pagination, [
|
|
406
|
+
"CERT",
|
|
407
|
+
"NAME",
|
|
408
|
+
"CITY",
|
|
409
|
+
"STALP",
|
|
410
|
+
"FAILDATE",
|
|
411
|
+
"COST",
|
|
412
|
+
"RESTYPE"
|
|
413
|
+
]),
|
|
328
414
|
CHARACTER_LIMIT
|
|
329
415
|
);
|
|
330
416
|
return {
|
|
@@ -348,7 +434,7 @@ Args:
|
|
|
348
434
|
- cert (number): FDIC Certificate Number of the failed institution
|
|
349
435
|
- fields (string, optional): Comma-separated list of fields to return
|
|
350
436
|
|
|
351
|
-
Returns failure
|
|
437
|
+
Returns detailed failure information suitable for concise summaries, with structured fields available for exact values when needed.`,
|
|
352
438
|
inputSchema: CertSchema,
|
|
353
439
|
annotations: {
|
|
354
440
|
readOnlyHint: true,
|
|
@@ -377,7 +463,16 @@ Returns failure details including failure date, resolution method, and cost to F
|
|
|
377
463
|
};
|
|
378
464
|
}
|
|
379
465
|
const output = records[0];
|
|
380
|
-
const text =
|
|
466
|
+
const text = formatLookupResultText("Failure details", output, [
|
|
467
|
+
"CERT",
|
|
468
|
+
"NAME",
|
|
469
|
+
"FAILDATE",
|
|
470
|
+
"RESTYPE",
|
|
471
|
+
"COST",
|
|
472
|
+
"QBFASSET",
|
|
473
|
+
"CITY",
|
|
474
|
+
"STALP"
|
|
475
|
+
]);
|
|
381
476
|
return {
|
|
382
477
|
content: [{ type: "text", text }],
|
|
383
478
|
structuredContent: output
|
|
@@ -435,7 +530,7 @@ Args:
|
|
|
435
530
|
- sort_by (string, optional): Field to sort by
|
|
436
531
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
437
532
|
|
|
438
|
-
|
|
533
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and branch location records.`,
|
|
439
534
|
inputSchema: LocationQuerySchema,
|
|
440
535
|
annotations: {
|
|
441
536
|
readOnlyHint: true,
|
|
@@ -462,7 +557,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, locations[] }`
|
|
|
462
557
|
);
|
|
463
558
|
const output = { ...pagination, locations: records };
|
|
464
559
|
const text = truncateIfNeeded(
|
|
465
|
-
|
|
560
|
+
formatSearchResultText("locations", records, pagination, [
|
|
561
|
+
"CERT",
|
|
562
|
+
"UNINAME",
|
|
563
|
+
"NAMEFULL",
|
|
564
|
+
"CITY",
|
|
565
|
+
"STALP",
|
|
566
|
+
"BRNUM"
|
|
567
|
+
]),
|
|
466
568
|
CHARACTER_LIMIT
|
|
467
569
|
);
|
|
468
570
|
return {
|
|
@@ -523,7 +625,7 @@ Args:
|
|
|
523
625
|
- sort_by (string, optional): Field to sort by (e.g., PROCDATE)
|
|
524
626
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
525
627
|
|
|
526
|
-
|
|
628
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and event records.`,
|
|
527
629
|
inputSchema: HistoryQuerySchema,
|
|
528
630
|
annotations: {
|
|
529
631
|
readOnlyHint: true,
|
|
@@ -550,7 +652,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, events[] }`,
|
|
|
550
652
|
);
|
|
551
653
|
const output = { ...pagination, events: records };
|
|
552
654
|
const text = truncateIfNeeded(
|
|
553
|
-
|
|
655
|
+
formatSearchResultText("events", records, pagination, [
|
|
656
|
+
"CERT",
|
|
657
|
+
"INSTNAME",
|
|
658
|
+
"TYPE",
|
|
659
|
+
"PROCDATE",
|
|
660
|
+
"PCITY",
|
|
661
|
+
"PSTALP"
|
|
662
|
+
]),
|
|
554
663
|
CHARACTER_LIMIT
|
|
555
664
|
);
|
|
556
665
|
return {
|
|
@@ -620,7 +729,7 @@ Args:
|
|
|
620
729
|
- sort_by (string, optional): Field to sort by
|
|
621
730
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'DESC' recommended for most recent first)
|
|
622
731
|
|
|
623
|
-
|
|
732
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and quarterly financial records.`,
|
|
624
733
|
inputSchema: FinancialQuerySchema,
|
|
625
734
|
annotations: {
|
|
626
735
|
readOnlyHint: true,
|
|
@@ -647,7 +756,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, financials[] }
|
|
|
647
756
|
);
|
|
648
757
|
const output = { ...pagination, financials: records };
|
|
649
758
|
const text = truncateIfNeeded(
|
|
650
|
-
|
|
759
|
+
formatSearchResultText("financial records", records, pagination, [
|
|
760
|
+
"CERT",
|
|
761
|
+
"NAME",
|
|
762
|
+
"REPDTE",
|
|
763
|
+
"ASSET",
|
|
764
|
+
"DEP",
|
|
765
|
+
"NETINC"
|
|
766
|
+
]),
|
|
651
767
|
CHARACTER_LIMIT
|
|
652
768
|
);
|
|
653
769
|
return {
|
|
@@ -695,7 +811,7 @@ Args:
|
|
|
695
811
|
- sort_by (string, optional): Field to sort by (e.g., YEAR, ASSET)
|
|
696
812
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
697
813
|
|
|
698
|
-
|
|
814
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and annual summary records.`,
|
|
699
815
|
inputSchema: SummaryQuerySchema,
|
|
700
816
|
annotations: {
|
|
701
817
|
readOnlyHint: true,
|
|
@@ -722,7 +838,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, summary[] }`,
|
|
|
722
838
|
);
|
|
723
839
|
const output = { ...pagination, summary: records };
|
|
724
840
|
const text = truncateIfNeeded(
|
|
725
|
-
|
|
841
|
+
formatSearchResultText("annual summary records", records, pagination, [
|
|
842
|
+
"CERT",
|
|
843
|
+
"YEAR",
|
|
844
|
+
"ASSET",
|
|
845
|
+
"DEP",
|
|
846
|
+
"NETINC",
|
|
847
|
+
"ROA"
|
|
848
|
+
]),
|
|
726
849
|
CHARACTER_LIMIT
|
|
727
850
|
);
|
|
728
851
|
return {
|
|
@@ -784,7 +907,7 @@ Args:
|
|
|
784
907
|
- sort_by (string, optional): Field to sort by (e.g., DEPSUMBR, YEAR)
|
|
785
908
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
786
909
|
|
|
787
|
-
|
|
910
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and deposit records.`,
|
|
788
911
|
inputSchema: SodQuerySchema,
|
|
789
912
|
annotations: {
|
|
790
913
|
readOnlyHint: true,
|
|
@@ -811,7 +934,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, deposits[] }`,
|
|
|
811
934
|
);
|
|
812
935
|
const output = { ...pagination, deposits: records };
|
|
813
936
|
const text = truncateIfNeeded(
|
|
814
|
-
|
|
937
|
+
formatSearchResultText("deposit records", records, pagination, [
|
|
938
|
+
"CERT",
|
|
939
|
+
"YEAR",
|
|
940
|
+
"UNINAME",
|
|
941
|
+
"NAMEFULL",
|
|
942
|
+
"CITYBR",
|
|
943
|
+
"DEPSUMBR"
|
|
944
|
+
]),
|
|
815
945
|
CHARACTER_LIMIT
|
|
816
946
|
);
|
|
817
947
|
return {
|
|
@@ -873,7 +1003,7 @@ Args:
|
|
|
873
1003
|
- sort_by (string, optional): Field to sort by
|
|
874
1004
|
- sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
|
|
875
1005
|
|
|
876
|
-
|
|
1006
|
+
Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and demographic records.`,
|
|
877
1007
|
inputSchema: DemographicsQuerySchema,
|
|
878
1008
|
annotations: {
|
|
879
1009
|
readOnlyHint: true,
|
|
@@ -900,7 +1030,657 @@ Returns JSON with { total, offset, count, has_more, next_offset?, demographics[]
|
|
|
900
1030
|
);
|
|
901
1031
|
const output = { ...pagination, demographics: records };
|
|
902
1032
|
const text = truncateIfNeeded(
|
|
903
|
-
|
|
1033
|
+
formatSearchResultText("demographic records", records, pagination, [
|
|
1034
|
+
"CERT",
|
|
1035
|
+
"REPDTE",
|
|
1036
|
+
"OFFTOT",
|
|
1037
|
+
"OFFSTATE",
|
|
1038
|
+
"METRO",
|
|
1039
|
+
"CBSANAME"
|
|
1040
|
+
]),
|
|
1041
|
+
CHARACTER_LIMIT
|
|
1042
|
+
);
|
|
1043
|
+
return {
|
|
1044
|
+
content: [{ type: "text", text }],
|
|
1045
|
+
structuredContent: output
|
|
1046
|
+
};
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
return formatToolError(err);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/tools/analysis.ts
|
|
1055
|
+
var import_zod7 = require("zod");
|
|
1056
|
+
var CHUNK_SIZE = 25;
|
|
1057
|
+
var MAX_CONCURRENCY = 4;
|
|
1058
|
+
var SortFieldSchema = import_zod7.z.enum([
|
|
1059
|
+
"asset_growth",
|
|
1060
|
+
"asset_growth_pct",
|
|
1061
|
+
"dep_growth",
|
|
1062
|
+
"dep_growth_pct",
|
|
1063
|
+
"netinc_change",
|
|
1064
|
+
"netinc_change_pct",
|
|
1065
|
+
"roa_change",
|
|
1066
|
+
"roe_change",
|
|
1067
|
+
"offices_change",
|
|
1068
|
+
"assets_per_office_change",
|
|
1069
|
+
"deposits_per_office_change",
|
|
1070
|
+
"deposits_to_assets_change"
|
|
1071
|
+
]);
|
|
1072
|
+
var AnalysisModeSchema = import_zod7.z.enum(["snapshot", "timeseries"]);
|
|
1073
|
+
var SnapshotAnalysisSchema = import_zod7.z.object({
|
|
1074
|
+
state: import_zod7.z.string().optional().describe(
|
|
1075
|
+
'State name for the institution roster filter. Example: "North Carolina"'
|
|
1076
|
+
),
|
|
1077
|
+
certs: import_zod7.z.array(import_zod7.z.number().int().positive()).max(100).optional().describe(
|
|
1078
|
+
"Optional list of FDIC certificate numbers to compare directly. Max 100."
|
|
1079
|
+
),
|
|
1080
|
+
institution_filters: import_zod7.z.string().optional().describe(
|
|
1081
|
+
'Additional institution-level filter used when building the comparison set. Example: BKCLASS:N or CITY:"Charlotte"'
|
|
1082
|
+
),
|
|
1083
|
+
active_only: import_zod7.z.boolean().default(true).describe("Limit the comparison set to currently active institutions."),
|
|
1084
|
+
start_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Starting report date in YYYYMMDD format."),
|
|
1085
|
+
end_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Ending report date in YYYYMMDD format."),
|
|
1086
|
+
analysis_mode: AnalysisModeSchema.default("snapshot").describe(
|
|
1087
|
+
"Use snapshot for two-point comparison or timeseries for quarterly trend analysis across the date range."
|
|
1088
|
+
),
|
|
1089
|
+
include_demographics: import_zod7.z.boolean().default(true).describe(
|
|
1090
|
+
"Include office-count changes from the demographics dataset when available."
|
|
1091
|
+
),
|
|
1092
|
+
limit: import_zod7.z.number().int().min(1).max(100).default(10).describe("Maximum number of ranked comparisons to return."),
|
|
1093
|
+
sort_by: SortFieldSchema.default("asset_growth").describe(
|
|
1094
|
+
"Comparison field used to rank institutions."
|
|
1095
|
+
),
|
|
1096
|
+
sort_order: import_zod7.z.enum(["ASC", "DESC"]).default("DESC").describe("Sort direction for the ranked comparisons.")
|
|
1097
|
+
}).superRefine((value, ctx) => {
|
|
1098
|
+
if (!value.state && (!value.certs || value.certs.length === 0)) {
|
|
1099
|
+
ctx.addIssue({
|
|
1100
|
+
code: import_zod7.z.ZodIssueCode.custom,
|
|
1101
|
+
message: "Provide either state or certs.",
|
|
1102
|
+
path: ["state"]
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
function asNumber(value) {
|
|
1107
|
+
return typeof value === "number" ? value : null;
|
|
1108
|
+
}
|
|
1109
|
+
function buildCertFilters(certs) {
|
|
1110
|
+
const filters = [];
|
|
1111
|
+
for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
|
|
1112
|
+
const chunk = certs.slice(i, i + CHUNK_SIZE);
|
|
1113
|
+
filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
|
|
1114
|
+
}
|
|
1115
|
+
return filters;
|
|
1116
|
+
}
|
|
1117
|
+
async function mapWithConcurrency(values, limit, mapper) {
|
|
1118
|
+
const results = new Array(values.length);
|
|
1119
|
+
let nextIndex = 0;
|
|
1120
|
+
async function worker() {
|
|
1121
|
+
while (true) {
|
|
1122
|
+
const currentIndex = nextIndex;
|
|
1123
|
+
nextIndex += 1;
|
|
1124
|
+
if (currentIndex >= values.length) return;
|
|
1125
|
+
results[currentIndex] = await mapper(values[currentIndex], currentIndex);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
await Promise.all(
|
|
1129
|
+
Array.from({ length: Math.min(limit, values.length) }, () => worker())
|
|
1130
|
+
);
|
|
1131
|
+
return results;
|
|
1132
|
+
}
|
|
1133
|
+
function ratio(numerator, denominator) {
|
|
1134
|
+
if (numerator === null || denominator === null || denominator === 0) {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
return numerator / denominator;
|
|
1138
|
+
}
|
|
1139
|
+
function pctChange(start, end) {
|
|
1140
|
+
if (start === null || end === null || start === 0) return null;
|
|
1141
|
+
return (end - start) / start * 100;
|
|
1142
|
+
}
|
|
1143
|
+
function change(start, end) {
|
|
1144
|
+
if (start === null || end === null) return null;
|
|
1145
|
+
return end - start;
|
|
1146
|
+
}
|
|
1147
|
+
function yearsBetween(startRepdte, endRepdte) {
|
|
1148
|
+
const start = /* @__PURE__ */ new Date(
|
|
1149
|
+
`${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
|
|
1150
|
+
);
|
|
1151
|
+
const end = /* @__PURE__ */ new Date(
|
|
1152
|
+
`${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
|
|
1153
|
+
);
|
|
1154
|
+
return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
|
|
1155
|
+
}
|
|
1156
|
+
function cagr(start, end, years) {
|
|
1157
|
+
if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
return (Math.pow(end / start, 1 / years) - 1) * 100;
|
|
1161
|
+
}
|
|
1162
|
+
function formatPercent(value) {
|
|
1163
|
+
return value === null ? "n/a" : `${value.toFixed(1)}%`;
|
|
1164
|
+
}
|
|
1165
|
+
function formatChange(value, digits = 4) {
|
|
1166
|
+
if (value === null) return "n/a";
|
|
1167
|
+
return value.toFixed(digits);
|
|
1168
|
+
}
|
|
1169
|
+
function formatInteger(value) {
|
|
1170
|
+
return value === null ? "n/a" : `${Math.round(value).toLocaleString()}`;
|
|
1171
|
+
}
|
|
1172
|
+
function sortComparisons(comparisons, sortBy, sortOrder) {
|
|
1173
|
+
const direction = sortOrder === "ASC" ? 1 : -1;
|
|
1174
|
+
return [...comparisons].sort((left, right) => {
|
|
1175
|
+
const leftValue = asNumber(left[sortBy]) ?? Number.NEGATIVE_INFINITY;
|
|
1176
|
+
const rightValue = asNumber(right[sortBy]) ?? Number.NEGATIVE_INFINITY;
|
|
1177
|
+
return (leftValue - rightValue) * direction;
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
function classifyInsights(comparison) {
|
|
1181
|
+
const insights = [];
|
|
1182
|
+
const assetGrowthPct = asNumber(comparison.asset_growth_pct);
|
|
1183
|
+
const depGrowthPct = asNumber(comparison.dep_growth_pct);
|
|
1184
|
+
const roaChange = asNumber(comparison.roa_change);
|
|
1185
|
+
const roeChange = asNumber(comparison.roe_change);
|
|
1186
|
+
const officesChange = asNumber(comparison.offices_change);
|
|
1187
|
+
const depositsToAssetsChange = asNumber(comparison.deposits_to_assets_change);
|
|
1188
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && roaChange !== null && roaChange > 0) {
|
|
1189
|
+
insights.push("growth_with_better_profitability");
|
|
1190
|
+
}
|
|
1191
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && officesChange !== null && officesChange > 0) {
|
|
1192
|
+
insights.push("growth_with_branch_expansion");
|
|
1193
|
+
}
|
|
1194
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 20 && (roaChange === null || roaChange <= 0) && (roeChange === null || roeChange <= 0)) {
|
|
1195
|
+
insights.push("balance_sheet_growth_without_profitability");
|
|
1196
|
+
}
|
|
1197
|
+
if (assetGrowthPct !== null && assetGrowthPct > 0 && officesChange !== null && officesChange < 0) {
|
|
1198
|
+
insights.push("growth_with_branch_consolidation");
|
|
1199
|
+
}
|
|
1200
|
+
if (depositsToAssetsChange !== null && depositsToAssetsChange < 0 && depGrowthPct !== null && depGrowthPct < 0) {
|
|
1201
|
+
insights.push("deposit_mix_softening");
|
|
1202
|
+
}
|
|
1203
|
+
return insights;
|
|
1204
|
+
}
|
|
1205
|
+
function buildTopLevelInsights(comparisons) {
|
|
1206
|
+
return {
|
|
1207
|
+
growth_with_better_profitability: comparisons.filter(
|
|
1208
|
+
(comparison) => comparison.insights?.includes(
|
|
1209
|
+
"growth_with_better_profitability"
|
|
1210
|
+
)
|
|
1211
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1212
|
+
growth_with_branch_expansion: comparisons.filter(
|
|
1213
|
+
(comparison) => comparison.insights?.includes(
|
|
1214
|
+
"growth_with_branch_expansion"
|
|
1215
|
+
)
|
|
1216
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1217
|
+
balance_sheet_growth_without_profitability: comparisons.filter(
|
|
1218
|
+
(comparison) => comparison.insights?.includes(
|
|
1219
|
+
"balance_sheet_growth_without_profitability"
|
|
1220
|
+
)
|
|
1221
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1222
|
+
growth_with_branch_consolidation: comparisons.filter(
|
|
1223
|
+
(comparison) => comparison.insights?.includes(
|
|
1224
|
+
"growth_with_branch_consolidation"
|
|
1225
|
+
)
|
|
1226
|
+
).slice(0, 5).map((comparison) => String(comparison.name))
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
function longestMonotonicStreak(values, direction) {
|
|
1230
|
+
let longest = 0;
|
|
1231
|
+
let current = 0;
|
|
1232
|
+
for (let i = 1; i < values.length; i += 1) {
|
|
1233
|
+
const previous = values[i - 1];
|
|
1234
|
+
const next = values[i];
|
|
1235
|
+
if (previous === null || next === null) {
|
|
1236
|
+
current = 0;
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
const matches = direction === "up" ? next > previous : next < previous;
|
|
1240
|
+
if (matches) {
|
|
1241
|
+
current += 1;
|
|
1242
|
+
longest = Math.max(longest, current);
|
|
1243
|
+
} else {
|
|
1244
|
+
current = 0;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return longest;
|
|
1248
|
+
}
|
|
1249
|
+
function summarizeTimeSeries(records, demographicsByDate, institution) {
|
|
1250
|
+
if (records.length < 2) return null;
|
|
1251
|
+
const sorted = [...records].sort(
|
|
1252
|
+
(left, right) => String(left.REPDTE).localeCompare(String(right.REPDTE))
|
|
1253
|
+
);
|
|
1254
|
+
const start = sorted[0];
|
|
1255
|
+
const end = sorted[sorted.length - 1];
|
|
1256
|
+
const startRepdte = String(start.REPDTE);
|
|
1257
|
+
const endRepdte = String(end.REPDTE);
|
|
1258
|
+
const years = yearsBetween(startRepdte, endRepdte);
|
|
1259
|
+
const assetSeries = sorted.map((record) => asNumber(record.ASSET));
|
|
1260
|
+
const roaSeries = sorted.map((record) => asNumber(record.ROA));
|
|
1261
|
+
const roeSeries = sorted.map((record) => asNumber(record.ROE));
|
|
1262
|
+
const officesSeries = sorted.map(
|
|
1263
|
+
(record) => asNumber(demographicsByDate.get(String(record.REPDTE))?.OFFTOT)
|
|
1264
|
+
);
|
|
1265
|
+
const assetStart = asNumber(start.ASSET);
|
|
1266
|
+
const assetEnd = asNumber(end.ASSET);
|
|
1267
|
+
const depStart = asNumber(start.DEP);
|
|
1268
|
+
const depEnd = asNumber(end.DEP);
|
|
1269
|
+
const roaStart = asNumber(start.ROA);
|
|
1270
|
+
const roaEnd = asNumber(end.ROA);
|
|
1271
|
+
const roeStart = asNumber(start.ROE);
|
|
1272
|
+
const roeEnd = asNumber(end.ROE);
|
|
1273
|
+
const netIncStart = asNumber(start.NETINC);
|
|
1274
|
+
const netIncEnd = asNumber(end.NETINC);
|
|
1275
|
+
const officesStart = officesSeries[0] ?? null;
|
|
1276
|
+
const officesEnd = officesSeries[officesSeries.length - 1] ?? null;
|
|
1277
|
+
const assetsPerOfficeStart = ratio(assetStart, officesStart);
|
|
1278
|
+
const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
|
|
1279
|
+
const depositsPerOfficeStart = ratio(depStart, officesStart);
|
|
1280
|
+
const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
|
|
1281
|
+
const depositsToAssetsStart = ratio(depStart, assetStart);
|
|
1282
|
+
const depositsToAssetsEnd = ratio(depEnd, assetEnd);
|
|
1283
|
+
const peakAsset = Math.max(...assetSeries.filter((value) => value !== null));
|
|
1284
|
+
const troughRoaValues = roaSeries.filter((value) => value !== null);
|
|
1285
|
+
const troughRoa = troughRoaValues.length > 0 ? Math.min(...troughRoaValues) : null;
|
|
1286
|
+
const comparison = {
|
|
1287
|
+
cert: asNumber(start.CERT),
|
|
1288
|
+
name: end.NAME ?? start.NAME ?? institution.NAME,
|
|
1289
|
+
city: institution.CITY,
|
|
1290
|
+
stalp: institution.STALP,
|
|
1291
|
+
analysis_mode: "timeseries",
|
|
1292
|
+
start_repdte: startRepdte,
|
|
1293
|
+
end_repdte: endRepdte,
|
|
1294
|
+
periods_analyzed: sorted.length,
|
|
1295
|
+
asset_start: assetStart,
|
|
1296
|
+
asset_end: assetEnd,
|
|
1297
|
+
asset_growth: change(assetStart, assetEnd),
|
|
1298
|
+
asset_growth_pct: pctChange(assetStart, assetEnd),
|
|
1299
|
+
asset_cagr: cagr(assetStart, assetEnd, years),
|
|
1300
|
+
dep_start: depStart,
|
|
1301
|
+
dep_end: depEnd,
|
|
1302
|
+
dep_growth: change(depStart, depEnd),
|
|
1303
|
+
dep_growth_pct: pctChange(depStart, depEnd),
|
|
1304
|
+
netinc_start: netIncStart,
|
|
1305
|
+
netinc_end: netIncEnd,
|
|
1306
|
+
netinc_change: change(netIncStart, netIncEnd),
|
|
1307
|
+
netinc_change_pct: pctChange(netIncStart, netIncEnd),
|
|
1308
|
+
roa_start: roaStart,
|
|
1309
|
+
roa_end: roaEnd,
|
|
1310
|
+
roa_change: change(roaStart, roaEnd),
|
|
1311
|
+
roe_start: roeStart,
|
|
1312
|
+
roe_end: roeEnd,
|
|
1313
|
+
roe_change: change(roeStart, roeEnd),
|
|
1314
|
+
offices_start: officesStart,
|
|
1315
|
+
offices_end: officesEnd,
|
|
1316
|
+
offices_change: change(officesStart, officesEnd),
|
|
1317
|
+
assets_per_office_start: assetsPerOfficeStart,
|
|
1318
|
+
assets_per_office_end: assetsPerOfficeEnd,
|
|
1319
|
+
assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
|
|
1320
|
+
deposits_per_office_start: depositsPerOfficeStart,
|
|
1321
|
+
deposits_per_office_end: depositsPerOfficeEnd,
|
|
1322
|
+
deposits_per_office_change: change(
|
|
1323
|
+
depositsPerOfficeStart,
|
|
1324
|
+
depositsPerOfficeEnd
|
|
1325
|
+
),
|
|
1326
|
+
deposits_to_assets_start: depositsToAssetsStart,
|
|
1327
|
+
deposits_to_assets_end: depositsToAssetsEnd,
|
|
1328
|
+
deposits_to_assets_change: change(
|
|
1329
|
+
depositsToAssetsStart,
|
|
1330
|
+
depositsToAssetsEnd
|
|
1331
|
+
),
|
|
1332
|
+
asset_peak: peakAsset,
|
|
1333
|
+
roa_trough: troughRoa,
|
|
1334
|
+
asset_growth_streak: longestMonotonicStreak(assetSeries, "up"),
|
|
1335
|
+
roa_decline_streak: longestMonotonicStreak(roaSeries, "down"),
|
|
1336
|
+
roe_decline_streak: longestMonotonicStreak(roeSeries, "down"),
|
|
1337
|
+
time_series: sorted.map((record) => {
|
|
1338
|
+
const repdte = String(record.REPDTE);
|
|
1339
|
+
const demo = demographicsByDate.get(repdte);
|
|
1340
|
+
return {
|
|
1341
|
+
repdte,
|
|
1342
|
+
asset: asNumber(record.ASSET),
|
|
1343
|
+
dep: asNumber(record.DEP),
|
|
1344
|
+
netinc: asNumber(record.NETINC),
|
|
1345
|
+
roa: asNumber(record.ROA),
|
|
1346
|
+
roe: asNumber(record.ROE),
|
|
1347
|
+
offices: asNumber(demo?.OFFTOT)
|
|
1348
|
+
};
|
|
1349
|
+
})
|
|
1350
|
+
};
|
|
1351
|
+
comparison.insights = classifyInsights(comparison);
|
|
1352
|
+
if (comparison.asset_growth_streak >= 3) {
|
|
1353
|
+
comparison.insights.push("sustained_asset_growth");
|
|
1354
|
+
}
|
|
1355
|
+
if (comparison.roa_decline_streak >= 2) {
|
|
1356
|
+
comparison.insights.push("multi_quarter_roa_decline");
|
|
1357
|
+
}
|
|
1358
|
+
return comparison;
|
|
1359
|
+
}
|
|
1360
|
+
function formatComparisonText(output) {
|
|
1361
|
+
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.`;
|
|
1362
|
+
if (output.comparisons.length === 0) {
|
|
1363
|
+
return header;
|
|
1364
|
+
}
|
|
1365
|
+
const rows = output.comparisons.map((comparison, index) => {
|
|
1366
|
+
const name = String(comparison.name ?? comparison.cert);
|
|
1367
|
+
const city = comparison.city ? `, ${comparison.city}` : "";
|
|
1368
|
+
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))}`;
|
|
1369
|
+
if (output.analysis_mode === "timeseries") {
|
|
1370
|
+
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))}`;
|
|
1371
|
+
}
|
|
1372
|
+
return base;
|
|
1373
|
+
});
|
|
1374
|
+
const insights = output.insights ? Object.entries(output.insights).filter(([, names]) => names.length > 0).map(([label, names]) => `${label}: ${names.join(", ")}`).join("\n") : "";
|
|
1375
|
+
return insights ? `${header}
|
|
1376
|
+
${rows.join("\n")}
|
|
1377
|
+
Insights
|
|
1378
|
+
${insights}` : `${header}
|
|
1379
|
+
${rows.join("\n")}`;
|
|
1380
|
+
}
|
|
1381
|
+
async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
|
|
1382
|
+
const filterParts = [];
|
|
1383
|
+
if (state) filterParts.push(`STNAME:"${state}"`);
|
|
1384
|
+
if (activeOnly) filterParts.push("ACTIVE:1");
|
|
1385
|
+
if (institutionFilters) filterParts.push(`(${institutionFilters})`);
|
|
1386
|
+
const response = await queryEndpoint(ENDPOINTS.INSTITUTIONS, {
|
|
1387
|
+
filters: filterParts.join(" AND "),
|
|
1388
|
+
fields: "CERT,NAME,CITY,STALP,ACTIVE",
|
|
1389
|
+
limit: 1e4,
|
|
1390
|
+
offset: 0,
|
|
1391
|
+
sort_by: "CERT",
|
|
1392
|
+
sort_order: "ASC"
|
|
1393
|
+
});
|
|
1394
|
+
return extractRecords(response);
|
|
1395
|
+
}
|
|
1396
|
+
async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
|
|
1397
|
+
const certFilters = buildCertFilters(certs);
|
|
1398
|
+
const tasks = repdteFilters.flatMap(
|
|
1399
|
+
(repdteFilter) => certFilters.map((certFilter) => ({
|
|
1400
|
+
repdteFilter,
|
|
1401
|
+
certFilter
|
|
1402
|
+
}))
|
|
1403
|
+
);
|
|
1404
|
+
const responses = await mapWithConcurrency(tasks, MAX_CONCURRENCY, async (task) => {
|
|
1405
|
+
const response = await queryEndpoint(endpoint, {
|
|
1406
|
+
filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
|
|
1407
|
+
fields,
|
|
1408
|
+
limit: 1e4,
|
|
1409
|
+
offset: 0,
|
|
1410
|
+
sort_by: "CERT",
|
|
1411
|
+
sort_order: "ASC"
|
|
1412
|
+
});
|
|
1413
|
+
return { repdteFilter: task.repdteFilter, response };
|
|
1414
|
+
});
|
|
1415
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
1416
|
+
for (const { repdteFilter, response } of responses) {
|
|
1417
|
+
if (!byDate.has(repdteFilter)) {
|
|
1418
|
+
byDate.set(repdteFilter, /* @__PURE__ */ new Map());
|
|
1419
|
+
}
|
|
1420
|
+
const target = byDate.get(repdteFilter);
|
|
1421
|
+
for (const record of extractRecords(response)) {
|
|
1422
|
+
const cert = asNumber(record.CERT);
|
|
1423
|
+
if (cert !== null) target.set(cert, record);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return byDate;
|
|
1427
|
+
}
|
|
1428
|
+
async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
|
|
1429
|
+
const certFilters = buildCertFilters(certs);
|
|
1430
|
+
const responses = await mapWithConcurrency(
|
|
1431
|
+
certFilters,
|
|
1432
|
+
MAX_CONCURRENCY,
|
|
1433
|
+
async (certFilter) => queryEndpoint(endpoint, {
|
|
1434
|
+
filters: `(${certFilter}) AND REPDTE:[${startRepdte} TO ${endRepdte}]`,
|
|
1435
|
+
fields,
|
|
1436
|
+
limit: 1e4,
|
|
1437
|
+
offset: 0,
|
|
1438
|
+
sort_by: "REPDTE",
|
|
1439
|
+
sort_order: "ASC"
|
|
1440
|
+
})
|
|
1441
|
+
);
|
|
1442
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1443
|
+
for (const response of responses) {
|
|
1444
|
+
for (const record of extractRecords(response)) {
|
|
1445
|
+
const cert = asNumber(record.CERT);
|
|
1446
|
+
if (cert === null) continue;
|
|
1447
|
+
if (!grouped.has(cert)) grouped.set(cert, []);
|
|
1448
|
+
grouped.get(cert).push(record);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return grouped;
|
|
1452
|
+
}
|
|
1453
|
+
function buildSnapshotComparison(cert, institution, startFinancial, endFinancial, startDemo, endDemo, startRepdte, endRepdte) {
|
|
1454
|
+
const assetStart = asNumber(startFinancial.ASSET);
|
|
1455
|
+
const assetEnd = asNumber(endFinancial.ASSET);
|
|
1456
|
+
const depStart = asNumber(startFinancial.DEP);
|
|
1457
|
+
const depEnd = asNumber(endFinancial.DEP);
|
|
1458
|
+
const netIncStart = asNumber(startFinancial.NETINC);
|
|
1459
|
+
const netIncEnd = asNumber(endFinancial.NETINC);
|
|
1460
|
+
const roaStart = asNumber(startFinancial.ROA);
|
|
1461
|
+
const roaEnd = asNumber(endFinancial.ROA);
|
|
1462
|
+
const roeStart = asNumber(startFinancial.ROE);
|
|
1463
|
+
const roeEnd = asNumber(endFinancial.ROE);
|
|
1464
|
+
const officesStart = asNumber(startDemo?.OFFTOT);
|
|
1465
|
+
const officesEnd = asNumber(endDemo?.OFFTOT);
|
|
1466
|
+
const assetsPerOfficeStart = ratio(assetStart, officesStart);
|
|
1467
|
+
const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
|
|
1468
|
+
const depositsPerOfficeStart = ratio(depStart, officesStart);
|
|
1469
|
+
const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
|
|
1470
|
+
const depositsToAssetsStart = ratio(depStart, assetStart);
|
|
1471
|
+
const depositsToAssetsEnd = ratio(depEnd, assetEnd);
|
|
1472
|
+
const comparison = {
|
|
1473
|
+
cert,
|
|
1474
|
+
name: endFinancial.NAME ?? startFinancial.NAME ?? institution.NAME,
|
|
1475
|
+
city: institution.CITY,
|
|
1476
|
+
stalp: institution.STALP,
|
|
1477
|
+
analysis_mode: "snapshot",
|
|
1478
|
+
start_repdte: startRepdte,
|
|
1479
|
+
end_repdte: endRepdte,
|
|
1480
|
+
asset_start: assetStart,
|
|
1481
|
+
asset_end: assetEnd,
|
|
1482
|
+
asset_growth: change(assetStart, assetEnd),
|
|
1483
|
+
asset_growth_pct: pctChange(assetStart, assetEnd),
|
|
1484
|
+
dep_start: depStart,
|
|
1485
|
+
dep_end: depEnd,
|
|
1486
|
+
dep_growth: change(depStart, depEnd),
|
|
1487
|
+
dep_growth_pct: pctChange(depStart, depEnd),
|
|
1488
|
+
netinc_start: netIncStart,
|
|
1489
|
+
netinc_end: netIncEnd,
|
|
1490
|
+
netinc_change: change(netIncStart, netIncEnd),
|
|
1491
|
+
netinc_change_pct: pctChange(netIncStart, netIncEnd),
|
|
1492
|
+
roa_start: roaStart,
|
|
1493
|
+
roa_end: roaEnd,
|
|
1494
|
+
roa_change: change(roaStart, roaEnd),
|
|
1495
|
+
roe_start: roeStart,
|
|
1496
|
+
roe_end: roeEnd,
|
|
1497
|
+
roe_change: change(roeStart, roeEnd),
|
|
1498
|
+
offices_start: officesStart,
|
|
1499
|
+
offices_end: officesEnd,
|
|
1500
|
+
offices_change: change(officesStart, officesEnd),
|
|
1501
|
+
assets_per_office_start: assetsPerOfficeStart,
|
|
1502
|
+
assets_per_office_end: assetsPerOfficeEnd,
|
|
1503
|
+
assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
|
|
1504
|
+
deposits_per_office_start: depositsPerOfficeStart,
|
|
1505
|
+
deposits_per_office_end: depositsPerOfficeEnd,
|
|
1506
|
+
deposits_per_office_change: change(
|
|
1507
|
+
depositsPerOfficeStart,
|
|
1508
|
+
depositsPerOfficeEnd
|
|
1509
|
+
),
|
|
1510
|
+
deposits_to_assets_start: depositsToAssetsStart,
|
|
1511
|
+
deposits_to_assets_end: depositsToAssetsEnd,
|
|
1512
|
+
deposits_to_assets_change: change(
|
|
1513
|
+
depositsToAssetsStart,
|
|
1514
|
+
depositsToAssetsEnd
|
|
1515
|
+
),
|
|
1516
|
+
cbsa_start: startDemo?.CBSANAME,
|
|
1517
|
+
cbsa_end: endDemo?.CBSANAME
|
|
1518
|
+
};
|
|
1519
|
+
comparison.insights = classifyInsights(comparison);
|
|
1520
|
+
return comparison;
|
|
1521
|
+
}
|
|
1522
|
+
function registerAnalysisTools(server) {
|
|
1523
|
+
server.registerTool(
|
|
1524
|
+
"fdic_compare_bank_snapshots",
|
|
1525
|
+
{
|
|
1526
|
+
title: "Compare Bank Snapshot Trends",
|
|
1527
|
+
description: `Compare FDIC reporting snapshots across a set of institutions and rank the results by growth, profitability, or efficiency changes.
|
|
1528
|
+
|
|
1529
|
+
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.
|
|
1530
|
+
|
|
1531
|
+
Good uses:
|
|
1532
|
+
- Identify North Carolina banks with the strongest asset growth from 2021 to 2025
|
|
1533
|
+
- Compare whether deposit growth came with branch expansion or profitability improvement
|
|
1534
|
+
- Rank a specific cert list by ROA, ROE, asset-per-office, or deposit-to-asset changes
|
|
1535
|
+
- Pull a quarterly trend series and highlight inflection points, streaks, and structural shifts
|
|
1536
|
+
|
|
1537
|
+
Inputs:
|
|
1538
|
+
- state or certs: choose a geographic roster or provide a direct comparison set
|
|
1539
|
+
- start_repdte, end_repdte: report dates in YYYYMMDD format
|
|
1540
|
+
- analysis_mode: snapshot or timeseries
|
|
1541
|
+
- institution_filters: optional extra institution filter when building the roster
|
|
1542
|
+
- active_only: default true
|
|
1543
|
+
- include_demographics: default true, adds office-count comparisons when available
|
|
1544
|
+
- sort_by: ranking field such as asset_growth, dep_growth_pct, roa_change, assets_per_office_change
|
|
1545
|
+
- sort_order: ASC or DESC
|
|
1546
|
+
- limit: maximum ranked results to return
|
|
1547
|
+
|
|
1548
|
+
Returns concise comparison text plus structured deltas, derived metrics, and insight tags for each institution.`,
|
|
1549
|
+
inputSchema: SnapshotAnalysisSchema,
|
|
1550
|
+
annotations: {
|
|
1551
|
+
readOnlyHint: true,
|
|
1552
|
+
destructiveHint: false,
|
|
1553
|
+
idempotentHint: true,
|
|
1554
|
+
openWorldHint: true
|
|
1555
|
+
}
|
|
1556
|
+
},
|
|
1557
|
+
async ({
|
|
1558
|
+
state,
|
|
1559
|
+
certs,
|
|
1560
|
+
institution_filters,
|
|
1561
|
+
active_only,
|
|
1562
|
+
start_repdte,
|
|
1563
|
+
end_repdte,
|
|
1564
|
+
analysis_mode,
|
|
1565
|
+
include_demographics,
|
|
1566
|
+
limit,
|
|
1567
|
+
sort_by,
|
|
1568
|
+
sort_order
|
|
1569
|
+
}) => {
|
|
1570
|
+
try {
|
|
1571
|
+
const roster = certs && certs.length > 0 ? certs.map((cert) => ({ CERT: cert })) : await fetchInstitutionRoster(
|
|
1572
|
+
state,
|
|
1573
|
+
institution_filters,
|
|
1574
|
+
active_only
|
|
1575
|
+
);
|
|
1576
|
+
const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
|
|
1577
|
+
if (candidateCerts.length === 0) {
|
|
1578
|
+
const output2 = {
|
|
1579
|
+
total_candidates: 0,
|
|
1580
|
+
analyzed_count: 0,
|
|
1581
|
+
start_repdte,
|
|
1582
|
+
end_repdte,
|
|
1583
|
+
analysis_mode,
|
|
1584
|
+
sort_by,
|
|
1585
|
+
sort_order,
|
|
1586
|
+
comparisons: []
|
|
1587
|
+
};
|
|
1588
|
+
return {
|
|
1589
|
+
content: [
|
|
1590
|
+
{ type: "text", text: "No institutions matched the comparison set." }
|
|
1591
|
+
],
|
|
1592
|
+
structuredContent: output2
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
const rosterByCert = new Map(
|
|
1596
|
+
roster.map((record) => [asNumber(record.CERT), record]).filter(
|
|
1597
|
+
(entry) => entry[0] !== null
|
|
1598
|
+
)
|
|
1599
|
+
);
|
|
1600
|
+
let comparisons = [];
|
|
1601
|
+
if (analysis_mode === "timeseries") {
|
|
1602
|
+
const [financialSeries, demographicsSeries] = await Promise.all([
|
|
1603
|
+
fetchSeriesRecords(
|
|
1604
|
+
ENDPOINTS.FINANCIALS,
|
|
1605
|
+
candidateCerts,
|
|
1606
|
+
start_repdte,
|
|
1607
|
+
end_repdte,
|
|
1608
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1609
|
+
),
|
|
1610
|
+
include_demographics ? fetchSeriesRecords(
|
|
1611
|
+
ENDPOINTS.DEMOGRAPHICS,
|
|
1612
|
+
candidateCerts,
|
|
1613
|
+
start_repdte,
|
|
1614
|
+
end_repdte,
|
|
1615
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1616
|
+
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1617
|
+
]);
|
|
1618
|
+
comparisons = candidateCerts.map(
|
|
1619
|
+
(cert) => summarizeTimeSeries(
|
|
1620
|
+
financialSeries.get(cert) ?? [],
|
|
1621
|
+
new Map(
|
|
1622
|
+
(demographicsSeries.get(cert) ?? []).map((record) => [
|
|
1623
|
+
String(record.REPDTE),
|
|
1624
|
+
record
|
|
1625
|
+
])
|
|
1626
|
+
),
|
|
1627
|
+
rosterByCert.get(cert) ?? {}
|
|
1628
|
+
)
|
|
1629
|
+
).filter((comparison) => comparison !== null);
|
|
1630
|
+
} else {
|
|
1631
|
+
const [financialSnapshots, demographicSnapshots] = await Promise.all([
|
|
1632
|
+
fetchBatchedRecordsForDates(
|
|
1633
|
+
ENDPOINTS.FINANCIALS,
|
|
1634
|
+
candidateCerts,
|
|
1635
|
+
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1636
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1637
|
+
),
|
|
1638
|
+
include_demographics ? fetchBatchedRecordsForDates(
|
|
1639
|
+
ENDPOINTS.DEMOGRAPHICS,
|
|
1640
|
+
candidateCerts,
|
|
1641
|
+
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1642
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1643
|
+
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1644
|
+
]);
|
|
1645
|
+
const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1646
|
+
const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1647
|
+
const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1648
|
+
const endDemographics = demographicSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1649
|
+
comparisons = candidateCerts.map((cert) => {
|
|
1650
|
+
const startFinancial = startFinancials.get(cert);
|
|
1651
|
+
const endFinancial = endFinancials.get(cert);
|
|
1652
|
+
if (!startFinancial || !endFinancial) return null;
|
|
1653
|
+
return buildSnapshotComparison(
|
|
1654
|
+
cert,
|
|
1655
|
+
rosterByCert.get(cert) ?? {},
|
|
1656
|
+
startFinancial,
|
|
1657
|
+
endFinancial,
|
|
1658
|
+
startDemographics.get(cert),
|
|
1659
|
+
endDemographics.get(cert),
|
|
1660
|
+
start_repdte,
|
|
1661
|
+
end_repdte
|
|
1662
|
+
);
|
|
1663
|
+
}).filter((comparison) => comparison !== null);
|
|
1664
|
+
}
|
|
1665
|
+
const ranked = sortComparisons(comparisons, sort_by, sort_order).slice(
|
|
1666
|
+
0,
|
|
1667
|
+
limit
|
|
1668
|
+
);
|
|
1669
|
+
const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
|
|
1670
|
+
const output = {
|
|
1671
|
+
total_candidates: candidateCerts.length,
|
|
1672
|
+
analyzed_count: comparisons.length,
|
|
1673
|
+
start_repdte,
|
|
1674
|
+
end_repdte,
|
|
1675
|
+
analysis_mode,
|
|
1676
|
+
sort_by,
|
|
1677
|
+
sort_order,
|
|
1678
|
+
insights: buildTopLevelInsights(comparisons),
|
|
1679
|
+
...pagination,
|
|
1680
|
+
comparisons: ranked
|
|
1681
|
+
};
|
|
1682
|
+
const text = truncateIfNeeded(
|
|
1683
|
+
formatComparisonText(output),
|
|
904
1684
|
CHARACTER_LIMIT
|
|
905
1685
|
);
|
|
906
1686
|
return {
|
|
@@ -927,6 +1707,7 @@ function createServer() {
|
|
|
927
1707
|
registerFinancialTools(server);
|
|
928
1708
|
registerSodTools(server);
|
|
929
1709
|
registerDemographicsTools(server);
|
|
1710
|
+
registerAnalysisTools(server);
|
|
930
1711
|
return server;
|
|
931
1712
|
}
|
|
932
1713
|
async function runStdio() {
|