diff --git a/docs/DEPLOY_WORKERS_MCP.md b/docs/DEPLOY_WORKERS_MCP.md index bf67d3d..d17bd48 100644 --- a/docs/DEPLOY_WORKERS_MCP.md +++ b/docs/DEPLOY_WORKERS_MCP.md @@ -14,7 +14,7 @@ As tools deste Worker chamam fontes públicas oficiais: APIs JSON (BCB, IBGE, IPEA, SICONFI, Open Finance Directory) e os ZIPs/CSV da CVM em `dados.cvm.gov.br` para fundos abertos (`cvm_fund`). -Fora deste Worker (CDA/carteira, lâmina, perfil, B3 COTAHIST, ANBIMA XLS, +Fora deste Worker (lâmina, perfil, B3 COTAHIST, ANBIMA XLS, registry FTS5, code mode): `pip install openfindata` ou FastAPI interno. `cvm_fund` no Worker: @@ -22,10 +22,12 @@ registry FTS5, code mode): `pip install openfindata` ou FastAPI interno. - `dataset=catalog` + `cnpj` ou `q` — cadastro oficial RCVM 175 (`registro_fundo_classe.zip`: fundo + classe + subclasse). `cad_fi.csv` não lista fundos já adaptados à Resolução 175. -- `dataset=daily` + `cnpj` — série de cotas INF_DIARIO do mês - (`year`/`month`; default = mês UTC corrente). -- Não inclui CDA (composição da carteira): feed mensal atrasado e pesado, - separado do informe diário. Não usa Mais Retorno. +- `dataset=daily` + `cnpj` — série de cotas INF_DIARIO. Sem `year`/`month` + usa o mês mais recente no diretório CVM; `months` (1–3) olha para trás. +- `dataset=periods` — stamps YYYYMM publicados (`product=CDA` ou `INF_DIARIO`). +- `dataset=holdings` + `cnpj` — CDA (carteira). Sem `year`/`month` usa o + CDA mais recente. Scan em stream por CNPJ; linhas `CONFID` são sigilo, + não carteira aberta completa. Não usa Mais Retorno. ## Deploy @@ -57,9 +59,9 @@ curl -sS https://openfindata.com.br/health ``` Upstream calls no Worker têm timeout (15s) e teto de payload (2 MB; -8 MB no Directory Open Finance; 16 MB / 45s só em `cvm_fund`, porque o -cadastro e o INF_DIARIO mensal vêm em ZIP). O Worker faz scan em stream -do CSV deflate (não materializa os ~48 MB do INF_DIARIO). Séries BCB sem +8 MB no Directory Open Finance; 32 MB / 45s só em `cvm_fund`, porque +cadastro, INF_DIARIO e CDA vêm em ZIP). O Worker faz scan em stream do +CSV deflate (não materializa o CDA descompactado). Séries BCB sem intervalo caem em `last_n≤200`. Rate limits de `/mcp` não mudam. `/mcp` usa Workers Rate Limit bindings (não Cloudflare Queues): 60 req / diff --git a/docs/MCP_SURFACE.md b/docs/MCP_SURFACE.md index afa010a..785a421 100644 --- a/docs/MCP_SURFACE.md +++ b/docs/MCP_SURFACE.md @@ -6,8 +6,8 @@ > and [`docs/DEPLOY_WORKERS_MCP.md`](DEPLOY_WORKERS_MCP.md). Public `/mcp` is > 60 req/60s per IP with a 20/10s burst; overflow is 429 + Retry-After (no queue, > no code mode, no API key). Worker tools: the 9 JSON macro sources plus -> `cvm_fund` (RCVM 175 cadastro + INF_DIARIO). CDA/lâmina/perfil stay on -> the internal FastAPI catalog below. +> `cvm_fund` (RCVM 175 cadastro + INF_DIARIO + CDA holdings/periods). +> Lâmina/perfil stay on the internal FastAPI catalog below. ## Problem @@ -86,7 +86,7 @@ findata_run_code (code mode, opt-in) | `bcb_ptax` | `/ptax/usd`, `/ptax/usd/period`, `/ptax/{currency}` | `start`+`end` → period | | `bcb_focus` | `/focus/{indicators,annual,monthly,selic,top5}` | `horizon`, `panel`, `indicator` | | `cvm_company` | companies search/list, `fca/*`, `ipe` | `dataset=search\|list\|fca_*\|filings` | -| `cvm_fund` | `funds`, `funds/cadastro`, `funds/{daily,holdings,lamina,profile,periods}`, returns | `dataset`; `cnpj`/`q` → RCVM 175 | +| `cvm_fund` | `funds`, `funds/cadastro`, `funds/{daily,holdings,lamina,profile,periods}`, returns | `dataset`; `cnpj`/`q` → RCVM 175; omit year/month → latest CDA/INF_DIARIO | | `cvm_structured_fund` | `funds/{fii,fidc,fip}/*` | `kind` + `dataset` | | `b3_index` | index portfolio + monthly + list | `dataset`, omit `symbol` to list | | `tesouro_bonds` | bonds list/search/history | `dataset` | @@ -101,7 +101,7 @@ findata_run_code (code mode, opt-in) deliverable, not an afterthought. - **Consolidation can hide endpoint-specific params behind an enum.** Mitigated by documenting each `dataset`/`kind` value and validating bad combinations with - a `400` (e.g. `cvm_fund dataset=holdings` requires `cnpj`+`month`), matching the + a `400` (e.g. `cvm_fund dataset=holdings` requires `cnpj`; month defaults to latest), matching the REST API's `ValueError → 400` behaviour. - **Discoverability of rare endpoints.** A handful of niche REST routes are not individually surfaced as tools. They remain fully reachable over REST and via @@ -122,4 +122,6 @@ local/agent use. A production deployment should run it in a real sandbox - `bcb_ptax(start=2024-01-02, end=2024-01-05)` → daily PTAX USD series (the handoff's headline flow). - `cvm_fund(dataset=catalog, cnpj="38.729.027/0001-92")` → cadastro RCVM 175 (classe, condomínio, PL). - `cvm_fund(dataset=daily, cnpj="38729027000192", year=2026, month=8)` → INF_DIARIO (cota/PL/cotistas). +- `cvm_fund(dataset=periods, product="CDA")` → YYYYMM stamps + `latest`. +- `cvm_fund(dataset=holdings, cnpj="38729027000192")` → latest CDA carteira (CONFID = sigilo). - `findata_run_code("import findata; ...")` → runs in the sandbox, returns captured stdout. diff --git a/docs/agents/orientation.md b/docs/agents/orientation.md index af0935f..eaf62b0 100644 --- a/docs/agents/orientation.md +++ b/docs/agents/orientation.md @@ -24,7 +24,7 @@ mortos, fronteira atual, openfindata, findata. | Rede nos unit tests? | Proibido. `respx` nos unitários; live só `@pytest.mark.integration` | `AGENTS.md`, CI nightly | | Credenciais no repo? | Nunca. Fontes públicas preferidas; BdD usa billing project do operador via env | `AGENTS.md`, `docs/SOURCES_WITH_AUTH.md` | | MCP: 1:1 com REST ou curado? | Catálogo curado em `mcp_app` (~25 tools); REST intacto | `docs/MCP_SURFACE.md` | -| MCP público vs interno? | Worker `openfindata.com.br/mcp` (macro JSON + `cvm_fund`); FastAPI/Tailscale tem o catálogo completo | `docs/DEPLOY_WORKERS_MCP.md` | +| MCP público vs interno? | Worker `openfindata.com.br/mcp` (macro JSON + `cvm_fund` catalog/daily/holdings/periods); FastAPI/Tailscale tem o catálogo completo | `docs/DEPLOY_WORKERS_MCP.md` | | Code mode no MCP? | Opt-in via `FINDATA_MCP_CODE_MODE=1`; off por default | `docs/MCP_SURFACE.md`, `mcp_app.py` | | Charts: quais deps de plot? | Não adicionar matplotlib/pandas/plotly etc. só para gráfico | `AGENTS.md`, `docs/CHART_STANDARDS.md` | | Publicar no PyPI? | Só com aprovação humana explícita | `AGENTS.md` | diff --git a/src/findata/api/mcp_app.py b/src/findata/api/mcp_app.py index e42fe08..afbd66a 100644 --- a/src/findata/api/mcp_app.py +++ b/src/findata/api/mcp_app.py @@ -59,6 +59,7 @@ holdings, ipe, lamina, + latest_period, list_periods, profile, ) @@ -73,6 +74,36 @@ _MIN_YEAR_B3_COTAHIST = 1986 # B3 publishes COTAHIST since 1986 _RGF_MAX_PERIOD = 3 # RGF quadrimestre runs 1..3 +_DAILY_MONTHS_MAX = 12 +_YYYYMM_LEN = 6 + + +def _stamp_to_year_month(stamp: str) -> tuple[int, int]: + if len(stamp) != _YYYYMM_LEN or not stamp.isdigit(): + raise HTTPException(404, f"invalid CVM period stamp {stamp!r}") + return int(stamp[:4]), int(stamp[_YYYYMM_LEN - 2 :]) + + +def _add_months(year: int, month: int, delta: int) -> tuple[int, int]: + absolute = year * 12 + (month - 1) + delta + return absolute // 12, absolute % 12 + 1 + + +def _lookback_months(end_year: int, end_month: int, count: int) -> list[tuple[int, int]]: + return [_add_months(end_year, end_month, offset) for offset in range(-(count - 1), 1)] + + +async def _resolve_cvm_month(year: int | None, month: int | None, product: str) -> tuple[int, int]: + if (year is None) != (month is None): + raise HTTPException( + 400, "pass both `year` and `month`, or omit both for the latest published file" + ) + if year is not None and month is not None: + return year, month + latest = await latest_period("FI", f"DOC/{product}") + if not latest: + raise HTTPException(404, f"no published {product} period") + return _stamp_to_year_month(latest) # ── Registry: the entry point ───────────────────────────────────── @@ -316,8 +347,13 @@ async def cvm_fund( q: str | None = Query( None, min_length=2, description="catalog: name fragment when CNPJ is unknown" ), - year: int | None = Query(None, description="Reference year (required except catalog/periods)"), + year: int | None = Query( + None, description="Reference year; omit with month for latest CDA/INF_DIARIO" + ), month: int | None = Query(None, ge=1, le=12, description="Reference month (monthly datasets)"), + months: int = Query( + 1, ge=1, le=_DAILY_MONTHS_MAX, description="daily: lookback months including the end month" + ), horizon: Literal["monthly", "yearly"] = Query( "monthly", description="returns granularity (dataset=returns)" ), @@ -339,8 +375,10 @@ async def cvm_fund( """Open funds in one tool. ``catalog`` with ``cnpj`` or ``q`` reads the official RCVM 175 registro (fundo+classe+subclasse). Bare ``catalog`` still pages the legacy ``cad_fi.csv`` (non-adapted funds only). ``periods`` lists YYYYMM - stamps. ``daily`` is INF_DIARIO (cota/PL/cotistas). CDA ``holdings`` is a - separate monthly delayed feed and is not the cota series. + stamps. ``daily`` is INF_DIARIO (cota/PL/cotistas); omit ``year``/``month`` + for the latest published month, or pass ``months`` to look back. CDA + ``holdings`` is a separate monthly delayed feed — omit ``year``/``month`` + for the latest CDA. CONFID rows are sigilo, not a complete open book. """ if dataset == "catalog": if cnpj or q: @@ -348,17 +386,26 @@ async def cvm_fund( return (await funds.get_fund_catalog(True, None))[:limit] if dataset == "periods": return await list_periods("FI", f"DOC/{product}") - if year is None: - raise HTTPException(400, f"dataset={dataset} requires `year`") if dataset == "holdings": - if not cnpj or month is None: - raise HTTPException(400, "dataset=holdings requires `cnpj` and `month`") + if not cnpj: + raise HTTPException(400, "dataset=holdings requires `cnpj`") + year, month = await _resolve_cvm_month(year, month, "CDA") block_list = [b.strip() for b in blocks.split(",") if b.strip()] if blocks else None return await holdings.get_fund_holdings(cnpj, year, month, block_list) + if dataset == "daily": + year, month = await _resolve_cvm_month(year, month, "INF_DIARIO") + if months == 1: + return (await funds.get_fund_daily(year, month, cnpj))[:limit] + series: list[Any] = [] + for stamp_year, stamp_month in _lookback_months(year, month, months): + series.extend(await funds.get_fund_daily(stamp_year, stamp_month, cnpj)) + if len(series) >= limit: + break + return series[:limit] + if year is None: + raise HTTPException(400, f"dataset={dataset} requires `year`") if month is None: raise HTTPException(400, f"dataset={dataset} requires `month`") - if dataset == "daily": - return (await funds.get_fund_daily(year, month, cnpj))[:limit] if dataset == "lamina": return (await lamina.get_fund_lamina(year, month, cnpj))[:limit] if dataset == "profile": diff --git a/tests/test_mcp_surface.py b/tests/test_mcp_surface.py index a2de6e5..8f2c21f 100644 --- a/tests/test_mcp_surface.py +++ b/tests/test_mcp_surface.py @@ -12,12 +12,19 @@ from __future__ import annotations import importlib +import re +import httpx import pytest +import respx from fastapi.testclient import TestClient from findata.api.app import app from findata.api.mcp_app import mcp_app +from findata.http_client import clear_cache +from findata.sources.cvm._directory import _listing_cache +from tests.test_cvm_fund_cadastro import _daily_zip +from tests.test_cvm_funds import _LISTING_HTML, _make_cda_zip EXPECTED_TOOLS = 25 # curated tools with code mode OFF (the default) EXPECTED_REST_OPERATIONS = 97 # all REST routes (unconditional); bump when the surface changes @@ -103,12 +110,21 @@ def test_consolidated_tool_validates_missing_selector_args() -> None: assert "year" in r.json()["detail"] -def test_cvm_fund_holdings_requires_cnpj_and_month() -> None: - r = TestClient(mcp_app).get("/cvm/fund", params={"dataset": "holdings", "year": 2024}) +def test_cvm_fund_holdings_requires_cnpj() -> None: + r = TestClient(mcp_app).get("/cvm/fund", params={"dataset": "holdings"}) assert r.status_code == 400 assert "cnpj" in r.json()["detail"] +def test_cvm_fund_holdings_year_without_month_is_400() -> None: + r = TestClient(mcp_app).get( + "/cvm/fund", + params={"dataset": "holdings", "cnpj": "12.345.678/0001-99", "year": 2024}, + ) + assert r.status_code == 400 + assert "year" in r.json()["detail"] or "month" in r.json()["detail"] + + # ── code-mode gating ─────────────────────────────────────────────── @@ -154,3 +170,46 @@ def test_structured_fund_fip_rejects_dataset() -> None: "/cvm/structured-fund", params={"kind": "fip", "year": 2024, "dataset": "geral"} ) assert r.status_code == 400 + + +@respx.mock +def test_cvm_fund_holdings_defaults_to_latest_cda() -> None: + clear_cache() + _listing_cache.invalidate() + respx.get("https://dados.cvm.gov.br/dados/FI/DOC/CDA/DADOS/").mock( + return_value=httpx.Response(200, text=_LISTING_HTML) + ) + respx.get(re.compile(r"https://.*cda_fi_202603\.zip")).mock( + return_value=httpx.Response(200, content=_make_cda_zip()) + ) + r = TestClient(mcp_app).get( + "/cvm/fund", params={"dataset": "holdings", "cnpj": "12.345.678/0001-99"} + ) + assert r.status_code == 200 + body = r.json() + assert len(body) == 3 + assert {row["bloco"] for row in body} == {"BLC_4", "BLC_8"} + + +@respx.mock +def test_cvm_fund_daily_months_lookback() -> None: + clear_cache() + payload = _daily_zip() + respx.get(re.compile(r"https://.*inf_diario_fi_202607\.zip")).mock( + return_value=httpx.Response(200, content=payload) + ) + respx.get(re.compile(r"https://.*inf_diario_fi_202608\.zip")).mock( + return_value=httpx.Response(200, content=payload) + ) + r = TestClient(mcp_app).get( + "/cvm/fund", + params={ + "dataset": "daily", + "cnpj": "38729027000192", + "year": 2026, + "month": 8, + "months": 2, + }, + ) + assert r.status_code == 200 + assert len(r.json()) == 4 diff --git a/workers/mcp/package.json b/workers/mcp/package.json index 086c77a..c600eaf 100644 --- a/workers/mcp/package.json +++ b/workers/mcp/package.json @@ -1,7 +1,7 @@ { "name": "openfindata-mcp", "private": true, - "version": "0.3.1", + "version": "0.3.2", "type": "module", "scripts": { "dev": "wrangler dev", diff --git a/workers/mcp/public/index.html b/workers/mcp/public/index.html index 391520a..b1eefbb 100644 --- a/workers/mcp/public/index.html +++ b/workers/mcp/public/index.html @@ -40,10 +40,10 @@

MCP público no Cloudflare Workers

  • ipea_series ipea_search
  • tesouro_siconfi
  • openfinance_directory
  • -
  • cvm_fund — cadastro RCVM 175 + série de cotas INF_DIARIO (não CDA)
  • +
  • cvm_fund — cadastro RCVM 175, cotas INF_DIARIO e carteira CDA
  • - B3/ANBIMA/registry, CDA/lâmina/perfil e a API REST completa continuam no + B3/ANBIMA/registry, lâmina/perfil e a API REST completa continuam no pacote Python (pip install openfindata) e no FastAPI interno.

    Cliente MCP:

    diff --git a/workers/mcp/src/index.ts b/workers/mcp/src/index.ts index deb6072..b9f5915 100644 --- a/workers/mcp/src/index.ts +++ b/workers/mcp/src/index.ts @@ -11,7 +11,7 @@ export default { return Response.json({ status: "ok", surface: "mcp-worker", - version: "0.3.1", + version: "0.3.2", mcp: "/mcp", }); } diff --git a/workers/mcp/src/lib/http.ts b/workers/mcp/src/lib/http.ts index 376d96a..7726a68 100644 --- a/workers/mcp/src/lib/http.ts +++ b/workers/mcp/src/lib/http.ts @@ -1,4 +1,4 @@ -const USER_AGENT = "openfindata-mcp/0.3.1 (+https://github.com/robertoecf/OpenFinData)"; +const USER_AGENT = "openfindata-mcp/0.3.2 (+https://github.com/robertoecf/OpenFinData)"; export const FETCH_TIMEOUT_MS = 15_000; export const MAX_RESPONSE_BYTES = 2_000_000; diff --git a/workers/mcp/src/lib/zipCsv.ts b/workers/mcp/src/lib/zipCsv.ts index 07c9efa..f8c304e 100644 --- a/workers/mcp/src/lib/zipCsv.ts +++ b/workers/mcp/src/lib/zipCsv.ts @@ -53,6 +53,27 @@ function findZipEntry(zip: Uint8Array, name: string): ZipEntry { throw new Error(`zip entry not found: ${name}`); } +export function listZipEntryNames(zip: Uint8Array): string[] { + const names: string[] = []; + let offset = 0; + while (offset + 30 <= zip.length) { + const sig = u32(zip, offset); + if (sig === CENTRAL_SIG) { + break; + } + if (sig !== LOCAL_SIG) { + throw new Error("invalid zip local header"); + } + const compSize = u32(zip, offset + 18); + const nameLen = u16(zip, offset + 26); + const extraLen = u16(zip, offset + 28); + const nameStart = offset + 30; + names.push(LATIN1.decode(zip.subarray(nameStart, nameStart + nameLen))); + offset = nameStart + nameLen + extraLen + compSize; + } + return names; +} + function inflateRawStream(data: Uint8Array): ReadableStream { return new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw")); } diff --git a/workers/mcp/src/server.ts b/workers/mcp/src/server.ts index b009b50..6044421 100644 --- a/workers/mcp/src/server.ts +++ b/workers/mcp/src/server.ts @@ -24,7 +24,7 @@ function wrap(run: (args: T) => Promise) { export function createServer() { const server = new McpServer({ name: "openfindata", - version: "0.3.1", + version: "0.3.2", websiteUrl: "https://openfindata.com.br", }); @@ -141,13 +141,19 @@ export function createServer() { "cvm_fund", { description: - "CVM fund registry + open-fund cota series. catalog: RCVM 175 cadastro by CNPJ or name (any registered type; forma_condominio says Aberto/Fechado). daily: INF_DIARIO cota/PL/cotistas for one month (fundos abertos). Not CDA carteira. Not Mais Retorno.", + "CVM open-fund raw layer. catalog: RCVM 175 cadastro by CNPJ or name. daily: INF_DIARIO cota/PL/cotistas (omit year/month for latest published month; months=1..3 lookback). periods: available CDA or INF_DIARIO YYYYMM stamps. holdings: CDA carteira for one month (omit year/month for latest; CONFID is sigilo, not a complete open book). Not Mais Retorno.", inputSchema: { - dataset: z.enum(["catalog", "daily"]).default("catalog"), + dataset: z.enum(["catalog", "daily", "holdings", "periods"]).default("catalog"), cnpj: z.string().optional().describe("Fund CNPJ, punctuated or digits"), q: z.string().optional().describe("catalog: name fragment when CNPJ is unknown"), - year: z.number().int().min(2021).optional(), + year: z.number().int().min(2018).optional(), month: z.number().int().min(1).max(12).optional(), + months: z.number().int().min(1).max(3).optional().describe("daily: lookback months including the end month"), + product: z.enum(["CDA", "INF_DIARIO"]).optional().describe("periods: which directory to list"), + blocks: z + .string() + .optional() + .describe("holdings: comma list such as BLC_1,BLC_4 (BLC_1..8, CONFID, PL, FIE)"), limit: z.number().int().min(1).max(2000).optional(), }, }, diff --git a/workers/mcp/src/tools/cvm.test.ts b/workers/mcp/src/tools/cvm.test.ts index cd6f119..452aeb2 100644 --- a/workers/mcp/src/tools/cvm.test.ts +++ b/workers/mcp/src/tools/cvm.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { deflateRawSync } from "node:zlib"; import { afterEach, test } from "node:test"; -import { cvmFund } from "./cvm.ts"; -import { scanZipCsvForNeedles, zipFile } from "../lib/zipCsv.ts"; +import { cvmFund, listCvmZipMonths } from "./cvm.ts"; +import { listZipEntryNames, scanZipCsvForNeedles, zipFile } from "../lib/zipCsv.ts"; function crc32(data: Uint8Array): number { let crc = 0xffffffff; @@ -91,16 +91,39 @@ afterEach(() => { globalThis.fetch = originalFetch; }); -function mockZip(urlContains: string, zip: Uint8Array) { +function mockRoutes(routes: Array<{ match: string; body: Uint8Array | string; status?: number }>) { globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); - if (!url.includes(urlContains)) { + const route = routes + .filter((item) => url.includes(item.match)) + .sort((a, b) => b.match.length - a.match.length)[0]; + if (!route) { return new Response("missing", { status: 404 }); } - return new Response(zip, { status: 200 }); + return new Response(route.body, { status: route.status ?? 200 }); }) as typeof fetch; } +function mockZip(urlContains: string, zip: Uint8Array) { + mockRoutes([{ match: urlContains, body: zip }]); +} + +const CDA_HTML = + 'cda_fi_202606.zipcda_fi_202607.zip'; +const INF_HTML = + 'inf_diario_fi_202607.zipinf_diario_fi_202608.zip'; + +const CDA_BLC1 = + "CNPJ_FUNDO_CLASSE;DENOM_SOCIAL;DT_COMPTC;TP_APLIC;TP_ATIVO;EMISSOR;QT_POS_FINAL;VL_MERC_POS_FINAL;DS_ATIVO\n" + + "38.729.027/0001-92;AMW PREV;2026-07-31;Titulos Publicos;LFT;TESOURO;10;1000;LFT 2027\n" + + "21.494.444/0001-09;OUTRO;2026-07-31;Titulos Publicos;LFT;TESOURO;1;10;LFT 2027\n"; +const CDA_BLC2 = + "CNPJ_FUNDO_CLASSE;DENOM_SOCIAL;DT_COMPTC;TP_APLIC;TP_ATIVO;EMISSOR;QT_POS_FINAL;VL_MERC_POS_FINAL;DS_ATIVO\n" + + "38.729.027/0001-92;AMW PREV;2026-07-31;Cotas de Fundos;Cota;OUTRO FUNDO;5;500;Fundo X\n"; +const CDA_CONFID = + "CNPJ_FUNDO_CLASSE;DENOM_SOCIAL;DT_COMPTC;TP_APLIC\n" + + "38.729.027/0001-92;AMW PREV;2026-07-31;Sigilo\n"; + test("zipFile reads stored CSV", async () => { const zip = storeZip({ "registro_fundo.csv": FUNDO_CSV }); const bytes = await zipFile(zip, "registro_fundo.csv"); @@ -178,3 +201,142 @@ test("cvm_fund daily matches digit CNPJ to punctuated INF_DIARIO", async () => { assert.equal(body.series[0]?.cnpj, "38.729.027/0001-92"); assert.equal(body.series[0]?.vl_quota, 2.94); }); + +test("listCvmZipMonths reads only matching monthly zips", () => { + const months = listCvmZipMonths( + `${CDA_HTML}skipno`, + "cda_fi_", + ); + assert.deepEqual(months, ["202606", "202607"]); +}); + +test("listZipEntryNames walks every local header", () => { + const zip = storeZip({ "a.csv": "x\n", "b.csv": "y\n" }); + assert.deepEqual(listZipEntryNames(zip), ["a.csv", "b.csv"]); +}); + +test("cvm_fund periods lists CDA stamps and latest", async () => { + mockRoutes([{ match: "/CDA/DADOS/", body: CDA_HTML }]); + const result = await cvmFund({ dataset: "periods" }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { latest: string; periods: string[] }; + assert.equal(body.latest, "202607"); + assert.deepEqual(body.periods, ["202606", "202607"]); +}); + +test("cvm_fund holdings requires cnpj", async () => { + const result = await cvmFund({ dataset: "holdings", year: 2026, month: 7 }); + assert.equal(result.isError, true); +}); + +test("cvm_fund holdings scans every CDA block for one CNPJ", async () => { + mockRoutes([ + { + match: "cda_fi_202607.zip", + body: storeZip({ + "cda_fi_BLC_1_202607.csv": CDA_BLC1, + "cda_fi_BLC_2_202607.csv": CDA_BLC2, + "cda_fi_CONFID_202607.csv": CDA_CONFID, + }), + }, + ]); + const result = await cvmFund({ + dataset: "holdings", + cnpj: "38729027000192", + year: 2026, + month: 7, + }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { + holdings: Array<{ bloco: string; valor_mercado: number | null }>; + }; + assert.equal(body.holdings.length, 3); + assert.deepEqual( + body.holdings.map((row) => row.bloco), + ["BLC_1", "BLC_2", "CONFID"], + ); + assert.equal(body.holdings[0]?.valor_mercado, 1000); +}); + +test("cvm_fund holdings omits year/month and uses the latest CDA stamp", async () => { + mockRoutes([ + { match: "/CDA/DADOS/", body: CDA_HTML }, + { + match: "cda_fi_202607.zip", + body: storeZip({ "cda_fi_BLC_1_202607.csv": CDA_BLC1 }), + }, + ]); + const result = await cvmFund({ dataset: "holdings", cnpj: "38.729.027/0001-92" }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { year: number; month: number; holdings: unknown[] }; + assert.equal(body.year, 2026); + assert.equal(body.month, 7); + assert.equal(body.holdings.length, 1); +}); + +test("cvm_fund holdings honors a block whitelist", async () => { + mockRoutes([ + { + match: "cda_fi_202607.zip", + body: storeZip({ + "cda_fi_BLC_1_202607.csv": CDA_BLC1, + "cda_fi_BLC_2_202607.csv": CDA_BLC2, + }), + }, + ]); + const result = await cvmFund({ + dataset: "holdings", + cnpj: "38729027000192", + year: 2026, + month: 7, + blocks: "BLC_2", + }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { holdings: Array<{ bloco: string }> }; + assert.equal(body.holdings.length, 1); + assert.equal(body.holdings[0]?.bloco, "BLC_2"); +}); + +test("cvm_fund daily months=2 concatenates two INF_DIARIO months", async () => { + const july = + "TP_FUNDO_CLASSE;CNPJ_FUNDO_CLASSE;ID_SUBCLASSE;DT_COMPTC;VL_TOTAL;VL_QUOTA;VL_PATRIM_LIQ;CAPTC_DIA;RESG_DIA;NR_COTST\n" + + "CLASSES - FIF;38.729.027/0001-92;;2026-07-31;1;2.90;100;0;0;1\n"; + mockRoutes([ + { match: "inf_diario_fi_202607.zip", body: storeZip({ "inf_diario_fi_202607.csv": july }) }, + { match: "inf_diario_fi_202608.zip", body: storeZip({ "inf_diario_fi_202608.csv": DAILY_CSV }) }, + ]); + const result = await cvmFund({ + dataset: "daily", + cnpj: "38729027000192", + year: 2026, + month: 8, + months: 2, + }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { + from: string; + to: string; + series: Array<{ dt_comptc: string; vl_quota: number }>; + }; + assert.equal(body.from, "202607"); + assert.equal(body.to, "202608"); + assert.equal(body.series.length, 2); + assert.equal(body.series[0]?.vl_quota, 2.9); + assert.equal(body.series[1]?.vl_quota, 2.94); +}); + +test("cvm_fund daily without year/month uses the latest INF_DIARIO stamp", async () => { + mockRoutes([ + { match: "/INF_DIARIO/DADOS/", body: INF_HTML }, + { + match: "inf_diario_fi_202608.zip", + body: storeZip({ "inf_diario_fi_202608.csv": DAILY_CSV }), + }, + ]); + const result = await cvmFund({ dataset: "daily", cnpj: "38729027000192" }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { year: number; month: number; series: unknown[] }; + assert.equal(body.year, 2026); + assert.equal(body.month, 8); + assert.equal(body.series.length, 1); +}); diff --git a/workers/mcp/src/tools/cvm.ts b/workers/mcp/src/tools/cvm.ts index 02f43a6..645c1b2 100644 --- a/workers/mcp/src/tools/cvm.ts +++ b/workers/mcp/src/tools/cvm.ts @@ -1,13 +1,28 @@ -import { errorResult, getBytes, jsonResult } from "../lib/http.ts"; -import { cnpjDigits, optFloat, scanZipCsv, scanZipCsvForNeedles } from "../lib/zipCsv.ts"; +import { errorResult, getBytes, jsonResult, UpstreamError } from "../lib/http.ts"; +import { + cnpjDigits, + listZipEntryNames, + optFloat, + scanZipCsv, + scanZipCsvForNeedles, +} from "../lib/zipCsv.ts"; const REGISTRO_URL = "https://dados.cvm.gov.br/dados/FI/CAD/DADOS/registro_fundo_classe.zip"; const DAILY_URL = "https://dados.cvm.gov.br/dados/FI/DOC/INF_DIARIO/DADOS/inf_diario_fi_{ym}.zip"; +const CDA_URL = "https://dados.cvm.gov.br/dados/FI/DOC/CDA/DADOS/cda_fi_{ym}.zip"; +const CDA_LISTING_URL = "https://dados.cvm.gov.br/dados/FI/DOC/CDA/DADOS/"; +const DAILY_LISTING_URL = "https://dados.cvm.gov.br/dados/FI/DOC/INF_DIARIO/DADOS/"; const CVM_TIMEOUT_MS = 45_000; -const CVM_MAX_BYTES = 16_000_000; +const CVM_MAX_BYTES = 32_000_000; +const CVM_LISTING_MAX_BYTES = 2_000_000; const CATALOG_CLASS_CAP = 2_000; const DAILY_SCAN_CAP = 2_000; +const HOLDINGS_SCAN_CAP = 5_000; +const DAILY_MONTHS_MAX = 3; + +export type CvmDataset = "catalog" | "daily" | "holdings" | "periods"; +export type CvmPeriodProduct = "CDA" | "INF_DIARIO"; async function fetchCvmZip(url: string): Promise { return getBytes(url, { maxBytes: CVM_MAX_BYTES, timeoutMs: CVM_TIMEOUT_MS }); @@ -177,9 +192,66 @@ async function catalogByName(zip: Uint8Array, q: string, limit: number) { return fundos.map((row) => mapFundo(row, classesByFundo.get(row.ID_Registro_Fundo ?? "") ?? [])); } -function currentYearMonth(): { year: number; month: number } { - const now = new Date(); - return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 }; +function formatYm(year: number, month: number): string { + return `${year}${String(month).padStart(2, "0")}`; +} + +function addMonths(year: number, month: number, delta: number): { year: number; month: number } { + const absolute = year * 12 + (month - 1) + delta; + return { year: Math.floor(absolute / 12), month: (absolute % 12) + 1 }; +} + +function lookbackMonths( + endYear: number, + endMonth: number, + count: number, +): { year: number; month: number }[] { + const out: { year: number; month: number }[] = []; + for (let i = count - 1; i >= 0; i -= 1) { + out.push(addMonths(endYear, endMonth, -i)); + } + return out; +} + +export function listCvmZipMonths(html: string, prefix: string): string[] { + const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`${escaped}(\\d{6})\\.zip`, "gi"); + const months = new Set(); + for (const match of html.matchAll(re)) { + months.add(match[1]!); + } + return [...months].sort(); +} + +async function listPublishedMonths(url: string, prefix: string): Promise { + const html = new TextDecoder("utf-8").decode( + await getBytes(url, { maxBytes: CVM_LISTING_MAX_BYTES, timeoutMs: 15_000 }), + ); + return listCvmZipMonths(html, prefix); +} + +async function resolveYearMonth( + args: { year?: number; month?: number }, + listingUrl: string, + prefix: string, +): Promise<{ year: number; month: number } | { error: string }> { + const hasYear = args.year !== undefined; + const hasMonth = args.month !== undefined; + if (hasYear !== hasMonth) { + return { error: "pass both `year` and `month`, or omit both for the latest published file" }; + } + if (hasYear && hasMonth) { + if (args.year! < 2018 || args.month! < 1 || args.month! > 12) { + return { error: "year must be >= 2018 and month 1-12" }; + } + return { year: args.year!, month: args.month! }; + } + const published = await listPublishedMonths(listingUrl, prefix); + const latest = published.at(-1); + if (!latest) { + return { error: `no published files at ${listingUrl}` }; + } + return { year: Number(latest.slice(0, 4)), month: Number(latest.slice(4, 6)) }; } function mapDaily(row: Record) { @@ -197,16 +269,115 @@ function mapDaily(row: Record) { }; } +function cdaBlockLabel(filename: string): string { + const base = filename.split("/").pop() ?? filename; + const upper = base.toUpperCase(); + const parts = base.replace(/\.csv$/i, "").split("_"); + if (parts.length >= 4 && parts[2] === "BLC") { + return `BLC_${parts[3]}`; + } + if (upper.includes("CONFID")) { + return base.toLowerCase().includes("fie") ? "FIE_CONFID" : "CONFID"; + } + if (base.toLowerCase().startsWith("cda_fie")) { + return "FIE"; + } + if (upper.includes("_PL_")) { + return "PL"; + } + return "OTHER"; +} + +function mapHolding(row: Record, bloco: string) { + return { + cnpj: row.CNPJ_FUNDO_CLASSE || row.CNPJ_FUNDO || "", + nome_fundo: row.DENOM_SOCIAL || row.DENOM_CLASSE || "", + dt_referencia: row.DT_COMPTC ?? "", + bloco, + tipo_aplicacao: row.TP_APLIC || null, + tipo_ativo: row.TP_ATIVO || null, + emissor: row.EMISSOR_LIGADO || row.EMISSOR || null, + cnpj_emissor: row.CNPJ_EMISSOR || row.CPF_CNPJ_EMISSOR || null, + tipo_negociacao: row.TP_NEGOC || null, + quantidade_final: optFloat(row.QT_POS_FINAL), + valor_mercado: optFloat(row.VL_MERC_POS_FINAL || row.VL_MERCADO || row.VL_MERC_POSICAO), + descricao: row.DS_ATIVO || row.CD_ATIVO || null, + }; +} + +function parseBlocks(raw: string | undefined): Set | null { + if (!raw?.trim()) { + return null; + } + return new Set( + raw + .split(",") + .map((item) => item.trim().toUpperCase()) + .filter(Boolean), + ); +} + +async function dailySeries( + digits: string, + year: number, + month: number, + limit: number, +): Promise[]> { + const ym = formatYm(year, month); + const zip = await fetchCvmZip(DAILY_URL.replace("{ym}", ym)); + const csvName = `inf_diario_fi_${ym}.csv`; + const rows = ( + await scanZipCsvForNeedles(zip, csvName, cnpjNeedles(digits), Math.min(limit, DAILY_SCAN_CAP)) + ).filter((row) => fieldCnpjEquals(row, ["CNPJ_FUNDO_CLASSE", "CNPJ_FUNDO"], digits)); + return rows.map(mapDaily); +} + +async function holdingsFromZip( + zip: Uint8Array, + digits: string, + blocks: Set | null, + limit: number, +) { + const needles = cnpjNeedles(digits); + const holdings: ReturnType[] = []; + for (const name of listZipEntryNames(zip)) { + if (!name.toLowerCase().endsWith(".csv")) { + continue; + } + const bloco = cdaBlockLabel(name); + if (blocks && !blocks.has(bloco)) { + continue; + } + const rows = ( + await scanZipCsvForNeedles(zip, name, needles, Math.min(limit - holdings.length, HOLDINGS_SCAN_CAP)) + ).filter((row) => fieldCnpjEquals(row, ["CNPJ_FUNDO_CLASSE", "CNPJ_FUNDO"], digits)); + for (const row of rows) { + holdings.push(mapHolding(row, bloco)); + if (holdings.length >= limit) { + return holdings; + } + } + } + return holdings; +} + export async function cvmFund(args: { - dataset?: "catalog" | "daily"; + dataset?: CvmDataset; cnpj?: string; q?: string; year?: number; month?: number; + months?: number; + product?: CvmPeriodProduct; + blocks?: string; limit?: number; }) { const dataset = args.dataset ?? "catalog"; - const limit = Math.min(args.limit ?? (dataset === "daily" ? 500 : 20), dataset === "daily" ? 2000 : 100); + const limit = Math.min( + args.limit ?? (dataset === "holdings" ? 2000 : dataset === "daily" ? 500 : 20), + dataset === "daily" || dataset === "holdings" ? 2000 : 100, + ); + if (dataset === "catalog") { const digits = cnpjDigits(args.cnpj); const q = args.q?.trim() ?? ""; @@ -219,28 +390,79 @@ export async function cvmFund(args: { : await catalogByName(zip, q, limit); return jsonResult(rows); } + + if (dataset === "periods") { + const product = args.product ?? "CDA"; + const listingUrl = product === "CDA" ? CDA_LISTING_URL : DAILY_LISTING_URL; + const prefix = product === "CDA" ? "cda_fi_" : "inf_diario_fi_"; + const periods = await listPublishedMonths(listingUrl, prefix); + return jsonResult({ + source: product === "CDA" ? "cvm_cda" : "cvm_inf_diario", + product, + latest: periods.at(-1) ?? null, + periods, + }); + } + const digits = cnpjDigits(args.cnpj); if (digits.length < 8) { - return errorResult("dataset=daily requires `cnpj`"); + return errorResult(`dataset=${dataset} requires \`cnpj\``); } - const fallback = currentYearMonth(); - const year = args.year ?? fallback.year; - const month = args.month ?? fallback.month; - if (year < 2021 || month < 1 || month > 12) { - return errorResult("dataset=daily requires year>=2021 and month 1-12"); + + if (dataset === "holdings") { + const resolved = await resolveYearMonth(args, CDA_LISTING_URL, "cda_fi_"); + if ("error" in resolved) { + return errorResult(resolved.error); + } + const ym = formatYm(resolved.year, resolved.month); + const zip = await fetchCvmZip(CDA_URL.replace("{ym}", ym)); + const holdings = await holdingsFromZip(zip, digits, parseBlocks(args.blocks), limit); + return jsonResult({ + source: "cvm_cda", + year: resolved.year, + month: resolved.month, + cnpj: digits, + truncated: holdings.length >= limit, + note: "CDA is a delayed monthly feed. CONFID rows are confidential (sigilo), not a complete open book.", + holdings, + }); + } + + const months = Math.min(Math.max(args.months ?? 1, 1), DAILY_MONTHS_MAX); + const resolved = await resolveYearMonth(args, DAILY_LISTING_URL, "inf_diario_fi_"); + if ("error" in resolved) { + return errorResult(resolved.error); + } + const window = lookbackMonths(resolved.year, resolved.month, months); + const series: ReturnType[] = []; + const missing: string[] = []; + let remaining = limit; + for (const stamp of window) { + const ym = formatYm(stamp.year, stamp.month); + try { + const chunk = await dailySeries(digits, stamp.year, stamp.month, remaining); + series.push(...chunk); + remaining = Math.max(limit - series.length, 0); + if (remaining === 0) { + break; + } + } catch (error) { + if (error instanceof UpstreamError && error.status === 404) { + missing.push(ym); + continue; + } + throw error; + } } - const ym = `${year}${String(month).padStart(2, "0")}`; - const zip = await fetchCvmZip(DAILY_URL.replace("{ym}", ym)); - const csvName = `inf_diario_fi_${ym}.csv`; - const rows = ( - await scanZipCsvForNeedles(zip, csvName, cnpjNeedles(digits), Math.min(limit, DAILY_SCAN_CAP)) - ).filter((row) => fieldCnpjEquals(row, ["CNPJ_FUNDO_CLASSE", "CNPJ_FUNDO"], digits)); return jsonResult({ source: "cvm_inf_diario", - year, - month, + year: resolved.year, + month: resolved.month, + months, + from: formatYm(window[0]!.year, window[0]!.month), + to: formatYm(resolved.year, resolved.month), cnpj: digits, - note: "CDA carteira is a separate delayed monthly feed and is not included.", - series: rows.map(mapDaily), + missing, + series, }); }