@cliwant/mcp-sam-gov 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +279 -67
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +42 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +64 -0
- package/dist/cache.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +92 -17
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +247 -0
- package/dist/errors.js.map +1 -0
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +432 -15
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +114 -11
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3897 -295
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1664 -228
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +73 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +231 -127
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +303 -0
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +706 -191
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +290 -155
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4559 -327
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +2680 -925
- package/src/usitc.ts +420 -0
package/src/fdic.ts
ADDED
|
@@ -0,0 +1,2052 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FDIC BankFind Suite — keyless FDIC-insured-institution directory + regulated-
|
|
3
|
+
* entity financials (ADR-0028). The FIRST OFF-EDGAR entity source: an
|
|
4
|
+
* FDIC-insured-institution directory (`/banks/institutions`) + quarterly
|
|
5
|
+
* financial time-series (`/banks/financials`) for B2G counterparty / entity
|
|
6
|
+
* due-diligence. Source 22 → 23; tool snapshot 87 → 89. Fully PUBLIC, KEYLESS —
|
|
7
|
+
* no key param, no auth, no PII (institution NAME is an ORG name; the curated
|
|
8
|
+
* projections exclude every officer/contact field).
|
|
9
|
+
*
|
|
10
|
+
* R2 consumer — reuses the shipped `DataSource` port EXACTLY like ckan.ts /
|
|
11
|
+
* socrata.ts: `getJson` / `driftError` / `throughGate` (datasource.ts), `num`·
|
|
12
|
+
* `str` (coerce.ts), `withMeta` / `buildMeta` (meta.ts). It writes ZERO fetch /
|
|
13
|
+
* coercion / error / pagination code, and COPIES (does not import) the SSRF +
|
|
14
|
+
* honesty PATTERN.
|
|
15
|
+
* Directory: https://api.fdic.gov/banks/institutions?filters=…&search=…&fields=…
|
|
16
|
+
* Financials: https://api.fdic.gov/banks/financials?filters=CERT:<int>&fields=…
|
|
17
|
+
*
|
|
18
|
+
* SUCCESS envelope (live-verified 2026-07-13, SEC-style UA, HTTP 200 JSON):
|
|
19
|
+
* { "meta": { "total": <EXACT int>, "parameters": {…}, "index": { "name":…,
|
|
20
|
+
* "createTimestamp":… } }, "data": [ { "data": {<record>}, "score":… } ] }
|
|
21
|
+
* `meta.total` is the EXACT match count, STABLE across `offset` (honest exact-
|
|
22
|
+
* total pagination). Records are nested under `data[].data`. Two OTHER envelopes
|
|
23
|
+
* exist: a QUERY-ERROR `{ "errors":[{status,detail}] }` (HTTP 400) and a
|
|
24
|
+
* ROUTING `{ "message","statusCode" }` (HTTP 404) — both handled by the port's
|
|
25
|
+
* taxonomy (they THROW before the drift-guard ever runs).
|
|
26
|
+
*
|
|
27
|
+
* ★ SSRF GUARD (policy① — the central design risk). Fixed host `api.fdic.gov`,
|
|
28
|
+
* `https` only. The path is a FIXED endpoint constant the TOOL chooses
|
|
29
|
+
* (`institutions` | `financials`) — NO caller value on the path. Every param
|
|
30
|
+
* (`filters` / `search` / `fields` / `sort_by` / `sort_order` / `limit` /
|
|
31
|
+
* `offset` / `format`) goes through URLSearchParams, built server-side from
|
|
32
|
+
* allowlisted structured inputs with escaped values. Belt-and-suspenders builder
|
|
33
|
+
* assertion (mirrors ckan/socrata): `new URL(url).hostname === "api.fdic.gov"`
|
|
34
|
+
* and `protocol === "https:"` → else invalid_input. `limit`/`offset` are bounded
|
|
35
|
+
* ints (no unbounded paging). `redirect:"error"` on every getJson (a future 3xx
|
|
36
|
+
* off `api.fdic.gov` throws rather than being silently followed off-host).
|
|
37
|
+
*
|
|
38
|
+
* ★ HONESTY DESIGN (P1–P4; ADR §4 + the v2 review) — closing the landmines:
|
|
39
|
+
* (A/P4) filter-FIELD names are compile-time constants behind NAMED structured
|
|
40
|
+
* inputs; a caller can never supply a field name, so a typo'd field can never
|
|
41
|
+
* reach the wire as a false genuine-empty. Belt-and-suspenders: the builder
|
|
42
|
+
* asserts every emitted filter field ∈ its allowlist (institutions
|
|
43
|
+
* {STALP,ACTIVE,CERT}; financials {CERT}); every emitted SEARCH field ∈
|
|
44
|
+
* {NAME,CITY}; every sortBy ∈ the per-endpoint sort allowlist (a Set.has
|
|
45
|
+
* recheck behind the server's Zod enum) → an unknown sort field is
|
|
46
|
+
* invalid_input BEFORE fetch (live: `sort_by=NOTAFIELD` + a sort_order → HTTP
|
|
47
|
+
* 400, so the pre-fetch guard is load-bearing).
|
|
48
|
+
* ★M1 (BLOCKER) — `name`/`city` route through FDIC's full-text `search` param,
|
|
49
|
+
* NOT `filters=NAME/CITY:"…"`. The `filters` DSL treats NAME/CITY as
|
|
50
|
+
* case-sensitive EXACT-keyword (live: `filters=NAME:"chase"` → total 0 — a
|
|
51
|
+
* confident false-empty), whereas `search=NAME:chase` is a case-insensitive
|
|
52
|
+
* full-text token match (live: 43) that combines cleanly with `filters` (live:
|
|
53
|
+
* `search=NAME:first` + `filters=STALP:VA AND ACTIVE:1` → 9). We emit BOTH
|
|
54
|
+
* `filters=` (STALP/ACTIVE/CERT) and `search=` (NAME/CITY) when present.
|
|
55
|
+
* Disclosed in `_meta.notes`.
|
|
56
|
+
* ★M2 (v2 fix — the search value is UNQUOTED) — the `search` term is built
|
|
57
|
+
* `NAME:<escaped>` (NO surrounding quotes). Quoting a single token makes FDIC
|
|
58
|
+
* run a `match_phrase` that COLLAPSES recall to zero for real brand-name banks
|
|
59
|
+
* (live: `NAME:"Axos"`→0 but `NAME:Axos`→1 — Axos Bank CERT 35546 exists), the
|
|
60
|
+
* exact M1 false-empty class. The value is instead backslash-escaped for the
|
|
61
|
+
* UNQUOTED Lucene reserved chars the char-class allows (`( ) & / -` + `\`; see
|
|
62
|
+
* escapeSearch) — belt-and-suspenders on the Zod-bypass path. The `search`
|
|
63
|
+
* param is provably non-injectable for WIDENING (default-AND token semantics —
|
|
64
|
+
* `OR`/`&&` never form a union; live: `NAME:zzz OR STALP:VA`→0), so no quotes
|
|
65
|
+
* are needed for security either.
|
|
66
|
+
* ★S1 — a multi-word name/city `search` value is matched PER-TOKEN by FDIC's
|
|
67
|
+
* full-text index (may be BROADER than a literal substring — a record sharing
|
|
68
|
+
* only ONE token can match; live: `search=NAME:First Community`→225 incl.
|
|
69
|
+
* "First State Bank"). We disclose this in `_meta.notes` whenever a name/city
|
|
70
|
+
* value contains a space.
|
|
71
|
+
* (P1) EXACT total → honest pagination: `totalAvailable = num(meta.total)`
|
|
72
|
+
* (stable across offset); `records = data.map(d => d.data)`; `hasMore =
|
|
73
|
+
* offset + returned < totalAvailable`; `nextOffset`. Via withMeta/buildMeta.
|
|
74
|
+
* (P2) 3-envelope drift-guard: require `meta` object AND `data` Array AND
|
|
75
|
+
* `typeof meta.total === "number"` → else driftError (rejects the errors[] /
|
|
76
|
+
* message+statusCode shapes at HTTP 200). A non-JSON 200 body (json parse
|
|
77
|
+
* throw) → reclassified to driftError. The ONLY honest empty = HTTP 200 +
|
|
78
|
+
* `meta.total:0` + `data:[]` → returned:0, totalAvailable:0, complete:true.
|
|
79
|
+
* Everything else THROWS — never a fake-empty.
|
|
80
|
+
* (P3) $thousands→USD ×1000, null-never-0: FDIC publishes ASSET/DEP/NETINC in
|
|
81
|
+
* $thousands; `assetUSD = num(rec.ASSET) === null ? null : num(rec.ASSET) *
|
|
82
|
+
* 1000` (the null-guard PRECEDES the ×1000, so an absent value stays null,
|
|
83
|
+
* never 0). Disclosed in `_meta.notes`. CERT/REPDTE/ACTIVE via `num`;
|
|
84
|
+
* NAME/CITY/STALP/ID/ESTYMD via `str`.
|
|
85
|
+
* (B) Returned-fields disclosure: the union of keys across `data[].data`; any
|
|
86
|
+
* projected field absent from ALL records (when returned > 0) →
|
|
87
|
+
* `_meta.fieldsUnavailable` + a note (a field FDIC silently stops returning
|
|
88
|
+
* surfaces instead of vanishing).
|
|
89
|
+
* Snapshot-freshness: `meta.index.{name,createTimestamp}` → `_meta.notes`
|
|
90
|
+
* ("point-in-time snapshot, not a live-this-second read"; the institutions
|
|
91
|
+
* and financials indexes carry DIFFERENT snapshot times).
|
|
92
|
+
*
|
|
93
|
+
* ★ A1 — provenance `source` is set INLINE in each tool's withMeta partial (like
|
|
94
|
+
* ckan.ts). There is NO `fdic_` branch in server.ts's synthesizeDefaultMeta (both
|
|
95
|
+
* tools return a MetaBundle, so that switch is never consulted — it would be dead
|
|
96
|
+
* code shipping blank provenance).
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
100
|
+
import { getJson, driftError, throughGate } from "./datasource.js";
|
|
101
|
+
import { num, str } from "./coerce.js";
|
|
102
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
103
|
+
|
|
104
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js) so
|
|
105
|
+
// the fault suite's num-parity guard resolves fdic.num === coerce.num, exactly
|
|
106
|
+
// like ckan.num / socrata.num.
|
|
107
|
+
export { num };
|
|
108
|
+
|
|
109
|
+
// ─── Constants (SSRF core) ─────────────────────────────────────────
|
|
110
|
+
const FDIC_HOST = "api.fdic.gov";
|
|
111
|
+
// One host = one rate budget. ~5 req/s; conservative — FDIC publishes no hard
|
|
112
|
+
// ceiling, so stay polite. The gate label is host-only (getJson contract).
|
|
113
|
+
const FDIC_MIN_INTERVAL_MS = 200;
|
|
114
|
+
const LABEL = "fdic:" + FDIC_HOST; // → ToolError.upstreamEndpoint (host-only)
|
|
115
|
+
// The throughGate KEY — host-scoped so BOTH endpoints share one rate budget.
|
|
116
|
+
const GATE_KEY = "fdic";
|
|
117
|
+
|
|
118
|
+
// Fixed endpoint constants — the TOOL chooses these; NO caller value on the path.
|
|
119
|
+
const ENDPOINT_INSTITUTIONS = "institutions";
|
|
120
|
+
const ENDPOINT_FINANCIALS = "financials";
|
|
121
|
+
|
|
122
|
+
// Fixed field projections (every field live-verified valid → none silently
|
|
123
|
+
// dropped by a typo). The output maps each to a typed key.
|
|
124
|
+
const INST_FIELDS = "NAME,CITY,STALP,CERT,ASSET,ACTIVE,ESTYMD,ID";
|
|
125
|
+
const FIN_FIELDS = "CERT,REPDTE,ASSET,DEP,NETINC,ID";
|
|
126
|
+
// The raw FDIC field names, for the returned-fields (B) disclosure.
|
|
127
|
+
const INST_PROJECTION = [
|
|
128
|
+
"NAME",
|
|
129
|
+
"CITY",
|
|
130
|
+
"STALP",
|
|
131
|
+
"CERT",
|
|
132
|
+
"ASSET",
|
|
133
|
+
"ACTIVE",
|
|
134
|
+
"ESTYMD",
|
|
135
|
+
"ID",
|
|
136
|
+
] as const;
|
|
137
|
+
const FIN_PROJECTION = ["CERT", "REPDTE", "ASSET", "DEP", "NETINC", "ID"] as const;
|
|
138
|
+
|
|
139
|
+
// Filter-FIELD allowlists (P4 belt-and-suspenders). NAME/CITY are NOT here — they
|
|
140
|
+
// route through `search` (M1). These fields carry only constrained non-string
|
|
141
|
+
// values (STALP a 2-letter code, ACTIVE 0/1, CERT an int) so they need no quoting.
|
|
142
|
+
const INST_FILTER_FIELDS: ReadonlySet<string> = new Set(["STALP", "ACTIVE", "CERT"]);
|
|
143
|
+
const FIN_FILTER_FIELDS: ReadonlySet<string> = new Set(["CERT"]);
|
|
144
|
+
// SEARCH-FIELD allowlist (M1) — free-text NAME/CITY only (phrase-quoted + escaped).
|
|
145
|
+
const INST_SEARCH_FIELDS: ReadonlySet<string> = new Set(["NAME", "CITY"]);
|
|
146
|
+
// sortBy allowlists (mirror the server's Zod enums; a Set.has recheck in the
|
|
147
|
+
// builder → an unknown sort field is invalid_input BEFORE fetch).
|
|
148
|
+
const INST_SORT_FIELDS: ReadonlySet<string> = new Set([
|
|
149
|
+
"NAME",
|
|
150
|
+
"CERT",
|
|
151
|
+
"ASSET",
|
|
152
|
+
"ESTYMD",
|
|
153
|
+
"STALP",
|
|
154
|
+
"CITY",
|
|
155
|
+
"ACTIVE",
|
|
156
|
+
]);
|
|
157
|
+
const FIN_SORT_FIELDS: ReadonlySet<string> = new Set([
|
|
158
|
+
"REPDTE",
|
|
159
|
+
"ASSET",
|
|
160
|
+
"DEP",
|
|
161
|
+
"NETINC",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
// ─── Value escaping + builders (SSRF / injection discipline) ───────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* ★M2 (v2 fix) — escape an UNQUOTED `search` value: backslash FIRST (so a
|
|
168
|
+
* pre-existing `\` becomes a literal `\\` and we never double-process the escapes
|
|
169
|
+
* we add), then backslash-escape the Lucene reserved chars the char-class ALLOWS
|
|
170
|
+
* and that are meaningful UNQUOTED — grouping `(` `)`, the boolean-forming `&`
|
|
171
|
+
* (`&&`), the regex delimiter `/`, and the prefix/NOT operator `-`. There are NO
|
|
172
|
+
* surrounding quotes: FDIC's `search` treats a quoted single token as a
|
|
173
|
+
* `match_phrase` that COLLAPSES recall to ZERO for real brand-name banks (live:
|
|
174
|
+
* `NAME:"Axos"`→0 but `NAME:Axos`→1) — the exact M1 false-empty class this design
|
|
175
|
+
* exists to prevent. Quotes are also gratuitous for security: the SSRF review
|
|
176
|
+
* proved the `search` param is non-injectable for WIDENING (default-AND token
|
|
177
|
+
* semantics — `OR`/`&&` never form a union; live: `NAME:zzz OR STALP:VA`→0 vs
|
|
178
|
+
* STALP:VA→5998), and a field-pivot needs `:`, which the Zod char-class rejects.
|
|
179
|
+
* So this escape is belt-and-suspenders for the Zod-bypass path (`:` and `"` are
|
|
180
|
+
* char-class-rejected on the validated path). Live-verified 2026-07-13 that the
|
|
181
|
+
* escape preserves recall (`Farmers & Merchants`→181, `First-Citizens`→85,
|
|
182
|
+
* `Mizuho Bank \(USA\)`→1). Exported for the direct-builder fault fixture.
|
|
183
|
+
*/
|
|
184
|
+
export function escapeSearch(v: string): string {
|
|
185
|
+
return v.replace(/\\/g, "\\\\").replace(/[()&/\-]/g, "\\$&");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The filter-field allowlists, exported so the fault suite can drive the
|
|
189
|
+
* belt-and-suspenders field-guard directly (the tool functions only ever pass
|
|
190
|
+
* hardcoded fields, so this defense-in-depth check is otherwise unreachable). */
|
|
191
|
+
export { INST_FILTER_FIELDS, FIN_FILTER_FIELDS, INST_SEARCH_FIELDS };
|
|
192
|
+
|
|
193
|
+
/** A `filters` term `FIELD:VALUE` — asserts the field ∈ its allowlist (P4). A
|
|
194
|
+
* NON-NUMERIC value is DOUBLE-QUOTED; a numeric value stays bare. This quoting is
|
|
195
|
+
* LOAD-BEARING for correctness: the FDIC `filters` DSL is Lucene-style, so a
|
|
196
|
+
* BAREWORD state code that collides with a boolean operator is mis-parsed. Live-
|
|
197
|
+
* verified 2026-07-13: `PSTALP:OR` (Oregon, unquoted) → HTTP 400 parse_exception (a
|
|
198
|
+
* HARD-FAIL that throws, NOT an honest empty), whereas `PSTALP:"OR"` → 4289 rows.
|
|
199
|
+
* OR is the only 2-letter US state code that collides with a Lucene operator, but
|
|
200
|
+
* quoting is EQUIVALENT for every non-operator value (live: `STALP:CA` === `STALP:"CA"`
|
|
201
|
+
* === 1287; `PSTALP:CA`(failures) === 265; `PSTALP:CA`(history) === 35892) so it is
|
|
202
|
+
* uniformly safe for all state filters and fixes all 4 FDIC tools at once (they all
|
|
203
|
+
* call this). Numeric fields (CERT/CHANGECODE/FAILYR/EFFYEAR/ACTIVE) stay bare — the
|
|
204
|
+
* values are `^\d+$` by construction and a number never collides with an operator.
|
|
205
|
+
* The value is escaped backslash-first then `"` (defensive — `^[A-Z]{2}$`-constrained
|
|
206
|
+
* state values contain neither, and no other field routes a free string through here).
|
|
207
|
+
* This is the `filters`-DSL path ONLY; the `search` param (searchTerm/escapeSearch)
|
|
208
|
+
* is a DIFFERENT surface and stays correctly UNQUOTED (C116). Exported for the
|
|
209
|
+
* allowlist-bypass fault fixture (§7(b)). */
|
|
210
|
+
export function filterTerm(
|
|
211
|
+
field: string,
|
|
212
|
+
value: string,
|
|
213
|
+
allowed: ReadonlySet<string>,
|
|
214
|
+
): string {
|
|
215
|
+
if (!allowed.has(field)) {
|
|
216
|
+
throw new ToolErrorCarrier({
|
|
217
|
+
kind: "invalid_input",
|
|
218
|
+
message: `FDIC filter field ${JSON.stringify(field)} is not on the allowlist — refusing to build an un-allowlisted filter (P4 / SSRF safety).`,
|
|
219
|
+
retryable: false,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// ★OR-fix — a non-numeric value is double-quoted so a Lucene-operator-colliding
|
|
223
|
+
// state code (e.g. OR = Oregon) is parsed as a literal term, not a boolean
|
|
224
|
+
// operator (bare `PSTALP:OR` → live HTTP 400). Numeric values stay bare.
|
|
225
|
+
const emitted = /^\d+$/.test(value)
|
|
226
|
+
? value
|
|
227
|
+
: `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
228
|
+
return `${field}:${emitted}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A `search` term `FIELD:<escaped>` (M1 route for NAME/CITY) — asserts the field
|
|
232
|
+
* ∈ the search allowlist and M2-escapes the value UNQUOTED (see escapeSearch: NO
|
|
233
|
+
* surrounding quotes — quotes collapse match_phrase recall → false-empties).
|
|
234
|
+
* Exported for the allowlist-bypass fault fixture (§7(b)). */
|
|
235
|
+
export function searchTerm(field: string, value: string): string {
|
|
236
|
+
if (!INST_SEARCH_FIELDS.has(field)) {
|
|
237
|
+
throw new ToolErrorCarrier({
|
|
238
|
+
kind: "invalid_input",
|
|
239
|
+
message: `FDIC search field ${JSON.stringify(field)} is not on the allowlist — refusing to build an un-allowlisted search (P4 / SSRF safety).`,
|
|
240
|
+
retryable: false,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return `${field}:${escapeSearch(value)}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build the institutions `filters` string (STALP/ACTIVE/CERT only — NAME/CITY go
|
|
248
|
+
* through `search`, M1). Terms joined with ` AND `. Returns "" when there is no
|
|
249
|
+
* structured filter clause. Exported for the fault fixtures.
|
|
250
|
+
*/
|
|
251
|
+
export function buildInstFilters(inp: {
|
|
252
|
+
state?: string;
|
|
253
|
+
activeOnly?: boolean;
|
|
254
|
+
cert?: number;
|
|
255
|
+
}): string {
|
|
256
|
+
const terms: string[] = [];
|
|
257
|
+
if (inp.state !== undefined) terms.push(filterTerm("STALP", inp.state, INST_FILTER_FIELDS));
|
|
258
|
+
if (inp.activeOnly !== undefined)
|
|
259
|
+
terms.push(filterTerm("ACTIVE", inp.activeOnly ? "1" : "0", INST_FILTER_FIELDS));
|
|
260
|
+
if (inp.cert !== undefined) terms.push(filterTerm("CERT", String(inp.cert), INST_FILTER_FIELDS));
|
|
261
|
+
return terms.join(" AND ");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* ★M1 + ★M2 — build the institutions `search` string (FDIC full-text; NAME/CITY
|
|
266
|
+
* only). Each value is char-class-validated at the server boundary, then here
|
|
267
|
+
* M2-escaped UNQUOTED (backslash-first; NO surrounding quotes — see escapeSearch).
|
|
268
|
+
* Terms joined with ` AND ` (live-verified: `search=NAME:first AND CITY:richmond`
|
|
269
|
+
* → both must match). Returns "" when neither is present. Exported for the M1/M2
|
|
270
|
+
* fault fixtures.
|
|
271
|
+
*/
|
|
272
|
+
export function buildInstSearch(inp: { name?: string; city?: string }): string {
|
|
273
|
+
const terms: string[] = [];
|
|
274
|
+
if (inp.name !== undefined) terms.push(searchTerm("NAME", inp.name));
|
|
275
|
+
if (inp.city !== undefined) terms.push(searchTerm("CITY", inp.city));
|
|
276
|
+
return terms.join(" AND ");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Build the financials `filters` string — the sole filter is the numeric
|
|
280
|
+
* `CERT:<int>` (zero string inputs → zero injection surface). */
|
|
281
|
+
export function buildFinFilters(inp: { cert: number }): string {
|
|
282
|
+
return filterTerm("CERT", String(inp.cert), FIN_FILTER_FIELDS);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Resolve the sort params against the per-endpoint allowlist (belt-and-suspenders
|
|
287
|
+
* behind the server's Zod enum). An unknown sortBy is invalid_input BEFORE any
|
|
288
|
+
* fetch (never `sort_by=NOTAFIELD` on the wire — FDIC 400s it when a sort_order
|
|
289
|
+
* accompanies it). Returns {} when no sortBy (institutions may omit sorting).
|
|
290
|
+
*/
|
|
291
|
+
function sortParams(
|
|
292
|
+
sortBy: string | undefined,
|
|
293
|
+
sortOrder: string | undefined,
|
|
294
|
+
allowed: ReadonlySet<string>,
|
|
295
|
+
endpoint: string,
|
|
296
|
+
): { sort_by?: string; sort_order?: string } {
|
|
297
|
+
if (sortBy === undefined) return {};
|
|
298
|
+
if (!allowed.has(sortBy)) {
|
|
299
|
+
throw new ToolErrorCarrier({
|
|
300
|
+
kind: "invalid_input",
|
|
301
|
+
message: `FDIC ${endpoint} sortBy ${JSON.stringify(sortBy)} is not an allowed sort field. Allowed: ${[...allowed].join(", ")}.`,
|
|
302
|
+
retryable: false,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return { sort_by: sortBy, sort_order: sortOrder === "DESC" ? "DESC" : "ASC" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── fetch layer ──────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* GET one FDIC BankFind endpoint through the shared port. SSRF guard: the path is
|
|
312
|
+
* a FIXED endpoint constant (`institutions` | `financials`) — no caller value —
|
|
313
|
+
* and the CONSTRUCTED URL's hostname === api.fdic.gov over https. One shared gate
|
|
314
|
+
* `throughGate("fdic", 200, …)` around EVERY fetch (one host = one rate budget) +
|
|
315
|
+
* `redirect:"error"` (B1) + NO headers (keyless — byte-clean init). A JSON-parse
|
|
316
|
+
* throw from a non-JSON 200 body (an HTML/error page) is reclassified to
|
|
317
|
+
* driftError; a ToolErrorCarrier from the 429/5xx/404/400 taxonomy propagates
|
|
318
|
+
* unchanged. Returns the parsed JSON (unknown; the caller validates the envelope).
|
|
319
|
+
*/
|
|
320
|
+
async function getFdic(endpoint: string, params: URLSearchParams): Promise<unknown> {
|
|
321
|
+
const url = `https://${FDIC_HOST}/banks/${endpoint}?${params.toString()}`;
|
|
322
|
+
const built = new URL(url);
|
|
323
|
+
if (built.hostname !== FDIC_HOST || built.protocol !== "https:") {
|
|
324
|
+
throw new ToolErrorCarrier({
|
|
325
|
+
kind: "invalid_input",
|
|
326
|
+
message: `Constructed FDIC URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${FDIC_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
327
|
+
retryable: false,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
return await throughGate(GATE_KEY, FDIC_MIN_INTERVAL_MS, () =>
|
|
332
|
+
getJson(url, { label: LABEL, redirect: "error" }),
|
|
333
|
+
);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
// Preserve the structured taxonomy (404/429/5xx/400/timeout) unchanged.
|
|
336
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
337
|
+
// A 200 non-JSON body makes getJson's r.json() throw a SyntaxError → classify
|
|
338
|
+
// as schema_drift (an honest THROW), never a fake-empty. getJson stays
|
|
339
|
+
// byte-identical (the reclassification is at THIS call site — the fema pattern).
|
|
340
|
+
if (e instanceof SyntaxError) {
|
|
341
|
+
throw driftError(
|
|
342
|
+
LABEL,
|
|
343
|
+
"FDIC returned a non-JSON body at HTTP 200 — treating as schema drift.",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── envelope parse (P2 3-envelope drift-guard) + map helpers ──────
|
|
351
|
+
type FdicEnvelope = {
|
|
352
|
+
records: Record<string, unknown>[];
|
|
353
|
+
totalAvailable: number;
|
|
354
|
+
indexName: string | null;
|
|
355
|
+
indexCreated: string | null;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* ★P2 — the 3-envelope drift-guard on the parsed 200 body. Require the SUCCESS
|
|
360
|
+
* shape: `meta` is an object AND `data` is an Array (rejects the errors[] QUERY-
|
|
361
|
+
* ERROR shape and the message+statusCode ROUTING shape at HTTP 200), AND `typeof
|
|
362
|
+
* meta.total === "number"` (before num; a non-number/absent total is drift, never
|
|
363
|
+
* a fake empty). Records are nested under `data[].data`. Reads the snapshot-
|
|
364
|
+
* freshness `meta.index.{name,createTimestamp}`.
|
|
365
|
+
*/
|
|
366
|
+
function parseEnvelope(body: unknown): FdicEnvelope {
|
|
367
|
+
const b = (body ?? {}) as { meta?: unknown; data?: unknown };
|
|
368
|
+
if (b.meta === null || typeof b.meta !== "object" || !Array.isArray(b.data)) {
|
|
369
|
+
throw driftError(
|
|
370
|
+
LABEL,
|
|
371
|
+
"FDIC BankFind returned an unexpected shape (expected { meta:{…}, data:[…] }) — treating as schema drift (rejects the errors[] / message+statusCode envelopes served at HTTP 200).",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const meta = b.meta as { total?: unknown; index?: unknown };
|
|
375
|
+
if (typeof meta.total !== "number") {
|
|
376
|
+
throw driftError(
|
|
377
|
+
LABEL,
|
|
378
|
+
"FDIC BankFind returned a non-number meta.total — treating as schema drift (num() alone cannot distinguish a non-number total from an absent one).",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
const totalAvailable = num(meta.total);
|
|
382
|
+
if (totalAvailable === null) {
|
|
383
|
+
throw driftError(LABEL, "FDIC BankFind meta.total is not a finite number — treating as schema drift.");
|
|
384
|
+
}
|
|
385
|
+
const records = (b.data as Array<{ data?: unknown }>).map((d) =>
|
|
386
|
+
d && typeof d === "object" && d.data && typeof d.data === "object"
|
|
387
|
+
? (d.data as Record<string, unknown>)
|
|
388
|
+
: {},
|
|
389
|
+
);
|
|
390
|
+
const idx = (meta.index ?? {}) as { name?: unknown; createTimestamp?: unknown };
|
|
391
|
+
return {
|
|
392
|
+
records,
|
|
393
|
+
totalAvailable,
|
|
394
|
+
indexName: str(idx.name),
|
|
395
|
+
indexCreated: str(idx.createTimestamp),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** ★B — the union of keys across the raw records; a projected field absent from
|
|
400
|
+
* ALL records (when returned > 0) is surfaced (never silently vanished). */
|
|
401
|
+
function fieldsUnavailable(
|
|
402
|
+
records: Record<string, unknown>[],
|
|
403
|
+
projection: readonly string[],
|
|
404
|
+
): string[] {
|
|
405
|
+
if (records.length === 0) return [];
|
|
406
|
+
const present = new Set<string>();
|
|
407
|
+
for (const r of records) for (const k of Object.keys(r)) present.add(k);
|
|
408
|
+
return projection.filter((f) => !present.has(f));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** ★P3 — $thousands → whole USD with the null-guard BEFORE the ×1000 (an absent
|
|
412
|
+
* value stays null, never 0). */
|
|
413
|
+
function thousandsToUsd(v: unknown): number | null {
|
|
414
|
+
const n = num(v);
|
|
415
|
+
return n === null ? null : n * 1000;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export type FdicInstitution = {
|
|
419
|
+
name: string | null;
|
|
420
|
+
city: string | null;
|
|
421
|
+
state: string | null;
|
|
422
|
+
cert: number | null;
|
|
423
|
+
assetUSD: number | null;
|
|
424
|
+
active: number | null;
|
|
425
|
+
establishedDate: string | null;
|
|
426
|
+
id: string | null;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
function mapInstitution(rec: Record<string, unknown>): FdicInstitution {
|
|
430
|
+
return {
|
|
431
|
+
name: str(rec.NAME),
|
|
432
|
+
city: str(rec.CITY),
|
|
433
|
+
state: str(rec.STALP),
|
|
434
|
+
cert: num(rec.CERT),
|
|
435
|
+
assetUSD: thousandsToUsd(rec.ASSET),
|
|
436
|
+
active: num(rec.ACTIVE),
|
|
437
|
+
establishedDate: str(rec.ESTYMD),
|
|
438
|
+
id: str(rec.ID),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export type FdicFinancials = {
|
|
443
|
+
cert: number | null;
|
|
444
|
+
reportDate: number | null;
|
|
445
|
+
assetUSD: number | null;
|
|
446
|
+
depositsUSD: number | null;
|
|
447
|
+
netIncomeUSD: number | null;
|
|
448
|
+
id: string | null;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
function mapFinancials(rec: Record<string, unknown>): FdicFinancials {
|
|
452
|
+
return {
|
|
453
|
+
cert: num(rec.CERT),
|
|
454
|
+
reportDate: num(rec.REPDTE),
|
|
455
|
+
assetUSD: thousandsToUsd(rec.ASSET),
|
|
456
|
+
depositsUSD: thousandsToUsd(rec.DEP),
|
|
457
|
+
netIncomeUSD: thousandsToUsd(rec.NETINC),
|
|
458
|
+
id: str(rec.ID),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── shared disclosure notes ───────────────────────────────────────
|
|
463
|
+
const ASSET_NOTE =
|
|
464
|
+
"FDIC publishes ASSET/DEP/NETINC in $thousands; normalized here to whole USD (×1,000). A real 0 stays 0; an absent value is null (never 0).";
|
|
465
|
+
const NAME_CITY_SEARCH_NOTE =
|
|
466
|
+
"name/city use FDIC's full-text `search` (case-insensitive token match); a multi-word value is matched per-token and may be BROADER than a literal substring (it can match records sharing only some tokens) — verify counts.";
|
|
467
|
+
|
|
468
|
+
function freshnessNote(name: string | null, created: string | null): string {
|
|
469
|
+
return `Served from FDIC search-index snapshot ${name ?? "(unnamed)"}${created ? ` built ${created}` : ""} — a point-in-time snapshot, not a live-this-second read; the institutions and financials indexes carry DIFFERENT snapshot times.`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── Tool 1: fdic_search_institutions ──────────────────────────────
|
|
473
|
+
/**
|
|
474
|
+
* Search the FDIC-insured-institution directory (`/banks/institutions`).
|
|
475
|
+
* Structured inputs: `state` (STALP filter), `activeOnly` (ACTIVE filter), `cert`
|
|
476
|
+
* (CERT filter) → the `filters` param; `name`/`city` → the full-text `search`
|
|
477
|
+
* param (M1; case-insensitive token match, UNQUOTED + M2-escaped). Plus
|
|
478
|
+
* `limit`/`offset`/`sortBy`/`sortOrder`. Fixed field projection.
|
|
479
|
+
*
|
|
480
|
+
* HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1); the 3-
|
|
481
|
+
* envelope drift-guard makes the ONLY honest empty `200 + total:0 + data:[]`,
|
|
482
|
+
* everything else THROWS (P2); ASSET is $thousands → whole USD ×1000 null-never-0
|
|
483
|
+
* (P3); a projected field absent from all records → fieldsUnavailable (B); the
|
|
484
|
+
* snapshot build time is disclosed. name/city search + multi-word per-token are
|
|
485
|
+
* disclosed (M1/S1).
|
|
486
|
+
*/
|
|
487
|
+
export async function searchInstitutions(args: {
|
|
488
|
+
state?: string;
|
|
489
|
+
activeOnly?: boolean;
|
|
490
|
+
cert?: number;
|
|
491
|
+
name?: string;
|
|
492
|
+
city?: string;
|
|
493
|
+
limit?: number;
|
|
494
|
+
offset?: number;
|
|
495
|
+
sortBy?: string;
|
|
496
|
+
sortOrder?: string;
|
|
497
|
+
}): Promise<MetaBundle> {
|
|
498
|
+
const limit = args.limit ?? 100;
|
|
499
|
+
const offset = args.offset ?? 0;
|
|
500
|
+
|
|
501
|
+
const filters = buildInstFilters({
|
|
502
|
+
state: args.state,
|
|
503
|
+
activeOnly: args.activeOnly,
|
|
504
|
+
cert: args.cert,
|
|
505
|
+
});
|
|
506
|
+
const search = buildInstSearch({ name: args.name, city: args.city });
|
|
507
|
+
const sort = sortParams(args.sortBy, args.sortOrder, INST_SORT_FIELDS, "institutions");
|
|
508
|
+
|
|
509
|
+
const params = new URLSearchParams();
|
|
510
|
+
if (filters) params.set("filters", filters);
|
|
511
|
+
if (search) params.set("search", search);
|
|
512
|
+
params.set("fields", INST_FIELDS);
|
|
513
|
+
params.set("limit", String(limit));
|
|
514
|
+
params.set("offset", String(offset));
|
|
515
|
+
if (sort.sort_by) {
|
|
516
|
+
params.set("sort_by", sort.sort_by);
|
|
517
|
+
params.set("sort_order", sort.sort_order as string);
|
|
518
|
+
}
|
|
519
|
+
params.set("format", "json");
|
|
520
|
+
|
|
521
|
+
const body = await getFdic(ENDPOINT_INSTITUTIONS, params);
|
|
522
|
+
const env = parseEnvelope(body);
|
|
523
|
+
const records = env.records.map(mapInstitution);
|
|
524
|
+
const returned = records.length;
|
|
525
|
+
const totalAvailable = env.totalAvailable;
|
|
526
|
+
const hasMore = offset + returned < totalAvailable;
|
|
527
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
528
|
+
|
|
529
|
+
const filtersApplied: string[] = [];
|
|
530
|
+
if (args.state !== undefined) filtersApplied.push("state");
|
|
531
|
+
if (args.activeOnly !== undefined) filtersApplied.push("activeOnly");
|
|
532
|
+
if (args.cert !== undefined) filtersApplied.push("cert");
|
|
533
|
+
if (args.name !== undefined) filtersApplied.push("name");
|
|
534
|
+
if (args.city !== undefined) filtersApplied.push("city");
|
|
535
|
+
if (sort.sort_by) filtersApplied.push("sort");
|
|
536
|
+
|
|
537
|
+
const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
|
|
538
|
+
if (args.name !== undefined || args.city !== undefined) {
|
|
539
|
+
notes.push(NAME_CITY_SEARCH_NOTE);
|
|
540
|
+
// ★S1 — a multi-word name/city value is matched PER-TOKEN (phrase-quoting does
|
|
541
|
+
// NOT phrase-scope the `search` param). Disclose whenever a value has a space.
|
|
542
|
+
const multi: string[] = [];
|
|
543
|
+
if (args.name !== undefined && /\s/.test(args.name.trim())) multi.push("name");
|
|
544
|
+
if (args.city !== undefined && /\s/.test(args.city.trim())) multi.push("city");
|
|
545
|
+
if (multi.length > 0) {
|
|
546
|
+
notes.push(
|
|
547
|
+
`Multi-word ${multi.join(" and ")} is matched PER-TOKEN by FDIC's full-text index (the tokens are AND-combined but each is fuzzy — a record sharing only ONE token can match, e.g. \`search=NAME:First Community\` returns "First State Bank"); results may be BROADER than a literal substring — verify counts.`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const fu = fieldsUnavailable(env.records, INST_PROJECTION);
|
|
552
|
+
if (fu.length > 0) {
|
|
553
|
+
notes.push(
|
|
554
|
+
`Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return withMeta(
|
|
559
|
+
{ institutions: records },
|
|
560
|
+
{
|
|
561
|
+
source: "api.fdic.gov/banks/institutions (BankFind, keyless)",
|
|
562
|
+
keylessMode: true,
|
|
563
|
+
returned,
|
|
564
|
+
totalAvailable,
|
|
565
|
+
filtersApplied,
|
|
566
|
+
filtersDropped: [],
|
|
567
|
+
fieldsUnavailable: fu,
|
|
568
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
569
|
+
notes,
|
|
570
|
+
} satisfies Partial<ResponseMeta>,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ─── Tool 2: fdic_institution_financials ───────────────────────────
|
|
575
|
+
/**
|
|
576
|
+
* Quarterly financial time-series for ONE FDIC-insured institution by `cert`
|
|
577
|
+
* (`/banks/financials`). The sole filter is the numeric `CERT:<int>` (zero string
|
|
578
|
+
* inputs → zero injection surface). `sortBy` defaults to REPDTE + `sortOrder`
|
|
579
|
+
* DESC → newest quarter first. Fixed field projection. Consumes the IDENTICAL
|
|
580
|
+
* fetch → 3-envelope guard → pagination machinery as tool 1.
|
|
581
|
+
*
|
|
582
|
+
* HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live: CERT
|
|
583
|
+
* 10363 → total 169, stable across offset); the 3-envelope drift-guard (P2);
|
|
584
|
+
* ASSET/DEP/NETINC $thousands → whole USD ×1000 null-never-0 (P3); the
|
|
585
|
+
* fieldsUnavailable disclosure (B); the snapshot build time is disclosed.
|
|
586
|
+
*/
|
|
587
|
+
export async function institutionFinancials(args: {
|
|
588
|
+
cert: number;
|
|
589
|
+
limit?: number;
|
|
590
|
+
offset?: number;
|
|
591
|
+
sortBy?: string;
|
|
592
|
+
sortOrder?: string;
|
|
593
|
+
}): Promise<MetaBundle> {
|
|
594
|
+
const limit = args.limit ?? 100;
|
|
595
|
+
const offset = args.offset ?? 0;
|
|
596
|
+
// Financials default to REPDTE DESC (newest first) when the server's Zod default
|
|
597
|
+
// did not supply one (defensive — the server always defaults sortBy=REPDTE).
|
|
598
|
+
const sort = sortParams(
|
|
599
|
+
args.sortBy ?? "REPDTE",
|
|
600
|
+
args.sortOrder ?? "DESC",
|
|
601
|
+
FIN_SORT_FIELDS,
|
|
602
|
+
"financials",
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const params = new URLSearchParams();
|
|
606
|
+
params.set("filters", buildFinFilters({ cert: args.cert }));
|
|
607
|
+
params.set("fields", FIN_FIELDS);
|
|
608
|
+
params.set("limit", String(limit));
|
|
609
|
+
params.set("offset", String(offset));
|
|
610
|
+
if (sort.sort_by) {
|
|
611
|
+
params.set("sort_by", sort.sort_by);
|
|
612
|
+
params.set("sort_order", sort.sort_order as string);
|
|
613
|
+
}
|
|
614
|
+
params.set("format", "json");
|
|
615
|
+
|
|
616
|
+
const body = await getFdic(ENDPOINT_FINANCIALS, params);
|
|
617
|
+
const env = parseEnvelope(body);
|
|
618
|
+
const records = env.records.map(mapFinancials);
|
|
619
|
+
const returned = records.length;
|
|
620
|
+
const totalAvailable = env.totalAvailable;
|
|
621
|
+
const hasMore = offset + returned < totalAvailable;
|
|
622
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
623
|
+
|
|
624
|
+
const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
|
|
625
|
+
const fu = fieldsUnavailable(env.records, FIN_PROJECTION);
|
|
626
|
+
if (fu.length > 0) {
|
|
627
|
+
notes.push(
|
|
628
|
+
`Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return withMeta(
|
|
633
|
+
{ cert: args.cert, financials: records },
|
|
634
|
+
{
|
|
635
|
+
source: "api.fdic.gov/banks/financials (BankFind, keyless)",
|
|
636
|
+
keylessMode: true,
|
|
637
|
+
returned,
|
|
638
|
+
totalAvailable,
|
|
639
|
+
filtersApplied: ["cert", "sort"],
|
|
640
|
+
filtersDropped: [],
|
|
641
|
+
fieldsUnavailable: fu,
|
|
642
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
643
|
+
notes,
|
|
644
|
+
} satisfies Partial<ResponseMeta>,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
649
|
+
// Tool 3: fdic_bank_failures (ADR-0029) — the historical failed / FDIC-assisted
|
|
650
|
+
// institution list (`/banks/failures`). B2G counterparty-risk: a failed or
|
|
651
|
+
// FDIC-assisted institution is a due-diligence red flag; `CERT` links a failure
|
|
652
|
+
// back to fdic_search_institutions / fdic_institution_financials. Reuses the
|
|
653
|
+
// C116-hardened adapter VERBATIM (getFdic / parseEnvelope 3-envelope-guard /
|
|
654
|
+
// filter-field allowlist-by-construction / sortBy enum+Set.has / EXACT meta.total
|
|
655
|
+
// pagination / snapshot-freshness / $thousands→USD ×1000 null-never-0). The NEW
|
|
656
|
+
// surface is exactly three things, all forced by the v2 (cycle-33) live review:
|
|
657
|
+
// ★F1 — the state field on /failures is PSTALP, NOT STALP (live: `STALP:CA`→
|
|
658
|
+
// total 0 = the unknown-field false-empty landmine; `PSTALP:CA`→265). We map
|
|
659
|
+
// state→PSTALP in the filter, the allowlist, the projection, and the output.
|
|
660
|
+
// ★F2 — the `search` param is IGNORED on /failures (live: `search=NAME:…` AND
|
|
661
|
+
// no-params BOTH return total 4115 = the whole dataset = a false-FLOOD). So
|
|
662
|
+
// this tool has NO name/city filter and NEVER emits `search=` — it does NOT
|
|
663
|
+
// reuse searchTerm/escapeSearch. NAME/CITY stay in the PROJECTION (surfaced
|
|
664
|
+
// per row) but are not filterable; name-based lookup is the honest 2-step CERT
|
|
665
|
+
// linkage (resolve CERT in fdic_search_institutions → filter here by cert).
|
|
666
|
+
// ★F3 — FAILDATE is `M/D/YYYY` (not financials' YYYYMMDD): normFailDate does an
|
|
667
|
+
// EXACT Date.UTC round-trip → ISO YYYY-MM-DD (an unrecognized value is
|
|
668
|
+
// surfaced RAW + disclosed, never nulled/fabricated); COST can be a genuine 0
|
|
669
|
+
// (fully-assisted, no DIF loss) or NEGATIVE (a net DIF recovery/gain) —
|
|
670
|
+
// thousandsToUsd keeps both faithfully (null-guard BEFORE the ×1000).
|
|
671
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
672
|
+
|
|
673
|
+
// Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
|
|
674
|
+
const ENDPOINT_FAILURES = "failures";
|
|
675
|
+
|
|
676
|
+
// Fixed field projection (every field live-verified 2026-07-13). NAME/CITY are
|
|
677
|
+
// PROJECTED (surfaced per output row) but NOT filterable (F2).
|
|
678
|
+
const FAIL_FIELDS = "NAME,CERT,FAILDATE,FAILYR,CITY,PSTALP,COST,RESTYPE,SAVR,QBFDEP,QBFASSET,ID";
|
|
679
|
+
const FAIL_PROJECTION = [
|
|
680
|
+
"NAME",
|
|
681
|
+
"CERT",
|
|
682
|
+
"FAILDATE",
|
|
683
|
+
"FAILYR",
|
|
684
|
+
"CITY",
|
|
685
|
+
"PSTALP",
|
|
686
|
+
"COST",
|
|
687
|
+
"RESTYPE",
|
|
688
|
+
"SAVR",
|
|
689
|
+
"QBFDEP",
|
|
690
|
+
"QBFASSET",
|
|
691
|
+
"ID",
|
|
692
|
+
] as const;
|
|
693
|
+
|
|
694
|
+
// ★F1 — the failures filter-FIELD allowlist (P4 belt-and-suspenders). The state
|
|
695
|
+
// field is PSTALP (NOT STALP — that is a false-empty landmine). NAME/CITY are NOT
|
|
696
|
+
// here (F2 — /failures has no working name/city filter). These fields carry only
|
|
697
|
+
// constrained non-string values (PSTALP a 2-letter code, FAILYR a year, CERT an
|
|
698
|
+
// int) → no quoting needed.
|
|
699
|
+
const FDIC_FAILURES_FILTER_FIELDS: ReadonlySet<string> = new Set(["PSTALP", "FAILYR", "CERT"]);
|
|
700
|
+
// sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams
|
|
701
|
+
// → an unknown sort field is invalid_input BEFORE fetch).
|
|
702
|
+
const FAIL_SORT_FIELDS: ReadonlySet<string> = new Set([
|
|
703
|
+
"FAILDATE",
|
|
704
|
+
"COST",
|
|
705
|
+
"QBFASSET",
|
|
706
|
+
"QBFDEP",
|
|
707
|
+
"NAME",
|
|
708
|
+
"FAILYR",
|
|
709
|
+
]);
|
|
710
|
+
|
|
711
|
+
// Exported so the fault suite can drive the belt-and-suspenders field-guard directly.
|
|
712
|
+
export { FDIC_FAILURES_FILTER_FIELDS };
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Build the failures `filters` string — EXACT-KEY terms only: `state`→`PSTALP:<s>`
|
|
716
|
+
* (★F1), `failYear`→`FAILYR:<year>` (emit the integer's digits — FAILYR is a
|
|
717
|
+
* string field but the digits filter cleanly, live-verified), `cert`→`CERT:<int>`.
|
|
718
|
+
* There is NO name/city term (★F2 — /failures ignores `search` and NAME/CITY only
|
|
719
|
+
* filter as brittle exact-uppercase foot-guns). Terms joined with ` AND `. Returns
|
|
720
|
+
* "" when there is no structured filter clause. Exported for the fault fixtures.
|
|
721
|
+
*/
|
|
722
|
+
export function buildFailFilters(inp: {
|
|
723
|
+
state?: string;
|
|
724
|
+
failYear?: number;
|
|
725
|
+
cert?: number;
|
|
726
|
+
}): string {
|
|
727
|
+
const terms: string[] = [];
|
|
728
|
+
if (inp.state !== undefined)
|
|
729
|
+
terms.push(filterTerm("PSTALP", inp.state, FDIC_FAILURES_FILTER_FIELDS));
|
|
730
|
+
if (inp.failYear !== undefined)
|
|
731
|
+
terms.push(filterTerm("FAILYR", String(inp.failYear), FDIC_FAILURES_FILTER_FIELDS));
|
|
732
|
+
if (inp.cert !== undefined)
|
|
733
|
+
terms.push(filterTerm("CERT", String(inp.cert), FDIC_FAILURES_FILTER_FIELDS));
|
|
734
|
+
return terms.join(" AND ");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* ★F3 — normalize FDIC's `FAILDATE` (`M/D/YYYY`, e.g. `3/10/2023`) to ISO
|
|
739
|
+
* `YYYY-MM-DD`. Parse `^(\d{1,2})/(\d{1,2})/(\d{4})$`, then validate the calendar
|
|
740
|
+
* day with the EXACT 3-component Date.UTC round-trip (rejects JS's silent
|
|
741
|
+
* roll-overs like `2/30/2023`→Mar-02). On success → the padded ISO string
|
|
742
|
+
* (`normalized:true`). If the value does NOT match the pattern or fails the
|
|
743
|
+
* round-trip → surface the RAW upstream value UNCHANGED (`normalized:false`; NEVER
|
|
744
|
+
* null or fabricate a present date — the handler discloses the raw passthrough); a
|
|
745
|
+
* genuinely-absent value (null/undefined) → null. Live-confirmed 4115/4115 rows
|
|
746
|
+
* normalize (1934–2026), so the raw-passthrough branch is defensive-only. Exported
|
|
747
|
+
* for the fault fixtures.
|
|
748
|
+
*/
|
|
749
|
+
export function normFailDate(raw: unknown): { value: string | null; normalized: boolean } {
|
|
750
|
+
// A non-string (absent/null/numeric) → honest str coercion (null for absent);
|
|
751
|
+
// never String()-fabricate "null"/"undefined"/"[object Object]".
|
|
752
|
+
if (typeof raw !== "string") return { value: str(raw), normalized: false };
|
|
753
|
+
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(raw);
|
|
754
|
+
if (m) {
|
|
755
|
+
const mo = Number(m[1]);
|
|
756
|
+
const d = Number(m[2]);
|
|
757
|
+
const y = Number(m[3]);
|
|
758
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
759
|
+
if (dt.getUTCFullYear() === y && dt.getUTCMonth() + 1 === mo && dt.getUTCDate() === d) {
|
|
760
|
+
return {
|
|
761
|
+
value: `${m[3]}-${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
|
|
762
|
+
normalized: true,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// Present but unrecognized → surface the raw value verbatim (defensive; never taken live).
|
|
767
|
+
return { value: raw, normalized: false };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export type FdicFailure = {
|
|
771
|
+
name: string | null;
|
|
772
|
+
cert: number | null;
|
|
773
|
+
failDate: string | null;
|
|
774
|
+
failYear: string | null;
|
|
775
|
+
city: string | null;
|
|
776
|
+
state: string | null;
|
|
777
|
+
resolutionType: string | null;
|
|
778
|
+
resolutionFund: string | null;
|
|
779
|
+
estimatedLossUSD: number | null;
|
|
780
|
+
depositsUSD: number | null;
|
|
781
|
+
assetsUSD: number | null;
|
|
782
|
+
id: string | null;
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
function mapFailure(rec: Record<string, unknown>): FdicFailure {
|
|
786
|
+
return {
|
|
787
|
+
name: str(rec.NAME),
|
|
788
|
+
cert: num(rec.CERT),
|
|
789
|
+
failDate: normFailDate(rec.FAILDATE).value,
|
|
790
|
+
failYear: str(rec.FAILYR),
|
|
791
|
+
city: str(rec.CITY),
|
|
792
|
+
state: str(rec.PSTALP), // ★F1 — PSTALP, NOT STALP
|
|
793
|
+
resolutionType: str(rec.RESTYPE),
|
|
794
|
+
resolutionFund: str(rec.SAVR),
|
|
795
|
+
estimatedLossUSD: thousandsToUsd(rec.COST),
|
|
796
|
+
depositsUSD: thousandsToUsd(rec.QBFDEP),
|
|
797
|
+
assetsUSD: thousandsToUsd(rec.QBFASSET),
|
|
798
|
+
id: str(rec.ID),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ─── failures disclosure notes ─────────────────────────────────────
|
|
803
|
+
const FAIL_DATE_NOTE = "failDate is normalized from FDIC's M/D/YYYY to ISO YYYY-MM-DD.";
|
|
804
|
+
const FAIL_COST_NOTE =
|
|
805
|
+
"COST is FDIC's estimated loss to the Deposit Insurance Fund; COST/QBFDEP/QBFASSET are $thousands, normalized here to whole USD (×1,000). A genuine 0 = a fully-assisted resolution with NO DIF loss (a real 0, not absence); a NEGATIVE value = a net DIF recovery/gain (NOT a loss); an absent value is null (never 0).";
|
|
806
|
+
const FAIL_SCOPE_NOTE =
|
|
807
|
+
"Historical FDIC-insured institution failures / assistance transactions; a bank ABSENT here has no recorded FDIC failure (it may be active, acquired non-failed, or never FDIC-insured) — cross-check with fdic_search_institutions.";
|
|
808
|
+
const FAIL_CERT_NOTE =
|
|
809
|
+
"To find a specific institution's failure, resolve its CERT via fdic_search_institutions, then filter here by `cert`; name/city are shown but not searchable on this endpoint (FDIC's /failures `search` param is ignored and would return the whole dataset).";
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Historical FDIC bank failures / assistance transactions (`/banks/failures`).
|
|
813
|
+
* Exact-key structured inputs: `state` (→ PSTALP filter, ★F1), `failYear` (→
|
|
814
|
+
* FAILYR), `cert` (→ CERT) → the `filters` param; plus `limit`/`offset`/`sortBy`/
|
|
815
|
+
* `sortOrder` (default FAILDATE DESC → most-recent failures first). Fixed field
|
|
816
|
+
* projection. NO name/city filter and NEVER a `search=` param (★F2). Consumes the
|
|
817
|
+
* IDENTICAL fetch → 3-envelope guard → pagination machinery as tools 1 & 2.
|
|
818
|
+
*
|
|
819
|
+
* HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1); the 3-
|
|
820
|
+
* envelope drift-guard makes the ONLY honest empty `200 + total:0 + data:[]`,
|
|
821
|
+
* everything else THROWS (P2); COST/QBFDEP/QBFASSET are $thousands → whole USD
|
|
822
|
+
* ×1000 null-never-0 (P3; genuine 0 stays 0, negative = a net recovery, absent →
|
|
823
|
+
* null); failDate normalized M/D/YYYY→ISO (unrecognized → raw + disclosed, never
|
|
824
|
+
* nulled/fabricated); a projected field absent from all records → fieldsUnavailable
|
|
825
|
+
* (B); the snapshot build time is disclosed.
|
|
826
|
+
*/
|
|
827
|
+
export async function bankFailures(args: {
|
|
828
|
+
state?: string;
|
|
829
|
+
failYear?: number;
|
|
830
|
+
cert?: number;
|
|
831
|
+
limit?: number;
|
|
832
|
+
offset?: number;
|
|
833
|
+
sortBy?: string;
|
|
834
|
+
sortOrder?: string;
|
|
835
|
+
}): Promise<MetaBundle> {
|
|
836
|
+
const limit = args.limit ?? 100;
|
|
837
|
+
const offset = args.offset ?? 0;
|
|
838
|
+
// Default FAILDATE DESC (most-recent first) when the server's Zod default did not
|
|
839
|
+
// supply one (defensive — the server always defaults sortBy=FAILDATE/DESC).
|
|
840
|
+
const sort = sortParams(
|
|
841
|
+
args.sortBy ?? "FAILDATE",
|
|
842
|
+
args.sortOrder ?? "DESC",
|
|
843
|
+
FAIL_SORT_FIELDS,
|
|
844
|
+
"failures",
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
const filters = buildFailFilters({ state: args.state, failYear: args.failYear, cert: args.cert });
|
|
848
|
+
|
|
849
|
+
const params = new URLSearchParams();
|
|
850
|
+
if (filters) params.set("filters", filters);
|
|
851
|
+
params.set("fields", FAIL_FIELDS);
|
|
852
|
+
params.set("limit", String(limit));
|
|
853
|
+
params.set("offset", String(offset));
|
|
854
|
+
if (sort.sort_by) {
|
|
855
|
+
params.set("sort_by", sort.sort_by);
|
|
856
|
+
params.set("sort_order", sort.sort_order as string);
|
|
857
|
+
}
|
|
858
|
+
params.set("format", "json");
|
|
859
|
+
|
|
860
|
+
const body = await getFdic(ENDPOINT_FAILURES, params);
|
|
861
|
+
const env = parseEnvelope(body);
|
|
862
|
+
const records = env.records.map(mapFailure);
|
|
863
|
+
const returned = records.length;
|
|
864
|
+
const totalAvailable = env.totalAvailable;
|
|
865
|
+
const hasMore = offset + returned < totalAvailable;
|
|
866
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
867
|
+
|
|
868
|
+
const filtersApplied: string[] = [];
|
|
869
|
+
if (args.state !== undefined) filtersApplied.push("state");
|
|
870
|
+
if (args.failYear !== undefined) filtersApplied.push("failYear");
|
|
871
|
+
if (args.cert !== undefined) filtersApplied.push("cert");
|
|
872
|
+
if (sort.sort_by) filtersApplied.push("sort");
|
|
873
|
+
|
|
874
|
+
const notes: string[] = [
|
|
875
|
+
freshnessNote(env.indexName, env.indexCreated),
|
|
876
|
+
FAIL_DATE_NOTE,
|
|
877
|
+
FAIL_COST_NOTE,
|
|
878
|
+
FAIL_SCOPE_NOTE,
|
|
879
|
+
FAIL_CERT_NOTE,
|
|
880
|
+
];
|
|
881
|
+
// ★F3 — disclose any present-but-unrecognized FAILDATE surfaced raw (defensive;
|
|
882
|
+
// live 4115/4115 normalize, so this branch essentially never fires).
|
|
883
|
+
const anyRawFailDate = env.records.some((rec) => {
|
|
884
|
+
const fd = normFailDate(rec.FAILDATE);
|
|
885
|
+
return !fd.normalized && fd.value !== null;
|
|
886
|
+
});
|
|
887
|
+
if (anyRawFailDate) {
|
|
888
|
+
notes.push(
|
|
889
|
+
"One or more FAILDATE values did not match FDIC's M/D/YYYY format (or failed the calendar round-trip) and were surfaced RAW (not normalized to ISO) — never nulled or fabricated.",
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const fu = fieldsUnavailable(env.records, FAIL_PROJECTION);
|
|
893
|
+
if (fu.length > 0) {
|
|
894
|
+
notes.push(
|
|
895
|
+
`Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return withMeta(
|
|
900
|
+
{ failures: records },
|
|
901
|
+
{
|
|
902
|
+
source: "api.fdic.gov/banks/failures (BankFind, keyless)",
|
|
903
|
+
keylessMode: true,
|
|
904
|
+
returned,
|
|
905
|
+
totalAvailable,
|
|
906
|
+
filtersApplied,
|
|
907
|
+
filtersDropped: [],
|
|
908
|
+
fieldsUnavailable: fu,
|
|
909
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
910
|
+
notes,
|
|
911
|
+
} satisfies Partial<ResponseMeta>,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
916
|
+
// Tool 4: fdic_institution_history (ADR-0030) — the institution-level STRUCTURAL-
|
|
917
|
+
// CHANGE event log (`/banks/history`): mergers, absorptions, consolidations,
|
|
918
|
+
// failures, name/location/charter/regulator changes, branch open/close, trust-power
|
|
919
|
+
// grants, FRS membership changes. It COMPLETES the FDIC entity cluster (institutions
|
|
920
|
+
// directory + financials time-series + failures resolution events + history full
|
|
921
|
+
// structural lineage). Reuses the C116-hardened adapter VERBATIM (getFdic /
|
|
922
|
+
// parseEnvelope 3-envelope-guard / filter-field allowlist-by-construction / sortBy
|
|
923
|
+
// enum+Set.has / EXACT meta.total pagination / snapshot-freshness). It does NOT use
|
|
924
|
+
// searchTerm/escapeSearch (no name search) or thousandsToUsd (no money field). The
|
|
925
|
+
// NEW surface is exactly four things, all live-verified 2026-07-13:
|
|
926
|
+
// ★F1-analog — the state field on /history is PSTALP, NOT STALP (live: `STALP:CA`
|
|
927
|
+
// → total 0 = the unknown-field false-empty landmine; `PSTALP:CA`→35892). We map
|
|
928
|
+
// state→PSTALP in the filter, the allowlist, the projection, and the output.
|
|
929
|
+
// ★F2-analog — the `search` param does NOT work for name on /history (live:
|
|
930
|
+
// `search=INSTNAME:chase` AND `search=INSTNAME:zzzznomatch` BOTH return total 0
|
|
931
|
+
// = a false-EMPTY). So this tool has NO name/city filter and NEVER emits
|
|
932
|
+
// `search=`. INSTNAME + the counterparty names are PROJECTED (surfaced per row)
|
|
933
|
+
// but not filterable; name-based lookup is the honest 2-step CERT linkage
|
|
934
|
+
// (resolve CERT in fdic_search_institutions → filter here by cert).
|
|
935
|
+
// ★Q1 — CHANGECODE is NOT opaque: FDIC co-serves an authoritative CHANGECODE_DESC
|
|
936
|
+
// INLINE in every record (27 distinct codes sampled, 0 nulls). We surface BOTH
|
|
937
|
+
// the numeric changeCode (authoritative) AND changeDescription = the co-served
|
|
938
|
+
// CHANGECODE_DESC PROJECTED verbatim (NOT a static embedded hand-map — strictly
|
|
939
|
+
// more honest, zero-drift, auto-covers every code). A null DESC (never observed)
|
|
940
|
+
// stays null via str, surfaced by fieldsUnavailable — never invented.
|
|
941
|
+
// ★Q2 — EFFDATE/PROCDATE are `YYYY-MM-DDT00:00:00` (a THIRD date format in this
|
|
942
|
+
// source): normHistDate strips the time + does an EXACT Date.UTC round-trip → ISO
|
|
943
|
+
// YYYY-MM-DD (an unrecognized value is surfaced RAW + disclosed, never nulled/
|
|
944
|
+
// fabricated); a 9999-* value is FDIC's "not-applicable/open" sentinel (surfaced
|
|
945
|
+
// verbatim + disclosed).
|
|
946
|
+
// ★Q3 — the ACQ_/OUT_/SUR_ counterparty CERT-triad is the headline value: on a
|
|
947
|
+
// merger/failure row it carries the acquiring/outgoing/surviving institution's
|
|
948
|
+
// CERT + INSTNAME, and each CERT links straight back to the CERT-keyed tools. On
|
|
949
|
+
// a NON-merger row (e.g. a 520 location change) the *_CERT/*_INSTNAME fields are
|
|
950
|
+
// live-verified ABSENT → num/str pass them through as null (an honest "no
|
|
951
|
+
// counterparty"), NEVER a fabricated 0. We use *_CERT (the clean null-when-N/A
|
|
952
|
+
// linkage key), NOT *_UNINUM (which carries a 0 sentinel for "none" = a
|
|
953
|
+
// misleading fake identifier; live: ACQ_UNINUM:0 on a 520 row).
|
|
954
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
955
|
+
|
|
956
|
+
// Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
|
|
957
|
+
const ENDPOINT_HISTORY = "history";
|
|
958
|
+
|
|
959
|
+
// Fixed field projection on the wire (all 16 fields; every one live-verified valid).
|
|
960
|
+
// The counterparty triad ACQ_/OUT_/SUR_ is REQUESTED here but is event-conditional
|
|
961
|
+
// (absent on non-merger rows) — it is deliberately EXCLUDED from HIST_PROJECTION
|
|
962
|
+
// (the fieldsUnavailable check) below.
|
|
963
|
+
const HIST_FIELDS =
|
|
964
|
+
"CERT,INSTNAME,PSTALP,CHANGECODE,CHANGECODE_DESC,EFFDATE,PROCDATE,EFFYEAR,TRANSNUM,ACQ_CERT,ACQ_INSTNAME,OUT_CERT,OUT_INSTNAME,SUR_CERT,SUR_INSTNAME,ID";
|
|
965
|
+
// ★Q3-NUANCE — the fieldsUnavailable "projected-field-absent-from-ALL-records"
|
|
966
|
+
// check is scoped to the ALWAYS-PRESENT fields ONLY. The ACQ_/OUT_/SUR_ counterparty
|
|
967
|
+
// triad is LEGITIMATELY event-conditional (a page of all-non-merger rows — e.g.
|
|
968
|
+
// branch closings — would correctly omit them), so including them here would fire a
|
|
969
|
+
// spurious "schema drift" on the expected shape. Their nullness is disclosed via
|
|
970
|
+
// COUNTERPARTY_NOTE (Q3), NOT fieldsUnavailable.
|
|
971
|
+
const HIST_PROJECTION = [
|
|
972
|
+
"CERT",
|
|
973
|
+
"INSTNAME",
|
|
974
|
+
"PSTALP",
|
|
975
|
+
"CHANGECODE",
|
|
976
|
+
"CHANGECODE_DESC",
|
|
977
|
+
"EFFDATE",
|
|
978
|
+
"PROCDATE",
|
|
979
|
+
"EFFYEAR",
|
|
980
|
+
"TRANSNUM",
|
|
981
|
+
"ID",
|
|
982
|
+
] as const;
|
|
983
|
+
|
|
984
|
+
// ★F1-analog — the history filter-FIELD allowlist (P4 belt-and-suspenders). The
|
|
985
|
+
// state field is PSTALP (NOT STALP — a false-empty landmine). NAME/CITY are NOT
|
|
986
|
+
// here (F2-analog — /history has no working name/city filter). These fields carry
|
|
987
|
+
// only constrained non-string values (PSTALP a 2-letter code, EFFYEAR a year, CERT
|
|
988
|
+
// / CHANGECODE ints) → no quoting needed.
|
|
989
|
+
const FDIC_HISTORY_FILTER_FIELDS: ReadonlySet<string> = new Set([
|
|
990
|
+
"CERT",
|
|
991
|
+
"CHANGECODE",
|
|
992
|
+
"EFFYEAR",
|
|
993
|
+
"PSTALP",
|
|
994
|
+
]);
|
|
995
|
+
// sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams
|
|
996
|
+
// → an unknown sort field is invalid_input BEFORE fetch; live `sort_by=NOTAFIELD`
|
|
997
|
+
// → 400, so the pre-fetch guard is load-bearing).
|
|
998
|
+
const HIST_SORT_FIELDS: ReadonlySet<string> = new Set([
|
|
999
|
+
"EFFDATE",
|
|
1000
|
+
"PROCDATE",
|
|
1001
|
+
"CHANGECODE",
|
|
1002
|
+
"TRANSNUM",
|
|
1003
|
+
]);
|
|
1004
|
+
|
|
1005
|
+
// Exported so the fault suite can drive the belt-and-suspenders field-guard directly.
|
|
1006
|
+
export { FDIC_HISTORY_FILTER_FIELDS };
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Build the history `filters` string — EXACT-KEY terms only: `cert`→`CERT:<int>`
|
|
1010
|
+
* (the PRIMARY lookup), `changeCode`→`CHANGECODE:<int>`, `effYear`→`EFFYEAR:<year>`
|
|
1011
|
+
* (emit the integer's digits — EFFYEAR is a string field but the digits filter
|
|
1012
|
+
* cleanly, live-verified), `state`→`PSTALP:<state>` (★F1-analog — PSTALP, NEVER
|
|
1013
|
+
* STALP). There is NO name/city term (★F2-analog — /history's `search` param
|
|
1014
|
+
* returns 0 for INSTNAME; this tool never emits `search=`). Terms joined with
|
|
1015
|
+
* ` AND `. Returns "" when there is no structured filter clause. Exported for the
|
|
1016
|
+
* fault fixtures.
|
|
1017
|
+
*/
|
|
1018
|
+
export function buildHistFilters(inp: {
|
|
1019
|
+
cert?: number;
|
|
1020
|
+
changeCode?: number;
|
|
1021
|
+
effYear?: number;
|
|
1022
|
+
state?: string;
|
|
1023
|
+
}): string {
|
|
1024
|
+
const terms: string[] = [];
|
|
1025
|
+
if (inp.cert !== undefined)
|
|
1026
|
+
terms.push(filterTerm("CERT", String(inp.cert), FDIC_HISTORY_FILTER_FIELDS));
|
|
1027
|
+
if (inp.changeCode !== undefined)
|
|
1028
|
+
terms.push(filterTerm("CHANGECODE", String(inp.changeCode), FDIC_HISTORY_FILTER_FIELDS));
|
|
1029
|
+
if (inp.effYear !== undefined)
|
|
1030
|
+
terms.push(filterTerm("EFFYEAR", String(inp.effYear), FDIC_HISTORY_FILTER_FIELDS));
|
|
1031
|
+
if (inp.state !== undefined)
|
|
1032
|
+
terms.push(filterTerm("PSTALP", inp.state, FDIC_HISTORY_FILTER_FIELDS));
|
|
1033
|
+
return terms.join(" AND ");
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* ★Q2 — normalize FDIC's `EFFDATE`/`PROCDATE` (`YYYY-MM-DDT00:00:00`, e.g.
|
|
1038
|
+
* `2002-07-01T00:00:00`) to ISO `YYYY-MM-DD`. Match
|
|
1039
|
+
* `^(\d{4})-(\d{2})-(\d{2})T00:00:00$`, extract y/m/d, then validate the calendar
|
|
1040
|
+
* day with the EXACT 3-component Date.UTC round-trip (`getUTCFullYear/Month+1/Date`
|
|
1041
|
+
* all match — rejects JS's silent roll-overs). On success → the ISO date
|
|
1042
|
+
* (`normalized:true`; the month/day are already zero-padded by the `\d{2}` capture).
|
|
1043
|
+
* If the value does NOT match the pattern or fails the round-trip → surface the RAW
|
|
1044
|
+
* upstream value UNCHANGED (`normalized:false`; NEVER null or fabricate a present
|
|
1045
|
+
* date — the handler discloses the raw passthrough); a genuinely-absent value
|
|
1046
|
+
* (null/undefined/"") → str-coerced (null for absent, "" preserved). Live-confirmed
|
|
1047
|
+
* 0/2000 nulls and 0 non-`T00:00:00` and old rows (1782 → `1782-01-01T00:00:00`)
|
|
1048
|
+
* conform, so the raw-passthrough branch is defensive-only. The `9999-12-31T00:00:00`
|
|
1049
|
+
* sentinel round-trips fine to `9999-12-31` (disclosed by the handler's sentinel
|
|
1050
|
+
* note). Exported for the fault fixtures.
|
|
1051
|
+
*/
|
|
1052
|
+
export function normHistDate(raw: unknown): { value: string | null; normalized: boolean } {
|
|
1053
|
+
// A non-string (absent/null/numeric) → honest str coercion (null for absent);
|
|
1054
|
+
// never String()-fabricate "null"/"undefined"/"[object Object]".
|
|
1055
|
+
if (typeof raw !== "string") return { value: str(raw), normalized: false };
|
|
1056
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})T00:00:00$/.exec(raw);
|
|
1057
|
+
if (m) {
|
|
1058
|
+
const y = Number(m[1]);
|
|
1059
|
+
const mo = Number(m[2]);
|
|
1060
|
+
const d = Number(m[3]);
|
|
1061
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
1062
|
+
if (dt.getUTCFullYear() === y && dt.getUTCMonth() + 1 === mo && dt.getUTCDate() === d) {
|
|
1063
|
+
return { value: `${m[1]}-${m[2]}-${m[3]}`, normalized: true };
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
// Present but unrecognized → surface the raw value verbatim (defensive; never taken live).
|
|
1067
|
+
return { value: raw, normalized: false };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export type FdicHistory = {
|
|
1071
|
+
cert: number | null;
|
|
1072
|
+
instName: string | null;
|
|
1073
|
+
state: string | null;
|
|
1074
|
+
changeCode: number | null;
|
|
1075
|
+
changeDescription: string | null;
|
|
1076
|
+
effectiveDate: string | null;
|
|
1077
|
+
processDate: string | null;
|
|
1078
|
+
effYear: string | null;
|
|
1079
|
+
transNum: number | null;
|
|
1080
|
+
acquirerCert: number | null;
|
|
1081
|
+
acquirerName: string | null;
|
|
1082
|
+
outgoingCert: number | null;
|
|
1083
|
+
outgoingName: string | null;
|
|
1084
|
+
survivingCert: number | null;
|
|
1085
|
+
survivingName: string | null;
|
|
1086
|
+
id: string | null;
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
function mapHistory(rec: Record<string, unknown>): FdicHistory {
|
|
1090
|
+
return {
|
|
1091
|
+
cert: num(rec.CERT),
|
|
1092
|
+
instName: str(rec.INSTNAME),
|
|
1093
|
+
state: str(rec.PSTALP), // ★F1-analog — PSTALP, NOT STALP
|
|
1094
|
+
changeCode: num(rec.CHANGECODE),
|
|
1095
|
+
// ★Q1 — changeDescription is FDIC's OWN co-served CHANGECODE_DESC, passed
|
|
1096
|
+
// through verbatim (NOT a hand-map); the numeric changeCode is authoritative.
|
|
1097
|
+
changeDescription: str(rec.CHANGECODE_DESC),
|
|
1098
|
+
effectiveDate: normHistDate(rec.EFFDATE).value,
|
|
1099
|
+
processDate: normHistDate(rec.PROCDATE).value,
|
|
1100
|
+
effYear: str(rec.EFFYEAR),
|
|
1101
|
+
transNum: num(rec.TRANSNUM),
|
|
1102
|
+
// ★Q3 — counterparty triad from *_CERT (NOT *_UNINUM's 0 sentinel). On a
|
|
1103
|
+
// non-merger row these are ABSENT → num/str → null (never a fabricated 0/"").
|
|
1104
|
+
acquirerCert: num(rec.ACQ_CERT),
|
|
1105
|
+
acquirerName: str(rec.ACQ_INSTNAME),
|
|
1106
|
+
outgoingCert: num(rec.OUT_CERT),
|
|
1107
|
+
outgoingName: str(rec.OUT_INSTNAME),
|
|
1108
|
+
survivingCert: num(rec.SUR_CERT),
|
|
1109
|
+
survivingName: str(rec.SUR_INSTNAME),
|
|
1110
|
+
id: str(rec.ID),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// ─── history disclosure notes ──────────────────────────────────────
|
|
1115
|
+
const HIST_DATE_NOTE =
|
|
1116
|
+
"effectiveDate/processDate are normalized from FDIC's YYYY-MM-DDT00:00:00 to ISO YYYY-MM-DD.";
|
|
1117
|
+
const HIST_CHANGEDESC_NOTE =
|
|
1118
|
+
"changeDescription is FDIC's own CHANGECODE_DESC, co-served with the numeric code in each record; the numeric changeCode is authoritative.";
|
|
1119
|
+
const HIST_COUNTERPARTY_NOTE =
|
|
1120
|
+
"acquirer/outgoing/surviving identify the merger counterparties; each Cert links back to fdic_search_institutions / fdic_institution_financials / fdic_bank_failures; null = no counterparty for this event type.";
|
|
1121
|
+
const HIST_SCOPE_NOTE =
|
|
1122
|
+
"Structural-change events for FDIC-insured institutions; a bank ABSENT here (for a given CERT) has no recorded structural change — cross-check with fdic_search_institutions. Name/city are surfaced (instName + counterparty names) but NOT searchable on this endpoint (FDIC's /history `search` param returns 0 for INSTNAME); to find an institution's history, resolve its CERT via fdic_search_institutions, then filter here by `cert`.";
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Institution-level structural-change event log (`/banks/history`). Exact-key
|
|
1126
|
+
* structured inputs: `cert` (→ CERT filter, the PRIMARY lookup), `changeCode` (→
|
|
1127
|
+
* CHANGECODE), `effYear` (→ EFFYEAR), `state` (→ PSTALP filter, ★F1-analog) → the
|
|
1128
|
+
* `filters` param; plus `limit`/`offset`/`sortBy`/`sortOrder` (default EFFDATE DESC
|
|
1129
|
+
* → newest structural change first). Fixed field projection. NO name/city filter and
|
|
1130
|
+
* NEVER a `search=` param (★F2-analog). Consumes the IDENTICAL fetch → 3-envelope
|
|
1131
|
+
* guard → pagination machinery as tools 1–3.
|
|
1132
|
+
*
|
|
1133
|
+
* HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live: CERT 3510
|
|
1134
|
+
* → total 13794, stable across offset); the 3-envelope drift-guard makes the ONLY
|
|
1135
|
+
* honest empty `200 + total:0 + data:[]`, everything else THROWS (P2); changeDescription
|
|
1136
|
+
* is FDIC's co-served CHANGECODE_DESC passed through verbatim (Q1, never hand-mapped);
|
|
1137
|
+
* effectiveDate/processDate normalized YYYY-MM-DDT00:00:00→ISO (unrecognized → raw +
|
|
1138
|
+
* disclosed, never nulled/fabricated; 9999-* sentinel disclosed) (Q2); the ACQ_/OUT_/
|
|
1139
|
+
* SUR_ counterparty CERT-triad is null-never-0 and uses *_CERT not *_UNINUM's 0
|
|
1140
|
+
* sentinel (Q3); a projected always-present field absent from all records →
|
|
1141
|
+
* fieldsUnavailable (B); the snapshot build time is disclosed.
|
|
1142
|
+
*/
|
|
1143
|
+
export async function institutionHistory(args: {
|
|
1144
|
+
cert?: number;
|
|
1145
|
+
changeCode?: number;
|
|
1146
|
+
effYear?: number;
|
|
1147
|
+
state?: string;
|
|
1148
|
+
limit?: number;
|
|
1149
|
+
offset?: number;
|
|
1150
|
+
sortBy?: string;
|
|
1151
|
+
sortOrder?: string;
|
|
1152
|
+
}): Promise<MetaBundle> {
|
|
1153
|
+
const limit = args.limit ?? 100;
|
|
1154
|
+
const offset = args.offset ?? 0;
|
|
1155
|
+
// Default EFFDATE DESC (newest structural change first) when the server's Zod
|
|
1156
|
+
// default did not supply one (defensive — the server always defaults sortBy=EFFDATE/DESC).
|
|
1157
|
+
const sort = sortParams(
|
|
1158
|
+
args.sortBy ?? "EFFDATE",
|
|
1159
|
+
args.sortOrder ?? "DESC",
|
|
1160
|
+
HIST_SORT_FIELDS,
|
|
1161
|
+
"history",
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
const filters = buildHistFilters({
|
|
1165
|
+
cert: args.cert,
|
|
1166
|
+
changeCode: args.changeCode,
|
|
1167
|
+
effYear: args.effYear,
|
|
1168
|
+
state: args.state,
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
const params = new URLSearchParams();
|
|
1172
|
+
if (filters) params.set("filters", filters);
|
|
1173
|
+
params.set("fields", HIST_FIELDS);
|
|
1174
|
+
params.set("limit", String(limit));
|
|
1175
|
+
params.set("offset", String(offset));
|
|
1176
|
+
if (sort.sort_by) {
|
|
1177
|
+
params.set("sort_by", sort.sort_by);
|
|
1178
|
+
params.set("sort_order", sort.sort_order as string);
|
|
1179
|
+
}
|
|
1180
|
+
params.set("format", "json");
|
|
1181
|
+
|
|
1182
|
+
const body = await getFdic(ENDPOINT_HISTORY, params);
|
|
1183
|
+
const env = parseEnvelope(body);
|
|
1184
|
+
const records = env.records.map(mapHistory);
|
|
1185
|
+
const returned = records.length;
|
|
1186
|
+
const totalAvailable = env.totalAvailable;
|
|
1187
|
+
const hasMore = offset + returned < totalAvailable;
|
|
1188
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
1189
|
+
|
|
1190
|
+
const filtersApplied: string[] = [];
|
|
1191
|
+
if (args.cert !== undefined) filtersApplied.push("cert");
|
|
1192
|
+
if (args.changeCode !== undefined) filtersApplied.push("changeCode");
|
|
1193
|
+
if (args.effYear !== undefined) filtersApplied.push("effYear");
|
|
1194
|
+
if (args.state !== undefined) filtersApplied.push("state");
|
|
1195
|
+
if (sort.sort_by) filtersApplied.push("sort");
|
|
1196
|
+
|
|
1197
|
+
const notes: string[] = [
|
|
1198
|
+
freshnessNote(env.indexName, env.indexCreated),
|
|
1199
|
+
HIST_DATE_NOTE,
|
|
1200
|
+
HIST_CHANGEDESC_NOTE,
|
|
1201
|
+
HIST_COUNTERPARTY_NOTE,
|
|
1202
|
+
HIST_SCOPE_NOTE,
|
|
1203
|
+
];
|
|
1204
|
+
// ★Q2 — disclose any present-but-unrecognized EFFDATE/PROCDATE surfaced raw
|
|
1205
|
+
// (defensive; live all conform, so this branch essentially never fires).
|
|
1206
|
+
const anyRawDate = env.records.some((rec) =>
|
|
1207
|
+
(["EFFDATE", "PROCDATE"] as const).some((f) => {
|
|
1208
|
+
const nd = normHistDate(rec[f]);
|
|
1209
|
+
return !nd.normalized && nd.value !== null && nd.value !== "";
|
|
1210
|
+
}),
|
|
1211
|
+
);
|
|
1212
|
+
if (anyRawDate) {
|
|
1213
|
+
notes.push(
|
|
1214
|
+
"One or more effectiveDate/processDate values did not match FDIC's YYYY-MM-DDT00:00:00 format (or failed the calendar round-trip) and were surfaced RAW (not normalized to ISO) — never nulled or fabricated.",
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
// ★Q2 sentinel — a 9999-* effectiveDate/processDate is FDIC's "not-applicable /
|
|
1218
|
+
// open" sentinel (normally on the un-projected ACQDATE/ENDDATE, but flagged here
|
|
1219
|
+
// should it ever surface on the projected dates). It round-trips to itself.
|
|
1220
|
+
const anySentinel = env.records.some((rec) =>
|
|
1221
|
+
(["EFFDATE", "PROCDATE"] as const).some(
|
|
1222
|
+
(f) => typeof rec[f] === "string" && (rec[f] as string).startsWith("9999"),
|
|
1223
|
+
),
|
|
1224
|
+
);
|
|
1225
|
+
if (anySentinel) {
|
|
1226
|
+
notes.push(
|
|
1227
|
+
"One or more effectiveDate/processDate values carry FDIC's 9999-* 'not-applicable / open' sentinel — surfaced verbatim, not treated as a real event date.",
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
const fu = fieldsUnavailable(env.records, HIST_PROJECTION);
|
|
1231
|
+
if (fu.length > 0) {
|
|
1232
|
+
notes.push(
|
|
1233
|
+
`Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return withMeta(
|
|
1238
|
+
{ history: records },
|
|
1239
|
+
{
|
|
1240
|
+
source: "api.fdic.gov/banks/history (BankFind, keyless)",
|
|
1241
|
+
keylessMode: true,
|
|
1242
|
+
returned,
|
|
1243
|
+
totalAvailable,
|
|
1244
|
+
filtersApplied,
|
|
1245
|
+
filtersDropped: [],
|
|
1246
|
+
fieldsUnavailable: fu,
|
|
1247
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
1248
|
+
notes,
|
|
1249
|
+
} satisfies Partial<ResponseMeta>,
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1254
|
+
// Tool 5: fdic_industry_summary (ADR-0031) — the FDIC's OWN aggregate/statistical
|
|
1255
|
+
// roll-ups (`/banks/summary`): industry-wide and per-state ANNUAL aggregate financials
|
|
1256
|
+
// (total assets, deposits, net income, equity, net interest income) + structural
|
|
1257
|
+
// counts (institutions, offices, branches, employees), grouped by CHARTER CLASS
|
|
1258
|
+
// (commercial banks vs savings institutions). ★NEW CAPABILITY TYPE for the source —
|
|
1259
|
+
// the FIRST AGGREGATE / statistical tool (the 4 existing tools are all per-ENTITY,
|
|
1260
|
+
// keyed on CERT). Answers "how big is the US (or a state's) banking industry this
|
|
1261
|
+
// year, and how many institutions?" — a question NONE of the 4 entity tools can
|
|
1262
|
+
// express without manually summing thousands of institution rows. Reuses the
|
|
1263
|
+
// C116/C118-hardened adapter VERBATIM (getFdic / parseEnvelope 3-envelope guard /
|
|
1264
|
+
// filterTerm allowlist-by-construction + Set.has / sortParams enum+Set.has / EXACT
|
|
1265
|
+
// meta.total pagination / snapshot-freshness / $thousands→USD ×1000 null-never-0 /
|
|
1266
|
+
// the C118-quoted filterTerm). It does NOT use searchTerm/escapeSearch (no name
|
|
1267
|
+
// search — /summary's `search` param is a no-op that returns the whole year). The NEW
|
|
1268
|
+
// surface is exactly four things, all live-verified 2026-07-13:
|
|
1269
|
+
// ★S1 — TWO cross-cut dimensions (NOT one): (1) charter class CB_SI (CB=commercial
|
|
1270
|
+
// banks, SI=savings institutions — every row is scoped to EXACTLY ONE; there is NO
|
|
1271
|
+
// pre-combined "all institutions" row), and (2) geography STALP (a MIX of
|
|
1272
|
+
// per-jurisdiction leaf rows AND geographic ROLL-UP rows).
|
|
1273
|
+
// ★S2 (the honesty crux) — the roll-up-vs-jurisdiction split. STALP ∈ {USA,US,OT,PI}
|
|
1274
|
+
// are GEOGRAPHIC AGGREGATES (scope national_total / national_states_dc /
|
|
1275
|
+
// territories_total / pacific_islands, isRollup:true); every other STALP is a
|
|
1276
|
+
// single jurisdiction (isRollup:false). Surfaced as derived scope+isRollup on
|
|
1277
|
+
// EVERY row + a MANDATORY disclosure: NEVER sum a roll-up row with jurisdiction
|
|
1278
|
+
// rows or across scopes (live-proven: Σ jurisdictions = USA; USA − US = OT). A
|
|
1279
|
+
// roll-up row must NEVER masquerade as a state.
|
|
1280
|
+
// ★S3 — the aggregate FIELD NAMES: number-of-institutions is BANKS (NOT NUMINST);
|
|
1281
|
+
// /summary serves NO ratio fields at all (ROA/ROE/NIMY/ERNAST absent) — only
|
|
1282
|
+
// $-aggregates + integer COUNTS.
|
|
1283
|
+
// ★S4 (the NIM foot-gun) — on /summary NIM is net interest INCOME in $thousands
|
|
1284
|
+
// (Alabama CB 2023 → 7,457,074 = $7.5B; USA CB → 660,219,591 = $660B), NOT the
|
|
1285
|
+
// net-interest-margin percentage. It MUST be ×1000-scaled like every money field
|
|
1286
|
+
// AND relabeled net interest income (netInterestIncomeUSD). Money
|
|
1287
|
+
// (ASSET/DEP/NETINC/EQ/NIM) ×1000 with the null-guard BEFORE the multiply; counts
|
|
1288
|
+
// (BANKS/OFFICES/BRANCHES/NUMEMP) pass through un-scaled (a count ×1000 is a
|
|
1289
|
+
// fabrication). A genuine 0 (American Samoa CB BANKS:0/ASSET:0/NIM:0) stays 0; an
|
|
1290
|
+
// absent value (American Samoa SI has NO BANKS key) stays null (never 0).
|
|
1291
|
+
// The state filter field is STALP (NOT PSTALP — a per-endpoint difference from
|
|
1292
|
+
// /failures & /history), C118-quoted so Oregon `STALP:"OR"` is operator-safe. NO
|
|
1293
|
+
// name/city filter, NEVER a `search=` param. The unknown-filter-field false-empty
|
|
1294
|
+
// (200/total:0) is neutralized by the allowlist-by-construction; the malformed-year
|
|
1295
|
+
// false-empty (YEAR:notanum → total:0) is guarded by Zod .int() at the boundary.
|
|
1296
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1297
|
+
|
|
1298
|
+
// Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
|
|
1299
|
+
const ENDPOINT_SUMMARY = "summary";
|
|
1300
|
+
|
|
1301
|
+
// Fixed field projection on the wire (every field live-verified present + non-null on
|
|
1302
|
+
// both a leaf row (CA CB) and a roll-up row (USA CB); genuine 0/absent handled by the
|
|
1303
|
+
// map). NO ratio fields (they do not exist on /summary).
|
|
1304
|
+
const SUMMARY_FIELDS =
|
|
1305
|
+
"YEAR,CB_SI,STNAME,STALP,STNUM,BANKS,OFFICES,BRANCHES,NUMEMP,ASSET,DEP,NETINC,EQ,NIM,ID";
|
|
1306
|
+
const SUMMARY_PROJECTION = [
|
|
1307
|
+
"YEAR",
|
|
1308
|
+
"CB_SI",
|
|
1309
|
+
"STNAME",
|
|
1310
|
+
"STALP",
|
|
1311
|
+
"STNUM",
|
|
1312
|
+
"BANKS",
|
|
1313
|
+
"OFFICES",
|
|
1314
|
+
"BRANCHES",
|
|
1315
|
+
"NUMEMP",
|
|
1316
|
+
"ASSET",
|
|
1317
|
+
"DEP",
|
|
1318
|
+
"NETINC",
|
|
1319
|
+
"EQ",
|
|
1320
|
+
"NIM",
|
|
1321
|
+
"ID",
|
|
1322
|
+
] as const;
|
|
1323
|
+
|
|
1324
|
+
// ★S3 — the summary filter-FIELD allowlist (P4 belt-and-suspenders). ONLY these three
|
|
1325
|
+
// (a caller never supplies a field name — they are compile-time constants behind named
|
|
1326
|
+
// inputs). YEAR is numeric (emitted bare by filterTerm); STALP/CB_SI are non-numeric
|
|
1327
|
+
// (C118-double-quoted). An un-allowlisted field → invalid_input pre-fetch (guards the
|
|
1328
|
+
// live HTTP-200 total:0 unknown-field false-empty).
|
|
1329
|
+
const FDIC_SUMMARY_FILTER_FIELDS: ReadonlySet<string> = new Set(["YEAR", "STALP", "CB_SI"]);
|
|
1330
|
+
// sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams →
|
|
1331
|
+
// an unknown sort field is invalid_input BEFORE fetch; live `sort_by=NOTAFIELD` → 400,
|
|
1332
|
+
// so the pre-fetch guard is load-bearing).
|
|
1333
|
+
const SUMMARY_SORT_FIELDS: ReadonlySet<string> = new Set(["YEAR", "ASSET", "DEP", "NETINC", "BANKS"]);
|
|
1334
|
+
|
|
1335
|
+
// Exported so the fault suite can drive the belt-and-suspenders field-guard directly.
|
|
1336
|
+
export { FDIC_SUMMARY_FILTER_FIELDS };
|
|
1337
|
+
|
|
1338
|
+
// ★S2 — the explicit roll-up STALP set (the honesty discriminator; live-verified
|
|
1339
|
+
// exhaustive over all 121 rows of 2023, and structurally by FIPS STNUM ∈ {0,99,98,97}).
|
|
1340
|
+
const ROLLUP_STALP: ReadonlySet<string> = new Set(["USA", "US", "OT", "PI"]);
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* ★CRUX-1 (S2) — derive the geographic SCOPE from the raw STALP, using the explicit
|
|
1344
|
+
* roll-up set (a fixed 4-element discriminator, NOT a fragile numeric threshold).
|
|
1345
|
+
* A non-roll-up value (incl. null / any future/unknown STALP) falls through to
|
|
1346
|
+
* "jurisdiction" and is surfaced by the ALWAYS-projected raw STNAME/STALP/STNUM — a
|
|
1347
|
+
* caller can always see the literal label, never a silently-mislabeled aggregate.
|
|
1348
|
+
* Exported for the fault fixtures.
|
|
1349
|
+
*/
|
|
1350
|
+
export function scopeOf(stalp: string | null): string {
|
|
1351
|
+
switch (stalp) {
|
|
1352
|
+
case "USA":
|
|
1353
|
+
return "national_total"; // STNUM 0 — 50 states + DC + all territories (grand total)
|
|
1354
|
+
case "US":
|
|
1355
|
+
return "national_states_dc"; // STNUM 99 — 50 states + DC, EXCL territories
|
|
1356
|
+
case "OT":
|
|
1357
|
+
return "territories_total"; // STNUM 98 — all US territories
|
|
1358
|
+
case "PI":
|
|
1359
|
+
return "pacific_islands"; // STNUM 97 — Pacific-island territories (⊂ OT)
|
|
1360
|
+
default:
|
|
1361
|
+
return "jurisdiction"; // a single state / DC / individual territory (or an absent/unknown STALP)
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* ★CRUX-1b (S1) — map the raw CB_SI charter code to a readable class. An UNMAPPED
|
|
1367
|
+
* value returns the raw code (never fabricated); a genuinely-absent (null) value stays
|
|
1368
|
+
* null. Exported for the fault fixtures.
|
|
1369
|
+
*/
|
|
1370
|
+
export function charterClassOf(code: string | null): string | null {
|
|
1371
|
+
if (code === "CB") return "commercial_banks";
|
|
1372
|
+
if (code === "SI") return "savings_institutions";
|
|
1373
|
+
return code; // unmapped → the raw code (never invented); null → null
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Build the summary `filters` string — EXACT-KEY terms only: `year`→`YEAR:<int>`
|
|
1378
|
+
* (numeric → emitted BARE by filterTerm; a non-int is guarded pre-fetch by Zod .int()),
|
|
1379
|
+
* `state`→`STALP:"<code>"` (★S2 — STALP is the /summary state field, NOT PSTALP;
|
|
1380
|
+
* C118-double-quoted so Oregon `STALP:"OR"` is Lucene-operator-safe), `charterClass`
|
|
1381
|
+
* →`CB_SI:"<v>"` (quoted). There is NO name/city term (★/summary's `search` is a no-op
|
|
1382
|
+
* that returns the whole year — this tool never emits `search=`). Terms joined with
|
|
1383
|
+
* ` AND `. Returns "" when there is no structured filter clause. Exported for the fault
|
|
1384
|
+
* fixtures.
|
|
1385
|
+
*/
|
|
1386
|
+
export function buildSummaryFilters(inp: {
|
|
1387
|
+
year?: number;
|
|
1388
|
+
state?: string;
|
|
1389
|
+
charterClass?: string;
|
|
1390
|
+
}): string {
|
|
1391
|
+
const terms: string[] = [];
|
|
1392
|
+
if (inp.year !== undefined)
|
|
1393
|
+
terms.push(filterTerm("YEAR", String(inp.year), FDIC_SUMMARY_FILTER_FIELDS));
|
|
1394
|
+
if (inp.state !== undefined)
|
|
1395
|
+
terms.push(filterTerm("STALP", inp.state, FDIC_SUMMARY_FILTER_FIELDS));
|
|
1396
|
+
if (inp.charterClass !== undefined)
|
|
1397
|
+
terms.push(filterTerm("CB_SI", inp.charterClass, FDIC_SUMMARY_FILTER_FIELDS));
|
|
1398
|
+
return terms.join(" AND ");
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
export type FdicIndustrySummary = {
|
|
1402
|
+
year: number | null;
|
|
1403
|
+
charterClass: string | null;
|
|
1404
|
+
charterClassCode: string | null;
|
|
1405
|
+
geography: string | null;
|
|
1406
|
+
stateCode: string | null;
|
|
1407
|
+
stateFips: string | null;
|
|
1408
|
+
scope: string;
|
|
1409
|
+
isRollup: boolean;
|
|
1410
|
+
institutionCount: number | null;
|
|
1411
|
+
officeCount: number | null;
|
|
1412
|
+
branchCount: number | null;
|
|
1413
|
+
employeeCount: number | null;
|
|
1414
|
+
totalAssetsUSD: number | null;
|
|
1415
|
+
totalDepositsUSD: number | null;
|
|
1416
|
+
netIncomeUSD: number | null;
|
|
1417
|
+
totalEquityUSD: number | null;
|
|
1418
|
+
netInterestIncomeUSD: number | null;
|
|
1419
|
+
id: string | null;
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
function mapSummary(rec: Record<string, unknown>): FdicIndustrySummary {
|
|
1423
|
+
const stateCode = str(rec.STALP);
|
|
1424
|
+
const charterClassCode = str(rec.CB_SI);
|
|
1425
|
+
const scope = scopeOf(stateCode); // ★S2 — derived from the explicit roll-up set
|
|
1426
|
+
return {
|
|
1427
|
+
year: num(rec.YEAR), // YEAR is a string field ("2023") → num parses the digits
|
|
1428
|
+
charterClass: charterClassOf(charterClassCode), // ★S1 — CB→commercial_banks, SI→savings_institutions (unmapped→raw)
|
|
1429
|
+
charterClassCode,
|
|
1430
|
+
geography: str(rec.STNAME),
|
|
1431
|
+
stateCode,
|
|
1432
|
+
stateFips: str(rec.STNUM),
|
|
1433
|
+
scope,
|
|
1434
|
+
isRollup: scope !== "jurisdiction", // ★S2 — a roll-up must NEVER masquerade as a state
|
|
1435
|
+
// ★S4 / P3 — COUNT fields pass through via num (NEVER ×1000; a count ×1000 is a
|
|
1436
|
+
// fabrication). A genuine 0 (American Samoa CB BANKS:0) stays 0; an absent value
|
|
1437
|
+
// (American Samoa SI has no BANKS key) stays null (never 0).
|
|
1438
|
+
institutionCount: num(rec.BANKS),
|
|
1439
|
+
officeCount: num(rec.OFFICES),
|
|
1440
|
+
branchCount: num(rec.BRANCHES),
|
|
1441
|
+
employeeCount: num(rec.NUMEMP),
|
|
1442
|
+
// ★S4 / P3 — MONEY fields ($thousands → whole USD ×1000, null-guard BEFORE the
|
|
1443
|
+
// multiply; a genuine 0 stays 0, absent → null).
|
|
1444
|
+
totalAssetsUSD: thousandsToUsd(rec.ASSET),
|
|
1445
|
+
totalDepositsUSD: thousandsToUsd(rec.DEP),
|
|
1446
|
+
netIncomeUSD: thousandsToUsd(rec.NETINC),
|
|
1447
|
+
totalEquityUSD: thousandsToUsd(rec.EQ),
|
|
1448
|
+
// ★S4 — NIM is net interest INCOME ($thousands), NOT the margin ratio: scaled ×1000
|
|
1449
|
+
// and relabeled income (never surfaced as a "margin").
|
|
1450
|
+
netInterestIncomeUSD: thousandsToUsd(rec.NIM),
|
|
1451
|
+
id: str(rec.ID),
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// ─── summary disclosure notes ──────────────────────────────────────
|
|
1456
|
+
// ★S2 — the load-bearing roll-up double-count-prevention disclosure (P4/P1 frontier).
|
|
1457
|
+
const SUMMARY_ROLLUP_NOTE =
|
|
1458
|
+
"Rows cross charter class (CB_SI) × geography (STALP); STALP ∈ {USA,US,OT,PI} are ROLL-UP totals (isRollup:true, scope national_total/national_states_dc/territories_total/pacific_islands) — NEVER sum a roll-up row with jurisdiction rows, and NEVER sum across scopes: national_total (USA) = national_states_dc (US) + territories_total (OT), and pacific_islands (PI) is a SUBSET of territories (live-proven: Σ jurisdictions = USA; USA − US = OT). A geography's total = its CB row + its SI row (there is NO pre-combined charter row). Filter by state/charterClass or by isRollup to avoid double-counting; to get one national figure read the national_total (USA) row directly rather than summing states — a roll-up row is NOT a state.";
|
|
1459
|
+
// ★S1 — the charter-class split (no combined row).
|
|
1460
|
+
const SUMMARY_CHARTER_NOTE =
|
|
1461
|
+
"Each row covers ONE charter class: CB = commercial_banks, SI = savings_institutions. There is NO pre-combined 'all institutions' row — a geography's all-FDIC-insured total for a year = its CB row + its SI row (omit charterClass to fetch both).";
|
|
1462
|
+
// ★S4 / P3 — money vs count units + the NIM foot-gun + the no-ratios fact.
|
|
1463
|
+
const SUMMARY_UNITS_NOTE =
|
|
1464
|
+
"ASSET/DEP/NETINC/EQ/NIM are $thousands, normalized here to whole USD (×1,000); NIM is net interest INCOME (a dollar sum, surfaced as netInterestIncomeUSD), NOT the net-interest-margin ratio. BANKS/OFFICES/BRANCHES/EMPLOYEES are COUNTS (not scaled). This endpoint provides NO ratio fields (ROA/ROE); derive them from netIncomeUSD / totalAssetsUSD / totalEquityUSD if needed. A real 0 stays 0; an absent value is null (never 0).";
|
|
1465
|
+
// ★ name-search scope (no institution-name search on /summary).
|
|
1466
|
+
const SUMMARY_SCOPE_NOTE =
|
|
1467
|
+
"This is FDIC's aggregate roll-up endpoint; it has no institution-name search (FDIC's /summary `search` param is ignored and returns the whole year). To drill from an industry aggregate to individual institutions, use fdic_search_institutions (filter by state) or fdic_institution_financials (by CERT).";
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* FDIC industry & state banking-sector ANNUAL aggregates (`/banks/summary`) — the
|
|
1471
|
+
* FIRST aggregate/statistical FDIC tool. Exact-key structured inputs (all optional,
|
|
1472
|
+
* AND-combined): `year` (→ YEAR filter), `state` (→ STALP filter, ★S2 — STALP NOT
|
|
1473
|
+
* PSTALP; accepts a jurisdiction code OR a roll-up code USA/US/OT/PI), `charterClass`
|
|
1474
|
+
* (→ CB_SI filter; CB/SI) → the `filters` param; plus `limit`/`offset`/`sortBy`/
|
|
1475
|
+
* `sortOrder` (default YEAR DESC → newest aggregate year first). Fixed field
|
|
1476
|
+
* projection. NO name/city filter and NEVER a `search=` param. Consumes the IDENTICAL
|
|
1477
|
+
* fetch → 3-envelope guard → pagination machinery as tools 1–4.
|
|
1478
|
+
*
|
|
1479
|
+
* HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live YEAR:2023 →
|
|
1480
|
+
* 121, stable across offset); the 3-envelope drift-guard makes the ONLY honest empty
|
|
1481
|
+
* `200 + total:0 + data:[]`, everything else THROWS (P2); money (ASSET/DEP/NETINC/EQ/
|
|
1482
|
+
* NIM) is $thousands → whole USD ×1000 null-never-0, counts (BANKS/OFFICES/BRANCHES/
|
|
1483
|
+
* NUMEMP) pass through un-scaled (P3; a genuine 0 stays 0, absent → null); ★NIM is net
|
|
1484
|
+
* interest INCOME (scaled + relabeled netInterestIncomeUSD), NOT the margin ratio (S4);
|
|
1485
|
+
* ★scope/isRollup are derived per row from the explicit roll-up STALP set so a roll-up
|
|
1486
|
+
* never masquerades as a state (S2); charterClass is derived from CB_SI (S1); a
|
|
1487
|
+
* projected field absent from all records → fieldsUnavailable (B); the snapshot build
|
|
1488
|
+
* time is disclosed.
|
|
1489
|
+
*/
|
|
1490
|
+
export async function industrySummary(args: {
|
|
1491
|
+
year?: number;
|
|
1492
|
+
state?: string;
|
|
1493
|
+
charterClass?: string;
|
|
1494
|
+
limit?: number;
|
|
1495
|
+
offset?: number;
|
|
1496
|
+
sortBy?: string;
|
|
1497
|
+
sortOrder?: string;
|
|
1498
|
+
}): Promise<MetaBundle> {
|
|
1499
|
+
const limit = args.limit ?? 100;
|
|
1500
|
+
const offset = args.offset ?? 0;
|
|
1501
|
+
// Default YEAR DESC (newest aggregate year first) when the server's Zod default did
|
|
1502
|
+
// not supply one (defensive — the server always defaults sortBy=YEAR/DESC).
|
|
1503
|
+
const sort = sortParams(args.sortBy ?? "YEAR", args.sortOrder ?? "DESC", SUMMARY_SORT_FIELDS, "summary");
|
|
1504
|
+
|
|
1505
|
+
const filters = buildSummaryFilters({
|
|
1506
|
+
year: args.year,
|
|
1507
|
+
state: args.state,
|
|
1508
|
+
charterClass: args.charterClass,
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
const params = new URLSearchParams();
|
|
1512
|
+
if (filters) params.set("filters", filters);
|
|
1513
|
+
params.set("fields", SUMMARY_FIELDS);
|
|
1514
|
+
params.set("limit", String(limit));
|
|
1515
|
+
params.set("offset", String(offset));
|
|
1516
|
+
if (sort.sort_by) {
|
|
1517
|
+
params.set("sort_by", sort.sort_by);
|
|
1518
|
+
params.set("sort_order", sort.sort_order as string);
|
|
1519
|
+
}
|
|
1520
|
+
params.set("format", "json");
|
|
1521
|
+
|
|
1522
|
+
const body = await getFdic(ENDPOINT_SUMMARY, params);
|
|
1523
|
+
const env = parseEnvelope(body);
|
|
1524
|
+
const records = env.records.map(mapSummary);
|
|
1525
|
+
const returned = records.length;
|
|
1526
|
+
const totalAvailable = env.totalAvailable;
|
|
1527
|
+
const hasMore = offset + returned < totalAvailable;
|
|
1528
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
1529
|
+
|
|
1530
|
+
const filtersApplied: string[] = [];
|
|
1531
|
+
if (args.year !== undefined) filtersApplied.push("year");
|
|
1532
|
+
if (args.state !== undefined) filtersApplied.push("state");
|
|
1533
|
+
if (args.charterClass !== undefined) filtersApplied.push("charterClass");
|
|
1534
|
+
if (sort.sort_by) filtersApplied.push("sort");
|
|
1535
|
+
|
|
1536
|
+
const notes: string[] = [
|
|
1537
|
+
freshnessNote(env.indexName, env.indexCreated),
|
|
1538
|
+
SUMMARY_ROLLUP_NOTE,
|
|
1539
|
+
SUMMARY_CHARTER_NOTE,
|
|
1540
|
+
SUMMARY_UNITS_NOTE,
|
|
1541
|
+
SUMMARY_SCOPE_NOTE,
|
|
1542
|
+
];
|
|
1543
|
+
const fu = fieldsUnavailable(env.records, SUMMARY_PROJECTION);
|
|
1544
|
+
if (fu.length > 0) {
|
|
1545
|
+
// /banks/summary is a 2-D roll-up: some count fields (e.g. BANKS/OFFICES/
|
|
1546
|
+
// BRANCHES) are STRUCTURALLY absent for certain roll-up/territory rows
|
|
1547
|
+
// (e.g. the Pacific-Islands roll-up carries no bank/branch count) — that is
|
|
1548
|
+
// a legitimate structural absence, NOT necessarily a schema change. Disclose
|
|
1549
|
+
// the absence honestly without falsely diagnosing "schema drift".
|
|
1550
|
+
notes.push(
|
|
1551
|
+
`Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record in this result set — for /banks/summary this is usually a structural absence (some count fields are not reported for certain roll-up or territory rows), though it can also indicate a schema change; the affected values are surfaced as null, never fabricated.`,
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
return withMeta(
|
|
1556
|
+
{ summary: records },
|
|
1557
|
+
{
|
|
1558
|
+
source: "api.fdic.gov/banks/summary (BankFind, keyless)",
|
|
1559
|
+
keylessMode: true,
|
|
1560
|
+
returned,
|
|
1561
|
+
totalAvailable,
|
|
1562
|
+
filtersApplied,
|
|
1563
|
+
filtersDropped: [],
|
|
1564
|
+
fieldsUnavailable: fu,
|
|
1565
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
1566
|
+
notes,
|
|
1567
|
+
} satisfies Partial<ResponseMeta>,
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1572
|
+
// Tool 6: fdic_risk_ratios (ADR-0040) — the FDIC counterparty-SOUNDNESS lane, a
|
|
1573
|
+
// WITHIN-SOURCE DEPTH tool on the ALREADY-WIRED `/banks/financials` endpoint (NO new
|
|
1574
|
+
// endpoint constant). The 5 existing FDIC tools surface only $-aggregates (assets /
|
|
1575
|
+
// deposits / net income); this tool projects the CURATED RISK-RATIO catalog
|
|
1576
|
+
// (profitability ROA/ROE, margin NIM, cost efficiency, capital adequacy leverage /
|
|
1577
|
+
// tier-1 / total risk-based, asset quality net charge-offs) + the tier-1 capital
|
|
1578
|
+
// LEVEL, keyed on CERT. Reuses the C116/C118-hardened adapter VERBATIM (getFdic /
|
|
1579
|
+
// parseEnvelope 3-envelope guard / filterTerm allowlist + Set.has / sortParams
|
|
1580
|
+
// enum+Set.has / EXACT meta.total pagination / freshness / thousandsToUsd / num). The
|
|
1581
|
+
// NEW surface is the PER-FIELD-UNITS ratio catalog + honesty, all live-verified
|
|
1582
|
+
// 2026-07-13:
|
|
1583
|
+
// ★P3 UNITS-IN-THE-KEY — a MODULE-CONSTANT catalog fieldCode→{outputKey,label,unit,
|
|
1584
|
+
// route}. The 9 PERCENT ratios (ROA/ROAPTX/ROE/NIMY/EEFFR/NTLNLSR/RBC1AAJ/RBC1RWAJ/
|
|
1585
|
+
// RBCRWAJ) route through `num` and are surfaced VERBATIM (NO scale, NO recompute);
|
|
1586
|
+
// the ONE $-amount (RBCT1J, tier-1 capital in $thousands) routes through
|
|
1587
|
+
// thousandsToUsd → tier1CapitalUSD (×1000). The unit rides the output KEY (…Pct /
|
|
1588
|
+
// …USD) AND the mandatory RATIO_UNITS_NOTE — a consumer never reads a 77.98%
|
|
1589
|
+
// efficiency ratio as a dollar amount or ×1000-scales a percent.
|
|
1590
|
+
// ★P3 NULL-NEVER-0 — a not-reported ratio → null (via `num`, which maps BOTH JSON
|
|
1591
|
+
// null and undefined → null), NEVER 0 (a false "no return / no capital"). FDIC
|
|
1592
|
+
// returns a not-reported ratio as an EXPLICIT null (key present, value null), NOT
|
|
1593
|
+
// an absent key — so we read every ratio via `num(rec.CODE)`, NEVER via
|
|
1594
|
+
// `'CODE' in rec` / `=== undefined` / hasOwnProperty (S2).
|
|
1595
|
+
// ★M1 (the BLOCKER) — the CBLR `RBCRWAJ=0` sentinel. LIVE: 37% of banks are
|
|
1596
|
+
// Community-Bank-Leverage-Ratio filers (CBLRIND:1) that return RBCRWAJ as a
|
|
1597
|
+
// LITERAL 0 (not null) while RBC1RWAJ is null and the leverage ratio RBC1AAJ is
|
|
1598
|
+
// populated + solvent. Routing RBCRWAJ verbatim through num yields num(0)=0 = a
|
|
1599
|
+
// false "0% total capital / insolvent" on healthy banks. FIX (CBLR-scoped, no
|
|
1600
|
+
// recompute, NO blanket 0→null): CBLRIND is added to the projection; when
|
|
1601
|
+
// CBLRIND===1 we map BOTH totalRiskBasedCapitalRatioPct AND
|
|
1602
|
+
// tier1RiskBasedCapitalRatioPct to null ("not applicable — CBLR framework") and
|
|
1603
|
+
// surface a per-row cblrFramework:boolean. Detection is via FDIC's OWN CBLRIND
|
|
1604
|
+
// flag — never a derived/recomputed ratio. Genuine zeros on OTHER fields
|
|
1605
|
+
// (NTLNLSR:0 = zero net charge-offs; /sod DEPSUMBR:0) are UNTOUCHED — this is NOT
|
|
1606
|
+
// a blanket 0→null.
|
|
1607
|
+
// ★P3 NO-RECOMPUTE — every ratio is FDIC's published value surfaced verbatim; the
|
|
1608
|
+
// tool never computes ROE=NETINC/EQ (or any ratio) itself (a computed ratio would
|
|
1609
|
+
// diverge from FDIC's official figure = a fabrication).
|
|
1610
|
+
// ★S2 per-code not-reported marker — each catalog entry carries an explicit
|
|
1611
|
+
// `notReported` marker ('null' | 'zero-sentinel-when-CBLR'): FDIC's not-reported
|
|
1612
|
+
// encoding is FIELD-SPECIFIC (RBC1RWAJ uses JSON null; RBCRWAJ uses a literal-0
|
|
1613
|
+
// sentinel in the CBLR cohort; NTLNLSR/EEFFR/NIMY 0 are GENUINE zeros). Any newly
|
|
1614
|
+
// added code MUST be live-validated for its 0/null encoding before shipping —
|
|
1615
|
+
// never assume the ROA/ROE null path generalizes.
|
|
1616
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1617
|
+
|
|
1618
|
+
// ★P3 — the per-field UNIT of a catalog ratio: a PERCENT (surfaced verbatim via num,
|
|
1619
|
+
// no scale) or a $-amount FDIC publishes in $thousands (×1000 via thousandsToUsd).
|
|
1620
|
+
type RatioUnit = "percent" | "usd-thousands";
|
|
1621
|
+
// ★S2 — the FIELD-SPECIFIC not-reported encoding marker. 'null' = FDIC returns an
|
|
1622
|
+
// explicit JSON null when not reported (the ROA/ROE/… path). 'zero-sentinel-when-CBLR'
|
|
1623
|
+
// = FDIC returns a LITERAL 0 sentinel for the risk-based capital ratios when the bank
|
|
1624
|
+
// files under the CBLR framework (the M1 blocker) — a 0 there is NOT a real 0%.
|
|
1625
|
+
type RatioNotReported = "null" | "zero-sentinel-when-CBLR";
|
|
1626
|
+
type RatioEntry = {
|
|
1627
|
+
code: string; // the FDIC field code (live-verified valid — a typo would silently drop off the wire)
|
|
1628
|
+
outputKey: string; // the output key WITH its unit suffix (…Pct / …USD)
|
|
1629
|
+
label: string; // human label (from the FDIC RIS / Call-Report data dictionary)
|
|
1630
|
+
unit: RatioUnit;
|
|
1631
|
+
notReported: RatioNotReported;
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
// ★P3 — the CURATED ratio catalog (a MODULE CONSTANT). Every code live-verified valid
|
|
1635
|
+
// + its unit pinned from the FDIC data dictionary AND the live values. Do NOT include
|
|
1636
|
+
// ROAA/ROEA (invalid codes — silently dropped from the record). Exported for the
|
|
1637
|
+
// catalog-shape fault fixture.
|
|
1638
|
+
const RATIO_CATALOG: readonly RatioEntry[] = [
|
|
1639
|
+
{ code: "ROA", outputKey: "returnOnAssetsPct", label: "Return on assets", unit: "percent", notReported: "null" },
|
|
1640
|
+
{ code: "ROAPTX", outputKey: "preTaxReturnOnAssetsPct", label: "Pretax return on assets", unit: "percent", notReported: "null" },
|
|
1641
|
+
{ code: "ROE", outputKey: "returnOnEquityPct", label: "Return on equity", unit: "percent", notReported: "null" },
|
|
1642
|
+
{ code: "NIMY", outputKey: "netInterestMarginPct", label: "Net interest margin", unit: "percent", notReported: "null" },
|
|
1643
|
+
{ code: "EEFFR", outputKey: "efficiencyRatioPct", label: "Efficiency ratio (noninterest expense / revenue)", unit: "percent", notReported: "null" },
|
|
1644
|
+
{ code: "NTLNLSR", outputKey: "netChargeOffsToLoansPct", label: "Net charge-offs to loans & leases", unit: "percent", notReported: "null" },
|
|
1645
|
+
{ code: "RBC1AAJ", outputKey: "leverageRatioPct", label: "Leverage (core capital) ratio", unit: "percent", notReported: "null" },
|
|
1646
|
+
// ★M1 — the two risk-based capital ratios carry the CBLR literal-0 / null sentinel.
|
|
1647
|
+
{ code: "RBC1RWAJ", outputKey: "tier1RiskBasedCapitalRatioPct", label: "Tier-1 risk-based capital ratio", unit: "percent", notReported: "zero-sentinel-when-CBLR" },
|
|
1648
|
+
{ code: "RBCRWAJ", outputKey: "totalRiskBasedCapitalRatioPct", label: "Total risk-based capital ratio", unit: "percent", notReported: "zero-sentinel-when-CBLR" },
|
|
1649
|
+
{ code: "RBCT1J", outputKey: "tier1CapitalUSD", label: "Tier-1 (core) capital", unit: "usd-thousands", notReported: "null" },
|
|
1650
|
+
] as const;
|
|
1651
|
+
|
|
1652
|
+
export { RATIO_CATALOG };
|
|
1653
|
+
|
|
1654
|
+
// Fixed field projection on the wire (CERT/REPDTE/ID + CBLRIND [★M1 sentinel detection]
|
|
1655
|
+
// + every catalog code). Built FROM the catalog so a catalog edit can never drift from
|
|
1656
|
+
// the wire projection.
|
|
1657
|
+
const RATIO_CODES: readonly string[] = RATIO_CATALOG.map((e) => e.code);
|
|
1658
|
+
const RATIO_FIELDS = ["CERT", "REPDTE", "CBLRIND", ...RATIO_CODES, "ID"].join(",");
|
|
1659
|
+
// The returned-fields (B) disclosure projection. FDIC returns a not-reported ratio as
|
|
1660
|
+
// an EXPLICIT null (key PRESENT) — so a code shows in Object.keys even when null, and
|
|
1661
|
+
// fieldsUnavailable fires ONLY on a genuinely-absent key (real schema drift / a code
|
|
1662
|
+
// FDIC does not publish for these rows), never on a normal not-reported null.
|
|
1663
|
+
const RATIO_PROJECTION = ["CERT", "REPDTE", "CBLRIND", ...RATIO_CODES, "ID"] as const;
|
|
1664
|
+
|
|
1665
|
+
// ★S1 — the ratio filter-FIELD allowlist (P4 belt-and-suspenders). CERT + optional
|
|
1666
|
+
// REPDTE, both NUMERIC (emitted bare by filterTerm). Do NOT reuse FIN_FILTER_FIELDS
|
|
1667
|
+
// ({CERT}) — it would THROW on REPDTE. Exported for the fault fixture.
|
|
1668
|
+
const RATIO_FILTER_FIELDS: ReadonlySet<string> = new Set(["CERT", "REPDTE"]);
|
|
1669
|
+
// sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams →
|
|
1670
|
+
// an unknown sort field is invalid_input BEFORE fetch).
|
|
1671
|
+
const RATIO_SORT_FIELDS: ReadonlySet<string> = new Set(["REPDTE", "ROA", "ROE", "RBCRWAJ", "EEFFR"]);
|
|
1672
|
+
|
|
1673
|
+
export { RATIO_FILTER_FIELDS };
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Build the risk-ratio `filters` string — `cert`→`CERT:<int>` (REQUIRED, numeric →
|
|
1677
|
+
* bare) + optional `reportDate`→`REPDTE:<int>` (numeric → bare, a YYYYMMDD quarter-end).
|
|
1678
|
+
* Both fields are on RATIO_FILTER_FIELDS (NOT FIN_FILTER_FIELDS, which is {CERT} and
|
|
1679
|
+
* would throw on REPDTE — S1). Terms joined with ` AND `. Exported for the fault fixtures.
|
|
1680
|
+
*/
|
|
1681
|
+
export function buildRatioFilters(inp: { cert: number; reportDate?: number }): string {
|
|
1682
|
+
const terms: string[] = [];
|
|
1683
|
+
terms.push(filterTerm("CERT", String(inp.cert), RATIO_FILTER_FIELDS));
|
|
1684
|
+
if (inp.reportDate !== undefined)
|
|
1685
|
+
terms.push(filterTerm("REPDTE", String(inp.reportDate), RATIO_FILTER_FIELDS));
|
|
1686
|
+
return terms.join(" AND ");
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* ★M1 — detect the Community Bank Leverage Ratio framework via FDIC's OWN `CBLRIND`
|
|
1691
|
+
* flag (CBLRIND===1). A CBLR filer does NOT report risk-based capital ratios: FDIC
|
|
1692
|
+
* returns RBCRWAJ as a LITERAL 0 sentinel (not null) and RBC1RWAJ as null. Detection is
|
|
1693
|
+
* strictly the flag — NO derived/recomputed ratio, NO blanket 0→null. Exported for the
|
|
1694
|
+
* fault fixture. (num maps a string/number/null CBLRIND consistently; an absent CBLRIND
|
|
1695
|
+
* → null → not CBLR → the ratios pass through verbatim.)
|
|
1696
|
+
*/
|
|
1697
|
+
export function isCblrFramework(rec: Record<string, unknown>): boolean {
|
|
1698
|
+
return num(rec.CBLRIND) === 1;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
export type FdicRiskRatios = {
|
|
1702
|
+
cert: number | null;
|
|
1703
|
+
reportDate: number | null;
|
|
1704
|
+
cblrFramework: boolean;
|
|
1705
|
+
returnOnAssetsPct: number | null;
|
|
1706
|
+
preTaxReturnOnAssetsPct: number | null;
|
|
1707
|
+
returnOnEquityPct: number | null;
|
|
1708
|
+
netInterestMarginPct: number | null;
|
|
1709
|
+
efficiencyRatioPct: number | null;
|
|
1710
|
+
netChargeOffsToLoansPct: number | null;
|
|
1711
|
+
leverageRatioPct: number | null;
|
|
1712
|
+
tier1RiskBasedCapitalRatioPct: number | null;
|
|
1713
|
+
totalRiskBasedCapitalRatioPct: number | null;
|
|
1714
|
+
tier1CapitalUSD: number | null;
|
|
1715
|
+
id: string | null;
|
|
1716
|
+
};
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* ★P3 + ★M1 — map ONE `/financials` record to the ratio row. The catalog drives the
|
|
1720
|
+
* per-field route: a PERCENT code → `num` VERBATIM (no scale, null-never-0); the
|
|
1721
|
+
* $-amount RBCT1J → `thousandsToUsd` (×1000, null-guard BEFORE the multiply). ★M1: when
|
|
1722
|
+
* the bank files under CBLR (CBLRIND===1) the two `zero-sentinel-when-CBLR` codes
|
|
1723
|
+
* (RBCRWAJ / RBC1RWAJ) map to NULL (never the 0 sentinel / never a false 0% capital);
|
|
1724
|
+
* a per-row `cblrFramework` explains the null. Genuine zeros on the other codes
|
|
1725
|
+
* (NTLNLSR:0 …) are surfaced verbatim by `num` — this is NOT a blanket 0→null.
|
|
1726
|
+
*/
|
|
1727
|
+
function mapRiskRatios(rec: Record<string, unknown>): FdicRiskRatios {
|
|
1728
|
+
const cblr = isCblrFramework(rec);
|
|
1729
|
+
const r: Record<string, number | null> = {};
|
|
1730
|
+
for (const entry of RATIO_CATALOG) {
|
|
1731
|
+
// ★M1 — CBLR-scoped sentinel → null (BOTH risk-based capital ratios), never 0.
|
|
1732
|
+
if (cblr && entry.notReported === "zero-sentinel-when-CBLR") {
|
|
1733
|
+
r[entry.outputKey] = null;
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
// ★P3 — $-amount ×1000 (thousandsToUsd); percent verbatim (num, no scale). Both
|
|
1737
|
+
// are null-never-0 (num / thousandsToUsd map null/undefined/""/"null" → null).
|
|
1738
|
+
r[entry.outputKey] =
|
|
1739
|
+
entry.unit === "usd-thousands" ? thousandsToUsd(rec[entry.code]) : num(rec[entry.code]);
|
|
1740
|
+
}
|
|
1741
|
+
// Each key is guaranteed populated by the catalog loop above; `?? null` only
|
|
1742
|
+
// satisfies noUncheckedIndexedAccess (an unexpected catalog-key drift → null,
|
|
1743
|
+
// never undefined — still null-never-0).
|
|
1744
|
+
return {
|
|
1745
|
+
cert: num(rec.CERT),
|
|
1746
|
+
reportDate: num(rec.REPDTE),
|
|
1747
|
+
cblrFramework: cblr,
|
|
1748
|
+
returnOnAssetsPct: r.returnOnAssetsPct ?? null,
|
|
1749
|
+
preTaxReturnOnAssetsPct: r.preTaxReturnOnAssetsPct ?? null,
|
|
1750
|
+
returnOnEquityPct: r.returnOnEquityPct ?? null,
|
|
1751
|
+
netInterestMarginPct: r.netInterestMarginPct ?? null,
|
|
1752
|
+
efficiencyRatioPct: r.efficiencyRatioPct ?? null,
|
|
1753
|
+
netChargeOffsToLoansPct: r.netChargeOffsToLoansPct ?? null,
|
|
1754
|
+
leverageRatioPct: r.leverageRatioPct ?? null,
|
|
1755
|
+
tier1RiskBasedCapitalRatioPct: r.tier1RiskBasedCapitalRatioPct ?? null,
|
|
1756
|
+
totalRiskBasedCapitalRatioPct: r.totalRiskBasedCapitalRatioPct ?? null,
|
|
1757
|
+
tier1CapitalUSD: r.tier1CapitalUSD ?? null,
|
|
1758
|
+
id: str(rec.ID),
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// ─── risk-ratio disclosure notes ───────────────────────────────────
|
|
1763
|
+
// ★P3 + ★M1 — units in the key + the corrected CBLR honesty (does NOT promise every
|
|
1764
|
+
// shown 0 is real; a null capital ratio on a CBLR bank is a normal framework artifact).
|
|
1765
|
+
const RATIO_UNITS_NOTE =
|
|
1766
|
+
"Each *Pct field is an FDIC-published PERCENTAGE surfaced verbatim (ROA/ROE/margin/efficiency/capital ratios) — do NOT read it as a dollar amount and do NOT ×1000-scale it. tier1CapitalUSD is a DOLLAR amount (FDIC publishes it in $thousands; normalized here ×1,000). A null ratio means FDIC did not report that ratio for this bank/period — it is NOT 0% (never read a null ratio as 'no return / no capital'). Banks reporting under the Community Bank Leverage Ratio (CBLR) framework (cblrFramework:true) do NOT report the risk-based capital ratios: FDIC returns a literal 0 for the total risk-based ratio, which this tool maps to null for BOTH tier1RiskBasedCapitalRatioPct and totalRiskBasedCapitalRatioPct — a null risk-based capital ratio here is frequently a NORMAL framework artifact (read it alongside the populated leverageRatioPct), not a red flag or a real 0% capital reading. Ratios are surfaced exactly as FDIC computes them; none is recomputed.";
|
|
1767
|
+
const RATIO_NOT_DETERMINATION_NOTE =
|
|
1768
|
+
"FDIC risk ratios are reported regulatory metrics from the bank's Call Report, NOT a soundness rating, safety-and-soundness examination result, or failure prediction. A single-period ratio is a snapshot; read the time-series and cross-check the institution's condition (fdic_search_institutions for status, fdic_bank_failures for resolution history, fdic_institution_history for structural changes). FDIC keys on CERT, not SAM UEI/DUNS.";
|
|
1769
|
+
const RATIO_EMPTY_NOTE =
|
|
1770
|
+
"No financial report is on record for this CERT/period — this does NOT mean the bank is unsound or unrated; the CERT may be wrong, or the bank may not have filed for this period. Confirm the CERT via fdic_search_institutions.";
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* FDIC counterparty RISK RATIOS for ONE institution by `cert` (`/banks/financials` —
|
|
1774
|
+
* the ALREADY-wired endpoint; NO new endpoint constant). Structured inputs: `cert`
|
|
1775
|
+
* (REQUIRED → CERT), optional `reportDate` (→ REPDTE, a YYYYMMDD quarter-end), plus
|
|
1776
|
+
* `limit`/`offset`/`sortBy` (allowlist {REPDTE,ROA,ROE,RBCRWAJ,EEFFR}, default REPDTE)/
|
|
1777
|
+
* `sortOrder` (default DESC → newest quarter first). Consumes the IDENTICAL fetch →
|
|
1778
|
+
* 3-envelope guard → pagination machinery as tools 1–5.
|
|
1779
|
+
*
|
|
1780
|
+
* HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1); the 3-envelope
|
|
1781
|
+
* drift-guard makes the ONLY honest empty `200 + total:0 + data:[]`, everything else
|
|
1782
|
+
* THROWS (P2); ★P3 percent ratios surfaced VERBATIM via num (null-never-0, NO scale, NO
|
|
1783
|
+
* recompute), the ONE $-amount (RBCT1J) via thousandsToUsd → tier1CapitalUSD; ★M1 the
|
|
1784
|
+
* CBLR risk-based capital ratios map to null (never the 0 sentinel) with a per-row
|
|
1785
|
+
* cblrFramework flag; a projected field absent from all records → fieldsUnavailable (B);
|
|
1786
|
+
* the snapshot build time is disclosed.
|
|
1787
|
+
*/
|
|
1788
|
+
export async function riskRatios(args: {
|
|
1789
|
+
cert: number;
|
|
1790
|
+
reportDate?: number;
|
|
1791
|
+
limit?: number;
|
|
1792
|
+
offset?: number;
|
|
1793
|
+
sortBy?: string;
|
|
1794
|
+
sortOrder?: string;
|
|
1795
|
+
}): Promise<MetaBundle> {
|
|
1796
|
+
const limit = args.limit ?? 100;
|
|
1797
|
+
const offset = args.offset ?? 0;
|
|
1798
|
+
// Default REPDTE DESC (newest quarter first) when the server's Zod default did not
|
|
1799
|
+
// supply one (defensive — the server always defaults sortBy=REPDTE/DESC).
|
|
1800
|
+
const sort = sortParams(
|
|
1801
|
+
args.sortBy ?? "REPDTE",
|
|
1802
|
+
args.sortOrder ?? "DESC",
|
|
1803
|
+
RATIO_SORT_FIELDS,
|
|
1804
|
+
"financials",
|
|
1805
|
+
);
|
|
1806
|
+
|
|
1807
|
+
const params = new URLSearchParams();
|
|
1808
|
+
params.set("filters", buildRatioFilters({ cert: args.cert, reportDate: args.reportDate }));
|
|
1809
|
+
params.set("fields", RATIO_FIELDS);
|
|
1810
|
+
params.set("limit", String(limit));
|
|
1811
|
+
params.set("offset", String(offset));
|
|
1812
|
+
if (sort.sort_by) {
|
|
1813
|
+
params.set("sort_by", sort.sort_by);
|
|
1814
|
+
params.set("sort_order", sort.sort_order as string);
|
|
1815
|
+
}
|
|
1816
|
+
params.set("format", "json");
|
|
1817
|
+
|
|
1818
|
+
const body = await getFdic(ENDPOINT_FINANCIALS, params);
|
|
1819
|
+
const env = parseEnvelope(body);
|
|
1820
|
+
const records = env.records.map(mapRiskRatios);
|
|
1821
|
+
const returned = records.length;
|
|
1822
|
+
const totalAvailable = env.totalAvailable;
|
|
1823
|
+
const hasMore = offset + returned < totalAvailable;
|
|
1824
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
1825
|
+
|
|
1826
|
+
const filtersApplied: string[] = ["cert"];
|
|
1827
|
+
if (args.reportDate !== undefined) filtersApplied.push("reportDate");
|
|
1828
|
+
if (sort.sort_by) filtersApplied.push("sort");
|
|
1829
|
+
|
|
1830
|
+
const notes: string[] = [
|
|
1831
|
+
freshnessNote(env.indexName, env.indexCreated),
|
|
1832
|
+
RATIO_UNITS_NOTE,
|
|
1833
|
+
RATIO_NOT_DETERMINATION_NOTE,
|
|
1834
|
+
];
|
|
1835
|
+
if (totalAvailable === 0) notes.push(RATIO_EMPTY_NOTE);
|
|
1836
|
+
const fu = fieldsUnavailable(env.records, RATIO_PROJECTION);
|
|
1837
|
+
if (fu.length > 0) {
|
|
1838
|
+
notes.push(
|
|
1839
|
+
`Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record in this result set — the affected values are surfaced as null (never fabricated); this can be a ratio FDIC does not publish for these institution(s)/period(s), or a schema change.`,
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
return withMeta(
|
|
1844
|
+
{ cert: args.cert, ratios: records },
|
|
1845
|
+
{
|
|
1846
|
+
source: "api.fdic.gov/banks/financials (BankFind, keyless)",
|
|
1847
|
+
keylessMode: true,
|
|
1848
|
+
returned,
|
|
1849
|
+
totalAvailable,
|
|
1850
|
+
filtersApplied,
|
|
1851
|
+
filtersDropped: [],
|
|
1852
|
+
fieldsUnavailable: fu,
|
|
1853
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
1854
|
+
notes,
|
|
1855
|
+
} satisfies Partial<ResponseMeta>,
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1860
|
+
// Tool 7: fdic_branch_deposits (ADR-0040) — the FDIC branch-deposit footprint
|
|
1861
|
+
// (`/banks/sod`, Summary of Deposits): the annual June-30 branch-office deposit
|
|
1862
|
+
// distribution ("where does this bank hold deposits, and how concentrated?"). ONE new
|
|
1863
|
+
// fixed endpoint constant `ENDPOINT_SOD = "sod"` on getFdic's `/banks/${endpoint}`
|
|
1864
|
+
// template (NO caller value on the path). Reuses the C116/C118-hardened adapter VERBATIM
|
|
1865
|
+
// (getFdic / parseEnvelope 3-envelope guard / filterTerm allowlist + Set.has + the C118
|
|
1866
|
+
// non-numeric quote / sortParams enum+Set.has / EXACT meta.total pagination / freshness /
|
|
1867
|
+
// thousandsToUsd / num / str), live-verified 2026-07-13:
|
|
1868
|
+
// ★S1 — the SOD filter-FIELD allowlist is {CERT, STALPBR, YEAR} (its OWN Set — NOT
|
|
1869
|
+
// FIN_FILTER_FIELDS). LOAD-BEARING: a bad /sod filter field silently returns
|
|
1870
|
+
// total:0 (a FALSE-empty, NOT a 400), so an un-allowlisted/mistyped/injected field
|
|
1871
|
+
// must be rejected BY CONSTRUCTION before the wire.
|
|
1872
|
+
// ★C118 — the state field STALPBR is non-numeric → filterTerm DOUBLE-QUOTES it
|
|
1873
|
+
// (Oregon `STALPBR:"OR"` is Lucene-operator-safe; bare `STALPBR:OR` → live HTTP 400).
|
|
1874
|
+
// ★P3 — DEPSUMBR (branch deposits, $thousands) → thousandsToUsd → depositsUSD
|
|
1875
|
+
// (null-guard BEFORE the ×1000); a GENUINE 0 stays 0, an absent value → null. YEAR
|
|
1876
|
+
// is a JSON integer via num; names/city/address/zip via str.
|
|
1877
|
+
// ★freshness — the /sod index is a DISTINCT ANNUAL snapshot (sod_*), far less fresh
|
|
1878
|
+
// than the quarterly /financials index — disclosed via freshnessNote + a snapshot note.
|
|
1879
|
+
// No PII — bank-BRANCH facility data (branch name/address/city/state/zip/deposits),
|
|
1880
|
+
// public commercial-bank infrastructure; no officer/personal-contact fields.
|
|
1881
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1882
|
+
|
|
1883
|
+
// Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
|
|
1884
|
+
const ENDPOINT_SOD = "sod";
|
|
1885
|
+
|
|
1886
|
+
// Fixed field projection on the wire (every field live-verified valid).
|
|
1887
|
+
const SOD_FIELDS = "CERT,NAMEFULL,BRNUM,NAMEBR,CITYBR,STALPBR,ZIPBR,ADDRESBR,DEPSUMBR,YEAR,ID";
|
|
1888
|
+
const SOD_PROJECTION = [
|
|
1889
|
+
"CERT",
|
|
1890
|
+
"NAMEFULL",
|
|
1891
|
+
"BRNUM",
|
|
1892
|
+
"NAMEBR",
|
|
1893
|
+
"CITYBR",
|
|
1894
|
+
"STALPBR",
|
|
1895
|
+
"ZIPBR",
|
|
1896
|
+
"ADDRESBR",
|
|
1897
|
+
"DEPSUMBR",
|
|
1898
|
+
"YEAR",
|
|
1899
|
+
"ID",
|
|
1900
|
+
] as const;
|
|
1901
|
+
|
|
1902
|
+
// ★S1 — the SOD filter-FIELD allowlist (P4 belt-and-suspenders; LOAD-BEARING — a bad
|
|
1903
|
+
// /sod field is a silent total:0 false-empty, NOT a 400). CERT/YEAR numeric (bare),
|
|
1904
|
+
// STALPBR non-numeric (C118-quoted). Do NOT reuse FIN_FILTER_FIELDS ({CERT} — would
|
|
1905
|
+
// throw on STALPBR/YEAR). Exported for the fault fixture.
|
|
1906
|
+
const SOD_FILTER_FIELDS: ReadonlySet<string> = new Set(["CERT", "STALPBR", "YEAR"]);
|
|
1907
|
+
// sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams →
|
|
1908
|
+
// an unknown sort field is invalid_input BEFORE fetch; live `sort_by=NOTAFIELD` → 400).
|
|
1909
|
+
const SOD_SORT_FIELDS: ReadonlySet<string> = new Set(["YEAR", "DEPSUMBR"]);
|
|
1910
|
+
|
|
1911
|
+
export { SOD_FILTER_FIELDS };
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* Build the /sod `filters` string — `cert`→`CERT:<int>` (numeric → bare), `state`→
|
|
1915
|
+
* `STALPBR:"<code>"` (★C118 non-numeric → DOUBLE-QUOTED; Oregon `STALPBR:"OR"` is
|
|
1916
|
+
* operator-safe), `year`→`YEAR:<int>` (numeric → bare). All fields on SOD_FILTER_FIELDS
|
|
1917
|
+
* (★S1). Terms joined with ` AND `. Returns "" when there is no structured filter clause.
|
|
1918
|
+
* Exported for the fault fixtures.
|
|
1919
|
+
*/
|
|
1920
|
+
export function buildSodFilters(inp: { cert?: number; state?: string; year?: number }): string {
|
|
1921
|
+
const terms: string[] = [];
|
|
1922
|
+
if (inp.cert !== undefined) terms.push(filterTerm("CERT", String(inp.cert), SOD_FILTER_FIELDS));
|
|
1923
|
+
if (inp.state !== undefined) terms.push(filterTerm("STALPBR", inp.state, SOD_FILTER_FIELDS));
|
|
1924
|
+
if (inp.year !== undefined) terms.push(filterTerm("YEAR", String(inp.year), SOD_FILTER_FIELDS));
|
|
1925
|
+
return terms.join(" AND ");
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
export type FdicBranchDeposit = {
|
|
1929
|
+
cert: number | null;
|
|
1930
|
+
institutionName: string | null;
|
|
1931
|
+
branchNumber: number | null;
|
|
1932
|
+
branchName: string | null;
|
|
1933
|
+
city: string | null;
|
|
1934
|
+
state: string | null;
|
|
1935
|
+
zip: string | null;
|
|
1936
|
+
address: string | null;
|
|
1937
|
+
depositsUSD: number | null;
|
|
1938
|
+
year: number | null;
|
|
1939
|
+
id: string | null;
|
|
1940
|
+
};
|
|
1941
|
+
|
|
1942
|
+
function mapBranchDeposit(rec: Record<string, unknown>): FdicBranchDeposit {
|
|
1943
|
+
return {
|
|
1944
|
+
cert: num(rec.CERT),
|
|
1945
|
+
institutionName: str(rec.NAMEFULL),
|
|
1946
|
+
branchNumber: num(rec.BRNUM),
|
|
1947
|
+
branchName: str(rec.NAMEBR),
|
|
1948
|
+
city: str(rec.CITYBR),
|
|
1949
|
+
state: str(rec.STALPBR),
|
|
1950
|
+
zip: str(rec.ZIPBR),
|
|
1951
|
+
address: str(rec.ADDRESBR),
|
|
1952
|
+
// ★P3 — DEPSUMBR is $thousands → whole USD ×1000 (null-guard BEFORE the multiply);
|
|
1953
|
+
// a GENUINE 0 stays 0, an absent value → null (never 0).
|
|
1954
|
+
depositsUSD: thousandsToUsd(rec.DEPSUMBR),
|
|
1955
|
+
year: num(rec.YEAR),
|
|
1956
|
+
id: str(rec.ID),
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// ─── /sod disclosure notes ─────────────────────────────────────────
|
|
1961
|
+
const SOD_UNITS_NOTE =
|
|
1962
|
+
"depositsUSD is FDIC's DEPSUMBR (branch-office deposits), published in $thousands and normalized here to whole USD (×1,000). A real 0 stays 0; an absent value is null (never 0).";
|
|
1963
|
+
const SOD_SNAPSHOT_NOTE =
|
|
1964
|
+
"Summary of Deposits is an ANNUAL June-30 branch-office snapshot (a DISTINCT index, far less fresh than the quarterly /financials data). Branch name/city/address are shown per row but the endpoint filters only by cert/state/year — resolve a bank's CERT via fdic_search_institutions.";
|
|
1965
|
+
const SOD_EMPTY_NOTE =
|
|
1966
|
+
"No Summary-of-Deposits branch records match — the bank may report no branches for this year, or the CERT/state/year filter may not match; SOD is an annual June-30 snapshot. Confirm the CERT via fdic_search_institutions.";
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* FDIC branch-deposit footprint (`/banks/sod`, Summary of Deposits). Structured inputs
|
|
1970
|
+
* (all optional, AND-combined; ≥1 recommended): `cert` (→ CERT), `state` (→ STALPBR,
|
|
1971
|
+
* ★C118-quoted), `year` (→ YEAR), plus `limit`/`offset`/`sortBy` (allowlist
|
|
1972
|
+
* {YEAR,DEPSUMBR}, default YEAR)/`sortOrder` (default DESC → newest snapshot first).
|
|
1973
|
+
* Consumes the IDENTICAL fetch → 3-envelope guard → pagination machinery as tools 1–6.
|
|
1974
|
+
*
|
|
1975
|
+
* HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live: CERT 10004 →
|
|
1976
|
+
* 74, STALPBR:"OR" → 31093); the 3-envelope drift-guard makes the ONLY honest empty
|
|
1977
|
+
* `200 + total:0 + data:[]`, everything else THROWS (P2); ★the /sod false-empty landmine
|
|
1978
|
+
* (a bad filter field → silent total:0) is neutralized by the S1 allowlist-by-
|
|
1979
|
+
* construction; DEPSUMBR is $thousands → whole USD ×1000 null-never-0 (P3; a genuine 0
|
|
1980
|
+
* stays 0, absent → null); a projected field absent from all records → fieldsUnavailable
|
|
1981
|
+
* (B); the DISTINCT annual snapshot build time is disclosed.
|
|
1982
|
+
*/
|
|
1983
|
+
export async function branchDeposits(args: {
|
|
1984
|
+
cert?: number;
|
|
1985
|
+
state?: string;
|
|
1986
|
+
year?: number;
|
|
1987
|
+
limit?: number;
|
|
1988
|
+
offset?: number;
|
|
1989
|
+
sortBy?: string;
|
|
1990
|
+
sortOrder?: string;
|
|
1991
|
+
}): Promise<MetaBundle> {
|
|
1992
|
+
const limit = args.limit ?? 100;
|
|
1993
|
+
const offset = args.offset ?? 0;
|
|
1994
|
+
// Default YEAR DESC (newest snapshot first) when the server's Zod default did not
|
|
1995
|
+
// supply one (defensive — the server always defaults sortBy=YEAR/DESC).
|
|
1996
|
+
const sort = sortParams(args.sortBy ?? "YEAR", args.sortOrder ?? "DESC", SOD_SORT_FIELDS, "sod");
|
|
1997
|
+
|
|
1998
|
+
const filters = buildSodFilters({ cert: args.cert, state: args.state, year: args.year });
|
|
1999
|
+
|
|
2000
|
+
const params = new URLSearchParams();
|
|
2001
|
+
if (filters) params.set("filters", filters);
|
|
2002
|
+
params.set("fields", SOD_FIELDS);
|
|
2003
|
+
params.set("limit", String(limit));
|
|
2004
|
+
params.set("offset", String(offset));
|
|
2005
|
+
if (sort.sort_by) {
|
|
2006
|
+
params.set("sort_by", sort.sort_by);
|
|
2007
|
+
params.set("sort_order", sort.sort_order as string);
|
|
2008
|
+
}
|
|
2009
|
+
params.set("format", "json");
|
|
2010
|
+
|
|
2011
|
+
const body = await getFdic(ENDPOINT_SOD, params);
|
|
2012
|
+
const env = parseEnvelope(body);
|
|
2013
|
+
const records = env.records.map(mapBranchDeposit);
|
|
2014
|
+
const returned = records.length;
|
|
2015
|
+
const totalAvailable = env.totalAvailable;
|
|
2016
|
+
const hasMore = offset + returned < totalAvailable;
|
|
2017
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
2018
|
+
|
|
2019
|
+
const filtersApplied: string[] = [];
|
|
2020
|
+
if (args.cert !== undefined) filtersApplied.push("cert");
|
|
2021
|
+
if (args.state !== undefined) filtersApplied.push("state");
|
|
2022
|
+
if (args.year !== undefined) filtersApplied.push("year");
|
|
2023
|
+
if (sort.sort_by) filtersApplied.push("sort");
|
|
2024
|
+
|
|
2025
|
+
const notes: string[] = [
|
|
2026
|
+
freshnessNote(env.indexName, env.indexCreated),
|
|
2027
|
+
SOD_UNITS_NOTE,
|
|
2028
|
+
SOD_SNAPSHOT_NOTE,
|
|
2029
|
+
];
|
|
2030
|
+
if (totalAvailable === 0) notes.push(SOD_EMPTY_NOTE);
|
|
2031
|
+
const fu = fieldsUnavailable(env.records, SOD_PROJECTION);
|
|
2032
|
+
if (fu.length > 0) {
|
|
2033
|
+
notes.push(
|
|
2034
|
+
`Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
return withMeta(
|
|
2039
|
+
{ branches: records },
|
|
2040
|
+
{
|
|
2041
|
+
source: "api.fdic.gov/banks/sod (BankFind Summary of Deposits, keyless)",
|
|
2042
|
+
keylessMode: true,
|
|
2043
|
+
returned,
|
|
2044
|
+
totalAvailable,
|
|
2045
|
+
filtersApplied,
|
|
2046
|
+
filtersDropped: [],
|
|
2047
|
+
fieldsUnavailable: fu,
|
|
2048
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
2049
|
+
notes,
|
|
2050
|
+
} satisfies Partial<ResponseMeta>,
|
|
2051
|
+
);
|
|
2052
|
+
}
|