From bb7e3f1fccc2960e381b2f21f952e8d7ccf996c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 21:08:08 +0000 Subject: [PATCH 1/2] feat: CVM fund quote history and structure mapping on public MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend cvm_fund so agents can search by class/subclass name, pull up to 12 months of INF_DIARIO (start/end), stitch a single-class 555→175 continuation, and report the nicename actually served without picking a FIDC série. Document the Mais Retorno data-tool mapping. No calc tools. --- CHANGELOG.md | 8 + docs/DEPLOY_WORKERS_MCP.md | 18 +- docs/MCP_SURFACE.md | 18 +- docs/agents/orientation.md | 2 +- src/findata/api/mcp_app.py | 200 +++++++++++++++++-- src/findata/sources/cvm/__init__.py | 4 + src/findata/sources/cvm/cadastro.py | 105 +++++++++- src/findata/sources/cvm/funds.py | 9 +- tests/test_cvm_fund_cadastro.py | 139 +++++++++++++- tests/test_mcp_surface.py | 51 ++++- workers/mcp/package.json | 2 +- workers/mcp/src/index.ts | 2 +- workers/mcp/src/lib/http.ts | 2 +- workers/mcp/src/server.ts | 25 ++- workers/mcp/src/tools/cvm.test.ts | 154 ++++++++++++++- workers/mcp/src/tools/cvm.ts | 285 +++++++++++++++++++++++++--- 16 files changed, 954 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd6353..d293b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ adheres to [Semantic Versioning](https://semver.org/). ### Added +- **Public MCP `cvm_fund` quotes/structure parity (CVM only).** `dataset=daily` + accepts `start`/`end` or `months` up to 12, stitches a single-class RCVM 175 + continuation onto legacy 555 INF_DIARIO when the files share sibling CNPJs, + and returns `served[].nicename` / class / subclass for the series actually + returned. Multiple subclasses stay grouped (`pick_required`) — the tool does + not pick a FIDC série. `dataset=catalog` `q` also matches classe and + subclasse names. Worker tool description maps Mais Retorno data tools onto + `cvm_fund` datasets. Calc/analytics tools stay out. Rate limits unchanged. - **Public MCP `cvm_fund` (Worker) + RCVM 175 cadastro.** The public Worker at `/mcp` now resolves an open-ended fund by CNPJ (or name) against official CVM `registro_fundo_classe.zip` and returns INF_DIARIO cota/PL/cotistas diff --git a/docs/DEPLOY_WORKERS_MCP.md b/docs/DEPLOY_WORKERS_MCP.md index d17bd48..a4ab876 100644 --- a/docs/DEPLOY_WORKERS_MCP.md +++ b/docs/DEPLOY_WORKERS_MCP.md @@ -20,15 +20,27 @@ registry FTS5, code mode): `pip install openfindata` ou FastAPI interno. `cvm_fund` no Worker: - `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. + (`registro_fundo_classe.zip`: fundo + classe + subclasse). `q` casa + nome de fundo, classe e subclasse. `cad_fi.csv` não lista fundos já + adaptados à Resolução 175. - `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. + usa o mês mais recente; `months` (1–12) ou `start`/`end` (YYYY-MM-DD, + no máximo 12 meses; janelas maiores pedem nova chamada). Classe única + adaptada da 555 costura o CNPJ legado quando o arquivo CVM permite. + `served[]` traz `nicename` / classe / subclasse efetivamente devolvidos; + várias séries no mesmo CNPJ vêm agrupadas (`pick_required`) — não + escolher FIDC sênior vs subordinada pelo usuário. ZIP anual HIST da CVM + passa de 32 MB e não entra no isolate. - `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. +Mapeamento das tools de **dados** da Mais Retorno (cálculo/analytics fora): +`search_assets` / `get_asset_info` / `list_fund_structure` / +`get_fund_class_subclass` → `catalog`; `get_quotes` → `daily`; +`get_available_wallets` → `periods`; `get_wallet_detail` → `holdings`. + ## Deploy ```bash diff --git a/docs/MCP_SURFACE.md b/docs/MCP_SURFACE.md index 785a421..ad018cb 100644 --- a/docs/MCP_SURFACE.md +++ b/docs/MCP_SURFACE.md @@ -6,7 +6,7 @@ > 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 holdings/periods). +> `cvm_fund` (RCVM 175 cadastro + INF_DIARIO history ≤12 months + 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; omit year/month → latest CDA/INF_DIARIO | +| `cvm_fund` | `funds`, `funds/cadastro`, `funds/{daily,holdings,lamina,profile,periods}`, returns | `dataset`; `cnpj`/`q` → RCVM 175 (classe/subclasse names too); `daily` `start`/`end` or `months`≤12; 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` | @@ -120,8 +120,18 @@ local/agent use. A production deployment should run it in a real sandbox - `registry_lookup(q="PETR4")` → PETROBRAS, CNPJ `33.000.167/0001-01`, `[PETR3, PETR4]` (offline). - `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=catalog, cnpj="38.729.027/0001-92")` → cadastro RCVM 175 (classe, condomínio, PL, subclasses). +- `cvm_fund(dataset=daily, cnpj="38729027000192", start="2025-09-01", end="2026-08-31")` → INF_DIARIO history (`served[].nicename`). - `cvm_fund(dataset=periods, product="CDA")` → YYYYMM stamps + `latest`. - `cvm_fund(dataset=holdings, cnpj="38729027000192")` → latest CDA carteira (CONFID = sigilo). + +Mais Retorno **data** tools (CVM/official only; calc tools are out of scope): + +| Mais Retorno | `cvm_fund` | +|---|---| +| `search_assets` / `get_asset_info` / `get_fund_class_subclass` | `dataset=catalog` (`q` or `cnpj`) | +| `list_fund_structure` | `catalog.classes[].subclasses[]` — do not pick a FIDC série | +| `get_quotes` | `dataset=daily` (`start`/`end` or `months`≤12; report `served[]`) | +| `get_available_wallets` | `dataset=periods` `product=CDA` | +| `get_wallet_detail` | `dataset=holdings` | - `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 eaf62b0..677909c 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` catalog/daily/holdings/periods); 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≤12m/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 afbd66a..bb02e8f 100644 --- a/src/findata/api/mcp_app.py +++ b/src/findata/api/mcp_app.py @@ -35,6 +35,7 @@ from datetime import date from typing import Any, Literal +import httpx from fastapi import APIRouter, FastAPI, HTTPException, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -62,7 +63,10 @@ latest_period, list_periods, profile, + quote_served_label, + related_quote_cnpjs, ) +from findata.sources.cvm.parser import cnpj_digits from findata.sources.ibge import indicators from findata.sources.ipea import series as ipea_series from findata.sources.openfinance import directory as of_dir @@ -76,6 +80,7 @@ _RGF_MAX_PERIOD = 3 # RGF quadrimestre runs 1..3 _DAILY_MONTHS_MAX = 12 _YYYYMM_LEN = 6 +_STITCH_SIBLING_CNPJS = 2 # requested + one fundo/classe pair def _stamp_to_year_month(stamp: str) -> tuple[int, int]: @@ -106,6 +111,160 @@ async def _resolve_cvm_month(year: int | None, month: int | None, product: str) return _stamp_to_year_month(latest) +def _parse_iso_month(value: str) -> tuple[int, int]: + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise HTTPException(400, "start/end must be YYYY-MM-DD") from exc + return parsed.year, parsed.month + + +def _stamps_inclusive(start: tuple[int, int], end: tuple[int, int]) -> list[tuple[int, int]]: + if start > end: + raise HTTPException(400, "`start` must be on or before `end`") + stamps: list[tuple[int, int]] = [] + year, month = start + while (year, month) <= end: + stamps.append((year, month)) + if len(stamps) > _DAILY_MONTHS_MAX: + raise HTTPException( + 400, + f"daily window exceeds {_DAILY_MONTHS_MAX} months; page `start`/`end`", + ) + year, month = _add_months(year, month, 1) + return stamps + + +async def _daily_month_stamps( + year: int | None, + month: int | None, + months: int, + start: str | None, + end: str | None, +) -> list[tuple[int, int]]: + if start or end: + if not start or not end: + raise HTTPException(400, "pass both `start` and `end`, or omit both") + return _stamps_inclusive(_parse_iso_month(start), _parse_iso_month(end)) + resolved_year, resolved_month = await _resolve_cvm_month(year, month, "INF_DIARIO") + return _lookback_months(resolved_year, resolved_month, months) + + +def _group_daily_series( + series: list[Any], + cadastro: list[Any], + compact: bool, + requested: str, + needles: list[str], +) -> dict[str, Any]: + target = requested if cadastro and len(needles) == _STITCH_SIBLING_CNPJS else "" + groups: dict[tuple[str, str], list[Any]] = {} + for row in series: + cnpj = getattr(row, "cnpj", "") if not isinstance(row, dict) else row.get("cnpj", "") + sub = ( + getattr(row, "id_subclasse", "") + if not isinstance(row, dict) + else row.get("id_subclasse", "") + ) + row_digits = cnpj_digits(str(cnpj)) + group_cnpj = target if target and row_digits in needles else row_digits + key = (group_cnpj, str(sub or "")) + groups.setdefault(key, []).append(row) + served: list[dict[str, Any]] = [] + for (cnpj, sub), rows in groups.items(): + label = quote_served_label(cadastro, cnpj, sub) + item: dict[str, Any] = { + "cnpj": cnpj, + "id_subclasse": sub, + **label, + "points": len(rows), + } + if compact: + item["dates"] = [ + getattr(row, "dt_comptc", "") + if not isinstance(row, dict) + else row.get("dt_comptc", "") + for row in rows + ] + item["vl_quota"] = [ + getattr(row, "vl_quota", 0) if not isinstance(row, dict) else row.get("vl_quota", 0) + for row in rows + ] + served.append(item) + pick_required = len(served) > 1 + note = ( + "Multiple INF_DIARIO series for this CNPJ (classes/subclasses). " + "They are different investments — pass `id_subclasse` or the class CNPJ; do not pick." + if pick_required + else "Report served[].nicename / class / subclass actually returned." + ) + payload: dict[str, Any] = { + "source": "cvm_inf_diario", + "pick_required": pick_required, + "served": served, + "note": note, + } + if not compact: + payload["series"] = series + return payload + + +async def _cvm_fund_quotes( + cnpj: str, + year: int | None, + month: int | None, + months: int, + start: str | None, + end: str | None, + id_subclasse: str | None, + compact: bool, + limit: int, +) -> dict[str, Any]: + stamps = await _daily_month_stamps(year, month, months, start, end) + try: + cadastro = await get_fund_cadastro(cnpj=cnpj, limit=20) + except httpx.HTTPError: + cadastro = [] + needles = related_quote_cnpjs(cadastro, cnpj) if cadastro else [cnpj] + series: list[Any] = [] + missing: list[str] = [] + for stamp_year, stamp_month in stamps: + try: + chunk = await funds.get_fund_daily(stamp_year, stamp_month, ",".join(needles)) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == httpx.codes.NOT_FOUND: + missing.append(f"{stamp_year}{stamp_month:02d}") + continue + raise + series.extend(chunk) + if len(series) >= limit: + series = series[:limit] + break + if id_subclasse: + series = [ + row + for row in series + if ( + getattr(row, "id_subclasse", "") + if not isinstance(row, dict) + else row.get("id_subclasse", "") + ) + == id_subclasse + ] + payload = _group_daily_series(series, cadastro, compact, cnpj_digits(cnpj), needles) + payload.update( + { + "cnpj": cnpj_digits(cnpj), + "from": f"{stamps[0][0]}{stamps[0][1]:02d}" if stamps else None, + "to": f"{stamps[-1][0]}{stamps[-1][1]:02d}" if stamps else None, + "months": len(stamps), + "needles": needles, + "missing": missing, + } + ) + return payload + + # ── Registry: the entry point ───────────────────────────────────── @@ -354,6 +513,18 @@ async def cvm_fund( months: int = Query( 1, ge=1, le=_DAILY_MONTHS_MAX, description="daily: lookback months including the end month" ), + start: str | None = Query( + None, description="daily: YYYY-MM-DD inclusive start (use with end; max 12 months)" + ), + end: str | None = Query( + None, description="daily: YYYY-MM-DD inclusive end (use with start; max 12 months)" + ), + id_subclasse: str | None = Query( + None, description="daily: keep only this CVM subclass/série; do not guess for the user" + ), + compact: bool = Query( + False, description="daily: served[].dates + served[].vl_quota instead of series[]" + ), horizon: Literal["monthly", "yearly"] = Query( "monthly", description="returns granularity (dataset=returns)" ), @@ -372,13 +543,14 @@ async def cvm_fund( ] = Query("INF_DIARIO", description="periods: which CVM document set to list stamps for"), limit: int = Query(500, ge=1, le=5000), ) -> Any: - """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); 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. + """Open funds in one tool. Mais Retorno data mapping (CVM/official only; no + calc tools): ``catalog`` = search_assets + get_asset_info + + list_fund_structure + get_fund_class_subclass (``q`` matches fundo, + classe and subclasse names); ``daily`` = get_quotes (``start``/``end`` or + ``months``≤12; ``served[].nicename`` is what was returned; never pick a + FIDC série); ``periods``+``holdings`` = get_available_wallets + + get_wallet_detail. Bare ``catalog`` still pages legacy ``cad_fi.csv``. + CDA ``holdings`` CONFID is sigilo, not a complete open book. """ if dataset == "catalog": if cnpj or q: @@ -393,15 +565,11 @@ async def cvm_fund( 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 not cnpj: + raise HTTPException(400, "dataset=daily requires `cnpj`") + return await _cvm_fund_quotes( + cnpj, year, month, months, start, end, id_subclasse, compact, limit + ) if year is None: raise HTTPException(400, f"dataset={dataset} requires `year`") if month is None: diff --git a/src/findata/sources/cvm/__init__.py b/src/findata/sources/cvm/__init__.py index 8b83881..137efef 100644 --- a/src/findata/sources/cvm/__init__.py +++ b/src/findata/sources/cvm/__init__.py @@ -36,6 +36,8 @@ FundClasse, FundSubclass, get_fund_cadastro, + quote_served_label, + related_quote_cnpjs, ) from findata.sources.cvm.companies import Company, get_companies, search_company from findata.sources.cvm.fca import ( @@ -128,5 +130,7 @@ "latest_period", "list_files", "list_periods", + "quote_served_label", + "related_quote_cnpjs", "search_company", ] diff --git a/src/findata/sources/cvm/cadastro.py b/src/findata/sources/cvm/cadastro.py index f210b04..298376a 100644 --- a/src/findata/sources/cvm/cadastro.py +++ b/src/findata/sources/cvm/cadastro.py @@ -210,14 +210,40 @@ def _select_fundos( ), limit, ) - if selected or not needle_digits: - return selected - class_ids = { + if needle_digits: + if selected: + return selected + class_ids = { + row.get("ID_Registro_Fundo", "") + for row in tables.classes + if cnpj_digits(row.get("CNPJ_Classe")) == needle_digits + } + return _take(tables.fundos, lambda row: row.get("ID_Registro_Fundo") in class_ids, limit) + extra_fundo_ids = { row.get("ID_Registro_Fundo", "") for row in tables.classes - if cnpj_digits(row.get("CNPJ_Classe")) == needle_digits + if needle_name in (row.get("Denominacao_Social") or "").casefold() + } + sub_class_ids = { + row.get("ID_Registro_Classe", "") + for row in tables.subclasses + if needle_name in (row.get("Denominacao_Social") or "").casefold() } - return _take(tables.fundos, lambda row: row.get("ID_Registro_Fundo") in class_ids, limit) + if sub_class_ids: + extra_fundo_ids.update( + row.get("ID_Registro_Fundo", "") + for row in tables.classes + if row.get("ID_Registro_Classe") in sub_class_ids + ) + already = {row.get("ID_Registro_Fundo") for row in selected} + for row in tables.fundos: + fundo_id = row.get("ID_Registro_Fundo") + if fundo_id in extra_fundo_ids and fundo_id not in already: + selected.append(row) + already.add(fundo_id) + if len(selected) >= limit: + break + return selected def _assemble(tables: _RegistroTables, selected: list[dict[str, str]]) -> list[FundCadastro]: @@ -246,6 +272,75 @@ def _assemble(tables: _RegistroTables, selected: list[dict[str, str]]) -> list[F return result +def related_quote_cnpjs(funds: list[FundCadastro], requested_digits: str) -> list[str]: + """INF_DIARIO CNPJs to scan for one catalog hit. + + A single-class RCVM 175 adaptation may add the sibling fundo/classe CNPJ so + legacy 555 rows stitch onto the continuation class. Multi-class funds never + borrow another class — those series are different investments. + """ + requested = cnpj_digits(requested_digits) + out: list[str] = [] + seen: set[str] = set() + + def add(raw: str) -> None: + digits = cnpj_digits(raw) + if digits and digits not in seen: + seen.add(digits) + out.append(digits) + + add(requested) + for fund in funds: + fund_digits = cnpj_digits(fund.cnpj) + class_digits = list( + dict.fromkeys( + cnpj_digits(classe.cnpj_classe) + for classe in fund.classes + if cnpj_digits(classe.cnpj_classe) + ) + ) + matched_fund = fund_digits == requested + matched_class = any(cnpj_digits(classe.cnpj_classe) == requested for classe in fund.classes) + if matched_fund: + for digits in class_digits: + add(digits) + elif matched_class and len(class_digits) == 1: + add(fund_digits) + return out + + +def quote_served_label(funds: list[FundCadastro], cnpj: str, id_subclasse: str) -> dict[str, str]: + """Nicename / CVM / ANBIMA class for the series actually returned.""" + digits = cnpj_digits(cnpj) + nicename = "" + tipo_classe = "" + classificacao = "" + classe_anbima = "" + subclass_name = "" + for fund in funds: + if cnpj_digits(fund.cnpj) == digits and not nicename: + nicename = fund.nome + for classe in fund.classes: + if cnpj_digits(classe.cnpj_classe) != digits: + continue + nicename = classe.nome or nicename + tipo_classe = classe.tipo_classe + classificacao = classe.classificacao + classe_anbima = classe.classe_anbima + for sub in classe.subclasses: + if id_subclasse and sub.id_subclasse == id_subclasse: + subclass_name = sub.nome + if sub.nome: + nicename = sub.nome + return { + "nicename": nicename, + "tipo_classe": tipo_classe, + "classificacao": classificacao, + "classe_anbima": classe_anbima, + "subclass_name": subclass_name, + } + + async def get_fund_cadastro( cnpj: str | None = None, q: str | None = None, diff --git a/src/findata/sources/cvm/funds.py b/src/findata/sources/cvm/funds.py index 9b131a7..63f5f80 100644 --- a/src/findata/sources/cvm/funds.py +++ b/src/findata/sources/cvm/funds.py @@ -122,17 +122,18 @@ async def get_fund_daily( Args: year: Year (2021+). month: Month (1-12). - cnpj_filter: Filter by fund CNPJ (highly recommended to reduce memory). - Punctuated and digit-only forms both match. + cnpj_filter: Filter by fund/class CNPJ (highly recommended). Punctuated + and digit-only forms both match. Comma-separated digits stitch a + single-class 555→175 continuation. """ ym = f"{year}{month:02d}" url = FUND_DAILY_URL.format(ym=ym) rows = await fetch_csv_from_zip(url) - needle = cnpj_digits(cnpj_filter) if cnpj_filter else "" + needles = {cnpj_digits(part) for part in (cnpj_filter or "").split(",") if cnpj_digits(part)} results: list[FundDaily] = [] for row in rows: cnpj = row.get("CNPJ_FUNDO_CLASSE") or row.get("CNPJ_FUNDO", "") - if needle and cnpj_digits(cnpj) != needle: + if needles and cnpj_digits(cnpj) not in needles: continue parsed = _parse_daily_row(row) if parsed is not None: diff --git a/tests/test_cvm_fund_cadastro.py b/tests/test_cvm_fund_cadastro.py index 80568b4..c415347 100644 --- a/tests/test_cvm_fund_cadastro.py +++ b/tests/test_cvm_fund_cadastro.py @@ -13,7 +13,16 @@ 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.cadastro import REGISTRO_URL, _registro_cache, get_fund_cadastro +from findata.sources.cvm.cadastro import ( + REGISTRO_URL, + FundCadastro, + FundClasse, + FundSubclass, + _registro_cache, + get_fund_cadastro, + quote_served_label, + related_quote_cnpjs, +) from findata.sources.cvm.funds import FUND_DAILY_URL, get_fund_daily @@ -113,6 +122,129 @@ async def test_cadastro_by_name_fragment() -> None: assert rows[0].cnpj == "21494444000109" +@respx.mock +async def test_cadastro_by_class_only_name() -> None: + classe = ( + _CLASSE_HEADER + + _AMW_CLASSE + + ( + "51617;88888;21494444000109;1;2024-12-02;2015-02-04;2024-12-02;" + "Classes de Cotas de Fundos FIF;SERIE SENIOR UNICA XYZ;" + "Em Funcionamento Normal;2024-12-02;Renda Fixa;DI de um dia;N;" + "Previdência RF;N/A;;N;N;Aberto;N;Público;1;2026-08-26;" + "1;AUD;2;CUST;3;CTRL\n" + ) + ) + payload = _zip_csv( + { + "registro_fundo.csv": _FUNDO_HEADER + _AMW_FUNDO + _OTHER_FUNDO, + "registro_classe.csv": classe, + "registro_subclasse.csv": _SUB_HEADER, + } + ) + respx.get(REGISTRO_URL).mock(return_value=httpx.Response(200, content=payload)) + rows = await get_fund_cadastro(q="serie senior unica xyz") + assert len(rows) == 1 + assert rows[0].cnpj == "21494444000109" + + +def test_related_quote_cnpjs_stitches_single_class_only() -> None: + single = FundCadastro( + cnpj="11111111000191", + codigo_cvm="1", + nome="FUNDO", + tipo="FI", + situacao="ok", + data_registro="", + data_constituicao="", + data_adaptacao_rcvm175="2024-09-01", + classes=[ + FundClasse( + id_registro_classe="10", + cnpj_classe="22222222000191", + codigo_cvm="2", + nome="CLASSE", + tipo_classe="FIF", + situacao="ok", + classificacao="Multimercado", + classe_anbima="Livre", + forma_condominio="Aberto", + exclusivo="N", + publico_alvo="", + ) + ], + ) + assert related_quote_cnpjs([single], "22222222000191") == [ + "22222222000191", + "11111111000191", + ] + multi = single.model_copy( + update={ + "classes": [ + single.classes[0], + FundClasse( + id_registro_classe="11", + cnpj_classe="33333333000191", + codigo_cvm="3", + nome="OUTRA", + tipo_classe="FIF", + situacao="ok", + classificacao="RF", + classe_anbima="", + forma_condominio="Aberto", + exclusivo="N", + publico_alvo="", + ), + ] + } + ) + assert related_quote_cnpjs([multi], "22222222000191") == ["22222222000191"] + + +def test_quote_served_label_prefers_subclass_name() -> None: + fund = FundCadastro( + cnpj="38729027000192", + codigo_cvm="1", + nome="FUNDO", + tipo="FI", + situacao="ok", + data_registro="", + data_constituicao="", + data_adaptacao_rcvm175="", + classes=[ + FundClasse( + id_registro_classe="10", + cnpj_classe="38729027000192", + codigo_cvm="2", + nome="CLASSE", + tipo_classe="FIF", + situacao="ok", + classificacao="Multimercado", + classe_anbima="Livre", + forma_condominio="Aberto", + exclusivo="N", + publico_alvo="", + subclasses=[ + FundSubclass( + id_subclasse="S2", + codigo_cvm="3", + nome="SUBORDINADA", + situacao="ok", + forma_condominio="Aberto", + exclusivo="N", + publico_alvo="", + previdenciario="N", + exclusivo_previdencia_complementar="N", + ) + ], + ) + ], + ) + label = quote_served_label([fund], "38729027000192", "S2") + assert label["nicename"] == "SUBORDINADA" + assert label["classificacao"] == "Multimercado" + + @respx.mock async def test_cadastro_ignores_administrator_cnpj() -> None: respx.get(REGISTRO_URL).mock(return_value=httpx.Response(200, content=_registro_zip())) @@ -174,6 +306,7 @@ def test_rest_and_mcp_cadastro_by_cnpj() -> None: @respx.mock def test_mcp_daily_accepts_digit_cnpj() -> None: + respx.get(REGISTRO_URL).mock(return_value=httpx.Response(200, content=_registro_zip())) url = FUND_DAILY_URL.format(ym="202608") respx.get(url).mock(return_value=httpx.Response(200, content=_daily_zip())) mcp = TestClient(mcp_app).get( @@ -181,4 +314,6 @@ def test_mcp_daily_accepts_digit_cnpj() -> None: params={"dataset": "daily", "cnpj": "38729027000192", "year": 2026, "month": 8}, ) assert mcp.status_code == 200 - assert len(mcp.json()) == 2 + body = mcp.json() + assert len(body["series"]) == 2 + assert body["served"][0]["points"] == 2 diff --git a/tests/test_mcp_surface.py b/tests/test_mcp_surface.py index 8f2c21f..7c501c8 100644 --- a/tests/test_mcp_surface.py +++ b/tests/test_mcp_surface.py @@ -23,7 +23,8 @@ 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 findata.sources.cvm.cadastro import REGISTRO_URL, _registro_cache +from tests.test_cvm_fund_cadastro import _daily_zip, _registro_zip from tests.test_cvm_funds import _LISTING_HTML, _make_cda_zip EXPECTED_TOOLS = 25 # curated tools with code mode OFF (the default) @@ -194,7 +195,9 @@ def test_cvm_fund_holdings_defaults_to_latest_cda() -> None: @respx.mock def test_cvm_fund_daily_months_lookback() -> None: clear_cache() + _registro_cache.invalidate() payload = _daily_zip() + respx.get(REGISTRO_URL).mock(return_value=httpx.Response(200, content=_registro_zip())) respx.get(re.compile(r"https://.*inf_diario_fi_202607\.zip")).mock( return_value=httpx.Response(200, content=payload) ) @@ -212,4 +215,48 @@ def test_cvm_fund_daily_months_lookback() -> None: }, ) assert r.status_code == 200 - assert len(r.json()) == 4 + body = r.json() + assert len(body["series"]) == 4 + assert body["from"] == "202607" + assert body["to"] == "202608" + + +@respx.mock +def test_cvm_fund_daily_start_end_and_cnpj_required() -> None: + clear_cache() + _registro_cache.invalidate() + missing = TestClient(mcp_app).get("/cvm/fund", params={"dataset": "daily"}) + assert missing.status_code == 400 + payload = _daily_zip() + respx.get(REGISTRO_URL).mock(return_value=httpx.Response(200, content=_registro_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", + "start": "2026-07-01", + "end": "2026-08-31", + "compact": True, + }, + ) + assert r.status_code == 200 + body = r.json() + assert "series" not in body + assert body["served"][0]["points"] == 4 + assert body["served"][0]["nicename"].startswith("AMW") + too_long = TestClient(mcp_app).get( + "/cvm/fund", + params={ + "dataset": "daily", + "cnpj": "38729027000192", + "start": "2024-01-01", + "end": "2026-08-31", + }, + ) + assert too_long.status_code == 400 diff --git a/workers/mcp/package.json b/workers/mcp/package.json index c600eaf..25d49ef 100644 --- a/workers/mcp/package.json +++ b/workers/mcp/package.json @@ -1,7 +1,7 @@ { "name": "openfindata-mcp", "private": true, - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "dev": "wrangler dev", diff --git a/workers/mcp/src/index.ts b/workers/mcp/src/index.ts index b9f5915..3e5bc51 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.2", + version: "0.3.3", mcp: "/mcp", }); } diff --git a/workers/mcp/src/lib/http.ts b/workers/mcp/src/lib/http.ts index 7726a68..abdd285 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.2 (+https://github.com/robertoecf/OpenFinData)"; +const USER_AGENT = "openfindata-mcp/0.3.3 (+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/server.ts b/workers/mcp/src/server.ts index 6044421..fec1940 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.2", + version: "0.3.3", websiteUrl: "https://openfindata.com.br", }); @@ -141,14 +141,29 @@ export function createServer() { "cvm_fund", { description: - "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.", + "CVM open-fund official layer (never Mais Retorno; no Sharpe/CAGR/drawdown). " + + "Mais Retorno data mapping: search_assets/get_asset_info/list_fund_structure/get_fund_class_subclass → dataset=catalog " + + "(q matches fundo, classe and subclasse names; classes[].subclasses are different investments — do not pick a FIDC série). " + + "get_quotes → dataset=daily (INF_DIARIO; omit year/month for latest; months=1..12 lookback or start/end YYYY-MM-DD, max 12 months; " + + "single-class 555→175 continuation is stitched; report served[].nicename/class/subclass actually returned). " + + "get_available_wallets → dataset=periods product=CDA. get_wallet_detail → dataset=holdings (omit year/month for latest CDA; CONFID is sigilo).", inputSchema: { 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"), + cnpj: z.string().optional().describe("Fund or class CNPJ, punctuated or digits"), + q: z.string().optional().describe("catalog: name fragment (fundo, classe or subclasse)"), 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"), + months: z + .number() + .int() + .min(1) + .max(12) + .optional() + .describe("daily: lookback months including the end month (max 12; page longer windows)"), + start: z.string().optional().describe("daily: YYYY-MM-DD inclusive start (use with end)"), + end: z.string().optional().describe("daily: YYYY-MM-DD inclusive end (use with start)"), + id_subclasse: z.string().optional().describe("daily: keep only this CVM série; do not guess"), + compact: z.boolean().optional().describe("daily: served[].dates + served[].vl_quota instead of series[]"), product: z.enum(["CDA", "INF_DIARIO"]).optional().describe("periods: which directory to list"), blocks: z .string() diff --git a/workers/mcp/src/tools/cvm.test.ts b/workers/mcp/src/tools/cvm.test.ts index 452aeb2..05438cf 100644 --- a/workers/mcp/src/tools/cvm.test.ts +++ b/workers/mcp/src/tools/cvm.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { deflateRawSync } from "node:zlib"; import { afterEach, test } from "node:test"; -import { cvmFund, listCvmZipMonths } from "./cvm.ts"; +import { cvmFund, listCvmZipMonths, relatedQuoteCnpjs } from "./cvm.ts"; import { listZipEntryNames, scanZipCsvForNeedles, zipFile } from "../lib/zipCsv.ts"; function crc32(data: Uint8Array): number { @@ -325,6 +325,158 @@ test("cvm_fund daily months=2 concatenates two INF_DIARIO months", async () => { assert.equal(body.series[1]?.vl_quota, 2.94); }); +test("cvm_fund catalog matches a class-only name", async () => { + const classeOnly = + CLASSE_CSV + + "66089;99999;21494444000109;1;2024-12-02;2015-02-04;2024-12-02;Classes de Cotas de Fundos FIF;" + + "SERIE SENIOR UNICA XYZ;Em Funcionamento Normal;2024-12-02;Renda Fixa;DI;N;Previdência RF;;;N;N;Aberto;N;Público;1;2026-08-26;1;AUD;2;CUST;3;CTRL\n"; + mockZip( + "registro_fundo_classe.zip", + storeZip({ + "registro_fundo.csv": FUNDO_CSV, + "registro_classe.csv": classeOnly, + "registro_subclasse.csv": SUB_CSV, + }), + ); + const result = await cvmFund({ dataset: "catalog", q: "serie senior unica xyz" }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as Array<{ cnpj: string }>; + assert.equal(body.length, 1); + assert.equal(body[0]?.cnpj, "38729027000192"); +}); + +test("relatedQuoteCnpjs stitches only a single-class sibling CNPJ", () => { + const single = relatedQuoteCnpjs( + [ + { + cnpj: "11111111000191", + nome: "FUNDO", + classes: [ + { + cnpj_classe: "22222222000191", + nome: "CLASSE", + tipo_classe: "FIF", + classificacao: "Multimercado", + classe_anbima: "Livre", + subclasses: [], + }, + ], + }, + ], + "22222222000191", + ); + assert.deepEqual(single, ["22222222000191", "11111111000191"]); + const multi = relatedQuoteCnpjs( + [ + { + cnpj: "11111111000191", + nome: "FUNDO", + classes: [ + { + cnpj_classe: "22222222000191", + nome: "A", + tipo_classe: "FIF", + classificacao: "RF", + classe_anbima: "", + subclasses: [], + }, + { + cnpj_classe: "33333333000191", + nome: "B", + tipo_classe: "FIF", + classificacao: "RF", + classe_anbima: "", + subclasses: [], + }, + ], + }, + ], + "22222222000191", + ); + assert.deepEqual(multi, ["22222222000191"]); +}); + +test("cvm_fund daily start/end stitches 555 fund CNPJ onto a single class", async () => { + const fundo = + "ID_Registro_Fundo;CNPJ_Fundo;Codigo_CVM;Denominacao_Social;Situacao;Data_Adaptacao_RCVM175\n" + + "1;11111111000191;1;FUNDO LEGADO;Em Funcionamento Normal;2024-09-01\n"; + const classe = + "ID_Registro_Fundo;ID_Registro_Classe;CNPJ_Classe;Codigo_CVM;Denominacao_Social;Situacao;Classificacao;Classificacao_Anbima;Tipo_Classe;Forma_Condominio\n" + + "1;10;22222222000191;2;CLASSE CONTINUACAO;Em Funcionamento Normal;Multimercado;Livre;FIF;Aberto\n"; + const oldDaily = + "TP_FUNDO;CNPJ_FUNDO;DT_COMPTC;VL_TOTAL;VL_QUOTA;VL_PATRIM_LIQ;CAPTC_DIA;RESG_DIA;NR_COTST\n" + + "FI;11.111.111/0001-91;2024-08-30;1;1.10;100;0;0;1\n"; + const newDaily = + "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;22.222.222/0001-91;;2024-09-02;1;1.20;110;0;0;1\n"; + mockRoutes([ + { + match: "registro_fundo_classe.zip", + body: storeZip({ + "registro_fundo.csv": fundo, + "registro_classe.csv": classe, + "registro_subclasse.csv": SUB_CSV, + }), + }, + { match: "inf_diario_fi_202408.zip", body: storeZip({ "inf_diario_fi_202408.csv": oldDaily }) }, + { match: "inf_diario_fi_202409.zip", body: storeZip({ "inf_diario_fi_202409.csv": newDaily }) }, + ]); + const result = await cvmFund({ + dataset: "daily", + cnpj: "22222222000191", + start: "2024-08-01", + end: "2024-09-30", + }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { + from: string; + to: string; + needles: string[]; + served: Array<{ nicename: string; points: number }>; + series: Array<{ vl_quota: number }>; + }; + assert.equal(body.from, "202408"); + assert.equal(body.to, "202409"); + assert.deepEqual(body.needles, ["22222222000191", "11111111000191"]); + assert.equal(body.series.length, 2); + assert.equal(body.served.length, 1); + assert.equal(body.served[0]?.nicename, "CLASSE CONTINUACAO"); + assert.equal(body.served[0]?.points, 2); +}); + +test("cvm_fund daily does not collapse two subclasses", async () => { + const daily = + "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;S1;2026-08-03;1;1.00;100;0;0;1\n" + + "CLASSES - FIF;38.729.027/0001-92;S2;2026-08-03;1;2.00;200;0;0;10\n"; + mockRoutes([ + { + match: "registro_fundo_classe.zip", + body: storeZip({ + "registro_fundo.csv": FUNDO_CSV, + "registro_classe.csv": CLASSE_CSV, + "registro_subclasse.csv": + SUB_CSV + + "12189;S1;1;2024-09-27;2024-09-27;SENIOR;Em Funcionamento Normal;2024-09-27;Aberto;N;Público;N;N;N\n" + + "12189;S2;2;2024-09-27;2024-09-27;SUBORDINADA;Em Funcionamento Normal;2024-09-27;Aberto;N;Público;N;N;N\n", + }), + }, + { match: "inf_diario_fi_202608.zip", body: storeZip({ "inf_diario_fi_202608.csv": daily }) }, + ]); + const result = await cvmFund({ dataset: "daily", cnpj: "38729027000192", year: 2026, month: 8 }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { + pick_required: boolean; + served: Array<{ id_subclasse: string; nicename: string }>; + }; + assert.equal(body.pick_required, true); + assert.deepEqual( + body.served.map((row) => row.id_subclasse).sort(), + ["S1", "S2"], + ); + assert.equal(body.served.find((row) => row.id_subclasse === "S2")?.nicename, "SUBORDINADA"); +}); + test("cvm_fund daily without year/month uses the latest INF_DIARIO stamp", async () => { mockRoutes([ { match: "/INF_DIARIO/DADOS/", body: INF_HTML }, diff --git a/workers/mcp/src/tools/cvm.ts b/workers/mcp/src/tools/cvm.ts index 645c1b2..0cb6036 100644 --- a/workers/mcp/src/tools/cvm.ts +++ b/workers/mcp/src/tools/cvm.ts @@ -19,7 +19,7 @@ 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; +const DAILY_MONTHS_MAX = 12; export type CvmDataset = "catalog" | "daily" | "holdings" | "periods"; export type CvmPeriodProduct = "CDA" | "INF_DIARIO"; @@ -150,14 +150,11 @@ async function catalogByCnpj(zip: Uint8Array, digits: string, limit: number) { return fundos.slice(0, limit).map((row) => mapFundo(row, classesByFundo.get(row.ID_Registro_Fundo ?? "") ?? [])); } -async function catalogByName(zip: Uint8Array, q: string, limit: number) { - const needle = q.toLowerCase(); - const fundos = await scanZipCsv( - zip, - "registro_fundo.csv", - (row) => (row.Denominacao_Social ?? "").toLowerCase().includes(needle), - limit, - ); +async function assembleCatalog( + zip: Uint8Array, + fundos: Record[], + limit: number, +) { if (fundos.length === 0) { return []; } @@ -189,7 +186,40 @@ async function catalogByName(zip: Uint8Array, q: string, limit: number) { list.push(mapClasse(row, subclassesByClasse.get(row.ID_Registro_Classe ?? "") ?? [])); classesByFundo.set(key, list); } - return fundos.map((row) => mapFundo(row, classesByFundo.get(row.ID_Registro_Fundo ?? "") ?? [])); + return fundos.slice(0, limit).map((row) => mapFundo(row, classesByFundo.get(row.ID_Registro_Fundo ?? "") ?? [])); +} + +async function catalogByName(zip: Uint8Array, q: string, limit: number) { + const needle = q.toLowerCase(); + const nameHit = (row: Record) => + (row.Denominacao_Social ?? "").toLowerCase().includes(needle); + const fundosByName = await scanZipCsv(zip, "registro_fundo.csv", nameHit, limit); + const classesByName = await scanZipCsv(zip, "registro_classe.csv", nameHit, CATALOG_CLASS_CAP); + const subsByName = await scanZipCsv(zip, "registro_subclasse.csv", nameHit, CATALOG_CLASS_CAP); + const extraFundoIds = new Set(classesByName.map((row) => row.ID_Registro_Fundo ?? "")); + const subClassIds = new Set(subsByName.map((row) => row.ID_Registro_Classe ?? "")); + if (subClassIds.size > 0) { + const parentClasses = await scanZipCsv( + zip, + "registro_classe.csv", + (row) => subClassIds.has(row.ID_Registro_Classe ?? ""), + CATALOG_CLASS_CAP, + ); + for (const row of parentClasses) { + extraFundoIds.add(row.ID_Registro_Fundo ?? ""); + } + } + const already = new Set(fundosByName.map((row) => row.ID_Registro_Fundo ?? "")); + const extraFundos = + extraFundoIds.size === 0 + ? [] + : await scanZipCsv( + zip, + "registro_fundo.csv", + (row) => extraFundoIds.has(row.ID_Registro_Fundo ?? "") && !already.has(row.ID_Registro_Fundo ?? ""), + limit, + ); + return assembleCatalog(zip, [...fundosByName, ...extraFundos], limit); } function formatYm(year: number, month: number): string { @@ -213,6 +243,121 @@ function lookbackMonths( return out; } +type CatalogClasse = { + cnpj_classe: string; + nome: string; + tipo_classe: string; + classificacao: string; + classe_anbima: string; + subclasses: Array<{ id_subclasse: string; nome: string }>; +}; + +type CatalogFund = { + cnpj: string; + nome: string; + classes: CatalogClasse[]; +}; + +export function relatedQuoteCnpjs(funds: CatalogFund[], requestedDigits: string): string[] { + const requested = cnpjDigits(requestedDigits); + const out: string[] = []; + const seen = new Set(); + const add = (raw: string) => { + const digits = cnpjDigits(raw); + if (digits && !seen.has(digits)) { + seen.add(digits); + out.push(digits); + } + }; + add(requested); + for (const fund of funds) { + const fundDigits = cnpjDigits(fund.cnpj); + const classDigits = [ + ...new Set(fund.classes.map((classe) => cnpjDigits(classe.cnpj_classe)).filter(Boolean)), + ]; + const matchedFund = fundDigits === requested; + const matchedClass = fund.classes.some((classe) => cnpjDigits(classe.cnpj_classe) === requested); + if (matchedFund) { + for (const digits of classDigits) { + add(digits); + } + } else if (matchedClass && classDigits.length === 1) { + add(fundDigits); + } + } + return out; +} + +function quoteServedLabel(funds: CatalogFund[], cnpj: string, idSubclasse: string) { + const digits = cnpjDigits(cnpj); + let nicename = ""; + let tipoClasse = ""; + let classificacao = ""; + let classeAnbima = ""; + let subclassName = ""; + for (const fund of funds) { + if (cnpjDigits(fund.cnpj) === digits && !nicename) { + nicename = fund.nome; + } + for (const classe of fund.classes) { + if (cnpjDigits(classe.cnpj_classe) !== digits) { + continue; + } + nicename = classe.nome || nicename; + tipoClasse = classe.tipo_classe; + classificacao = classe.classificacao; + classeAnbima = classe.classe_anbima; + for (const sub of classe.subclasses) { + if (idSubclasse && sub.id_subclasse === idSubclasse) { + subclassName = sub.nome; + if (sub.nome) { + nicename = sub.nome; + } + } + } + } + } + return { + nicename, + tipo_classe: tipoClasse, + classificacao, + classe_anbima: classeAnbima, + subclass_name: subclassName, + }; +} + +function parseIsoMonth(value: string): { year: number; month: number } | { error: string } { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return { error: "start/end must be YYYY-MM-DD" }; + } + const year = Number(match[1]); + const month = Number(match[2]); + if (month < 1 || month > 12) { + return { error: "start/end month must be 1-12" }; + } + return { year, month }; +} + +function stampsInclusive( + start: { year: number; month: number }, + end: { year: number; month: number }, +): { year: number; month: number }[] | { error: string } { + if (start.year > end.year || (start.year === end.year && start.month > end.month)) { + return { error: "`start` must be on or before `end`" }; + } + const out: { year: number; month: number }[] = []; + let cursor = start; + while (cursor.year < end.year || (cursor.year === end.year && cursor.month <= end.month)) { + out.push(cursor); + if (out.length > DAILY_MONTHS_MAX) { + return { error: `daily window exceeds ${DAILY_MONTHS_MAX} months; page start/end` }; + } + cursor = addMonths(cursor.year, cursor.month, 1); + } + return out; +} + export function listCvmZipMonths(html: string, prefix: string): string[] { const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`${escaped}(\\d{6})\\.zip`, "gi"); @@ -318,7 +463,7 @@ function parseBlocks(raw: string | undefined): Set | null { } async function dailySeries( - digits: string, + digitsList: string[], year: number, month: number, limit: number, @@ -326,12 +471,71 @@ async function dailySeries( const ym = formatYm(year, month); const zip = await fetchCvmZip(DAILY_URL.replace("{ym}", ym)); const csvName = `inf_diario_fi_${ym}.csv`; + const needles = digitsList.flatMap((digits) => cnpjNeedles(digits)); + const allowed = new Set(digitsList); const rows = ( - await scanZipCsvForNeedles(zip, csvName, cnpjNeedles(digits), Math.min(limit, DAILY_SCAN_CAP)) - ).filter((row) => fieldCnpjEquals(row, ["CNPJ_FUNDO_CLASSE", "CNPJ_FUNDO"], digits)); + await scanZipCsvForNeedles(zip, csvName, needles, Math.min(limit, DAILY_SCAN_CAP)) + ).filter((row) => + ["CNPJ_FUNDO_CLASSE", "CNPJ_FUNDO"].some((field) => allowed.has(cnpjDigits(row[field]))), + ); return rows.map(mapDaily); } +function continuationTarget(cadastro: CatalogFund[], requested: string, needles: string[]): string | null { + return cadastro.length > 0 && needles.length === 2 ? requested : null; +} + +function groupDailySeries( + series: ReturnType[], + cadastro: CatalogFund[], + compact: boolean, + requested: string, + needles: string[], +) { + const target = continuationTarget(cadastro, requested, needles); + const groups = new Map[]>(); + for (const row of series) { + const rowDigits = cnpjDigits(row.cnpj); + const groupCnpj = target && needles.includes(rowDigits) ? target : rowDigits; + const key = `${groupCnpj}|${row.id_subclasse}`; + const list = groups.get(key) ?? []; + list.push(row); + groups.set(key, list); + } + const served = [...groups.entries()].map(([key, rows]) => { + const [cnpj, idSubclasse] = key.split("|") as [string, string]; + const label = quoteServedLabel(cadastro, cnpj, idSubclasse); + const item: Record = { + cnpj, + id_subclasse: idSubclasse, + ...label, + points: rows.length, + }; + if (compact) { + item.dates = rows.map((row) => row.dt_comptc); + item.vl_quota = rows.map((row) => row.vl_quota); + } + return item; + }); + const pickRequired = served.length > 1; + return { + pick_required: pickRequired, + served, + note: pickRequired + ? "Multiple INF_DIARIO series for this CNPJ (classes/subclasses). They are different investments — pass id_subclasse or the class CNPJ; do not pick." + : "Report served[].nicename / class / subclass actually returned.", + }; +} + +async function loadCatalogBestEffort(digits: string): Promise { + try { + const zip = await fetchCvmZip(REGISTRO_URL); + return (await catalogByCnpj(zip, digits, 20)) as CatalogFund[]; + } catch { + return []; + } +} + async function holdingsFromZip( zip: Uint8Array, digits: string, @@ -368,6 +572,10 @@ export async function cvmFund(args: { year?: number; month?: number; months?: number; + start?: string; + end?: string; + id_subclasse?: string; + compact?: boolean; product?: CvmPeriodProduct; blocks?: string; limit?: number; @@ -429,18 +637,40 @@ export async function cvmFund(args: { } 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); + let window: { year: number; month: number }[]; + if (args.start || args.end) { + if (!args.start || !args.end) { + return errorResult("pass both `start` and `end`, or omit both"); + } + const start = parseIsoMonth(args.start); + const end = parseIsoMonth(args.end); + if ("error" in start) { + return errorResult(start.error); + } + if ("error" in end) { + return errorResult(end.error); + } + const ranged = stampsInclusive(start, end); + if ("error" in ranged) { + return errorResult(ranged.error); + } + window = ranged; + } else { + const resolved = await resolveYearMonth(args, DAILY_LISTING_URL, "inf_diario_fi_"); + if ("error" in resolved) { + return errorResult(resolved.error); + } + window = lookbackMonths(resolved.year, resolved.month, months); } - const window = lookbackMonths(resolved.year, resolved.month, months); + const cadastro = await loadCatalogBestEffort(digits); + const needles = relatedQuoteCnpjs(cadastro, digits); 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); + const chunk = await dailySeries(needles, stamp.year, stamp.month, remaining); series.push(...chunk); remaining = Math.max(limit - series.length, 0); if (remaining === 0) { @@ -454,15 +684,22 @@ export async function cvmFund(args: { throw error; } } + const filtered = args.id_subclasse + ? series.filter((row) => row.id_subclasse === args.id_subclasse) + : series; + const grouped = groupDailySeries(filtered, cadastro, Boolean(args.compact), digits, needles); + const last = window.at(-1); return jsonResult({ source: "cvm_inf_diario", - year: resolved.year, - month: resolved.month, - months, - from: formatYm(window[0]!.year, window[0]!.month), - to: formatYm(resolved.year, resolved.month), + year: last?.year, + month: last?.month, + months: window.length, + from: window[0] ? formatYm(window[0].year, window[0].month) : null, + to: last ? formatYm(last.year, last.month) : null, cnpj: digits, + needles, missing, - series, + ...grouped, + ...(args.compact ? {} : { series: filtered }), }); } From d8727df2f8e7d719da5bb1b6085584476d7f6302 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 21:13:58 +0000 Subject: [PATCH 2/2] fix: filter daily quotes by calendar day, subclass, and stitch date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start/end now keep only dt_comptc in the requested inclusive range and reject impossible dates. id_subclasse is applied before the point limit. Single-class 555→175 stitch uses the class CNPJ as identity and drops duplicate dates, keeping the continuation class row. --- src/findata/api/mcp_app.py | 75 +++++++++++++++------------ src/findata/sources/cvm/__init__.py | 2 + src/findata/sources/cvm/cadastro.py | 17 ++++++ tests/test_mcp_surface.py | 26 +++++++++- workers/mcp/package-lock.json | 4 +- workers/mcp/src/tools/cvm.test.ts | 70 +++++++++++++++++++++++++ workers/mcp/src/tools/cvm.ts | 80 +++++++++++++++++++++++------ 7 files changed, 222 insertions(+), 52 deletions(-) diff --git a/src/findata/api/mcp_app.py b/src/findata/api/mcp_app.py index bb02e8f..30e35b9 100644 --- a/src/findata/api/mcp_app.py +++ b/src/findata/api/mcp_app.py @@ -50,6 +50,7 @@ from findata.sources.bcb import focus, ptax, sgs from findata.sources.cvm import ( companies, + continuation_class_cnpj, fca, fidc, fii, @@ -150,6 +151,26 @@ async def _daily_month_stamps( return _lookback_months(resolved_year, resolved_month, months) +def _row_field(row: Any, name: str, default: Any = "") -> Any: + return getattr(row, name, default) if not isinstance(row, dict) else row.get(name, default) + + +def _dedupe_daily_rows(series: list[Any], prefer_cnpj: str) -> list[Any]: + chosen: dict[tuple[str, str], Any] = {} + for row in series: + key = (str(_row_field(row, "dt_comptc")), str(_row_field(row, "id_subclasse"))) + prev = chosen.get(key) + if prev is None or cnpj_digits(str(_row_field(row, "cnpj"))) == prefer_cnpj: + chosen[key] = row + return [chosen[key] for key in sorted(chosen)] + + +def _in_date_range(dt_comptc: str, start: str | None, end: str | None) -> bool: + if start and dt_comptc < start: + return False + return not (end and dt_comptc > end) + + def _group_daily_series( series: list[Any], cadastro: list[Any], @@ -157,19 +178,15 @@ def _group_daily_series( requested: str, needles: list[str], ) -> dict[str, Any]: - target = requested if cadastro and len(needles) == _STITCH_SIBLING_CNPJS else "" + target = continuation_class_cnpj(cadastro, requested) or "" + if not target and cadastro and len(needles) == _STITCH_SIBLING_CNPJS: + target = requested groups: dict[tuple[str, str], list[Any]] = {} for row in series: - cnpj = getattr(row, "cnpj", "") if not isinstance(row, dict) else row.get("cnpj", "") - sub = ( - getattr(row, "id_subclasse", "") - if not isinstance(row, dict) - else row.get("id_subclasse", "") - ) - row_digits = cnpj_digits(str(cnpj)) + row_digits = cnpj_digits(str(_row_field(row, "cnpj"))) + sub = str(_row_field(row, "id_subclasse")) group_cnpj = target if target and row_digits in needles else row_digits - key = (group_cnpj, str(sub or "")) - groups.setdefault(key, []).append(row) + groups.setdefault((group_cnpj, sub), []).append(row) served: list[dict[str, Any]] = [] for (cnpj, sub), rows in groups.items(): label = quote_served_label(cadastro, cnpj, sub) @@ -180,16 +197,8 @@ def _group_daily_series( "points": len(rows), } if compact: - item["dates"] = [ - getattr(row, "dt_comptc", "") - if not isinstance(row, dict) - else row.get("dt_comptc", "") - for row in rows - ] - item["vl_quota"] = [ - getattr(row, "vl_quota", 0) if not isinstance(row, dict) else row.get("vl_quota", 0) - for row in rows - ] + item["dates"] = [str(_row_field(row, "dt_comptc")) for row in rows] + item["vl_quota"] = [_row_field(row, "vl_quota", 0) for row in rows] served.append(item) pick_required = len(served) > 1 note = ( @@ -226,8 +235,10 @@ async def _cvm_fund_quotes( except httpx.HTTPError: cadastro = [] needles = related_quote_cnpjs(cadastro, cnpj) if cadastro else [cnpj] + prefer = continuation_class_cnpj(cadastro, cnpj) or cnpj_digits(cnpj) series: list[Any] = [] missing: list[str] = [] + truncated = False for stamp_year, stamp_month in stamps: try: chunk = await funds.get_fund_daily(stamp_year, stamp_month, ",".join(needles)) @@ -236,30 +247,30 @@ async def _cvm_fund_quotes( missing.append(f"{stamp_year}{stamp_month:02d}") continue raise - series.extend(chunk) + kept = [ + row + for row in chunk + if (not id_subclasse or str(_row_field(row, "id_subclasse")) == id_subclasse) + and _in_date_range(str(_row_field(row, "dt_comptc")), start, end) + ] + series.extend(kept) if len(series) >= limit: series = series[:limit] + truncated = True break - if id_subclasse: - series = [ - row - for row in series - if ( - getattr(row, "id_subclasse", "") - if not isinstance(row, dict) - else row.get("id_subclasse", "") - ) - == id_subclasse - ] + series = _dedupe_daily_rows(series, prefer) payload = _group_daily_series(series, cadastro, compact, cnpj_digits(cnpj), needles) payload.update( { "cnpj": cnpj_digits(cnpj), "from": f"{stamps[0][0]}{stamps[0][1]:02d}" if stamps else None, "to": f"{stamps[-1][0]}{stamps[-1][1]:02d}" if stamps else None, + "start": start, + "end": end, "months": len(stamps), "needles": needles, "missing": missing, + "truncated": truncated, } ) return payload diff --git a/src/findata/sources/cvm/__init__.py b/src/findata/sources/cvm/__init__.py index 137efef..968221a 100644 --- a/src/findata/sources/cvm/__init__.py +++ b/src/findata/sources/cvm/__init__.py @@ -35,6 +35,7 @@ FundCadastro, FundClasse, FundSubclass, + continuation_class_cnpj, get_fund_cadastro, quote_served_label, related_quote_cnpjs, @@ -106,6 +107,7 @@ "FundSubclass", "IPEDocument", "StatementType", + "continuation_class_cnpj", "get_companies", "get_dfp", "get_fca_dri", diff --git a/src/findata/sources/cvm/cadastro.py b/src/findata/sources/cvm/cadastro.py index 298376a..cc229c6 100644 --- a/src/findata/sources/cvm/cadastro.py +++ b/src/findata/sources/cvm/cadastro.py @@ -309,6 +309,23 @@ def add(raw: str) -> None: return out +def continuation_class_cnpj(funds: list[FundCadastro], requested_digits: str) -> str | None: + """Canonical class CNPJ for a single-class 555→175 stitch, else None.""" + requested = cnpj_digits(requested_digits) + for fund in funds: + class_digits = list( + dict.fromkeys( + cnpj_digits(classe.cnpj_classe) + for classe in fund.classes + if cnpj_digits(classe.cnpj_classe) + ) + ) + matched = requested == cnpj_digits(fund.cnpj) or requested in class_digits + if matched and len(class_digits) == 1: + return class_digits[0] + return None + + def quote_served_label(funds: list[FundCadastro], cnpj: str, id_subclasse: str) -> dict[str, str]: """Nicename / CVM / ANBIMA class for the series actually returned.""" digits = cnpj_digits(cnpj) diff --git a/tests/test_mcp_surface.py b/tests/test_mcp_surface.py index 7c501c8..897577b 100644 --- a/tests/test_mcp_surface.py +++ b/tests/test_mcp_surface.py @@ -216,7 +216,8 @@ def test_cvm_fund_daily_months_lookback() -> None: ) assert r.status_code == 200 body = r.json() - assert len(body["series"]) == 4 + assert len(body["series"]) == 2 + assert {row["dt_comptc"] for row in body["series"]} == {"2026-08-03", "2026-08-04"} assert body["from"] == "202607" assert body["to"] == "202608" @@ -248,8 +249,29 @@ def test_cvm_fund_daily_start_end_and_cnpj_required() -> None: assert r.status_code == 200 body = r.json() assert "series" not in body - assert body["served"][0]["points"] == 4 + assert body["served"][0]["points"] == 2 assert body["served"][0]["nicename"].startswith("AMW") + one_day = TestClient(mcp_app).get( + "/cvm/fund", + params={ + "dataset": "daily", + "cnpj": "38729027000192", + "start": "2026-08-04", + "end": "2026-08-04", + }, + ) + assert one_day.status_code == 200 + assert [row["dt_comptc"] for row in one_day.json()["series"]] == ["2026-08-04"] + bad_day = TestClient(mcp_app).get( + "/cvm/fund", + params={ + "dataset": "daily", + "cnpj": "38729027000192", + "start": "2026-02-31", + "end": "2026-03-01", + }, + ) + assert bad_day.status_code == 400 too_long = TestClient(mcp_app).get( "/cvm/fund", params={ diff --git a/workers/mcp/package-lock.json b/workers/mcp/package-lock.json index 38e995c..7ef8478 100644 --- a/workers/mcp/package-lock.json +++ b/workers/mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "openfindata-mcp", - "version": "0.3.1", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openfindata-mcp", - "version": "0.3.1", + "version": "0.3.3", "dependencies": { "@cloudflare/workers-types": "^5.20260813.1", "@modelcontextprotocol/server": "^2.0.0", diff --git a/workers/mcp/src/tools/cvm.test.ts b/workers/mcp/src/tools/cvm.test.ts index 05438cf..d59733f 100644 --- a/workers/mcp/src/tools/cvm.test.ts +++ b/workers/mcp/src/tools/cvm.test.ts @@ -444,6 +444,76 @@ test("cvm_fund daily start/end stitches 555 fund CNPJ onto a single class", asyn assert.equal(body.served[0]?.points, 2); }); +test("cvm_fund daily start/end keeps only the requested calendar days", async () => { + mockRoutes([ + { + match: "registro_fundo_classe.zip", + body: storeZip({ + "registro_fundo.csv": FUNDO_CSV, + "registro_classe.csv": CLASSE_CSV, + "registro_subclasse.csv": SUB_CSV, + }), + }, + { match: "inf_diario_fi_202608.zip", body: storeZip({ "inf_diario_fi_202608.csv": DAILY_CSV }) }, + ]); + const result = await cvmFund({ + dataset: "daily", + cnpj: "38729027000192", + start: "2026-08-03", + end: "2026-08-03", + }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { series: Array<{ dt_comptc: string }> }; + assert.deepEqual( + body.series.map((row) => row.dt_comptc), + ["2026-08-03"], + ); +}); + +test("cvm_fund daily rejects an impossible calendar date", async () => { + const result = await cvmFund({ + dataset: "daily", + cnpj: "38729027000192", + start: "2026-02-31", + end: "2026-03-01", + }); + assert.equal(result.isError, true); +}); + +test("cvm_fund daily applies id_subclasse before the point limit", async () => { + const daily = + "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;S1;2026-08-03;1;1.00;100;0;0;1\n" + + "CLASSES - FIF;38.729.027/0001-92;S1;2026-08-04;1;1.01;101;0;0;1\n" + + "CLASSES - FIF;38.729.027/0001-92;S2;2026-08-03;1;2.00;200;0;0;10\n"; + mockRoutes([ + { + match: "registro_fundo_classe.zip", + body: storeZip({ + "registro_fundo.csv": FUNDO_CSV, + "registro_classe.csv": CLASSE_CSV, + "registro_subclasse.csv": SUB_CSV, + }), + }, + { match: "inf_diario_fi_202608.zip", body: storeZip({ "inf_diario_fi_202608.csv": daily }) }, + ]); + const result = await cvmFund({ + dataset: "daily", + cnpj: "38729027000192", + year: 2026, + month: 8, + id_subclasse: "S2", + limit: 1, + }); + assert.equal(result.isError, undefined); + const body = JSON.parse(result.content[0].text) as { + series: Array<{ id_subclasse: string; vl_quota: number }>; + }; + assert.equal(body.series.length, 1); + assert.equal(body.series[0]?.id_subclasse, "S2"); + assert.equal(body.series[0]?.vl_quota, 2); +}); + test("cvm_fund daily does not collapse two subclasses", async () => { const daily = "TP_FUNDO_CLASSE;CNPJ_FUNDO_CLASSE;ID_SUBCLASSE;DT_COMPTC;VL_TOTAL;VL_QUOTA;VL_PATRIM_LIQ;CAPTC_DIA;RESG_DIA;NR_COTST\n" + diff --git a/workers/mcp/src/tools/cvm.ts b/workers/mcp/src/tools/cvm.ts index 0cb6036..032c06f 100644 --- a/workers/mcp/src/tools/cvm.ts +++ b/workers/mcp/src/tools/cvm.ts @@ -258,6 +258,20 @@ type CatalogFund = { classes: CatalogClasse[]; }; +export function continuationClassCnpj(funds: CatalogFund[], requestedDigits: string): string | null { + const requested = cnpjDigits(requestedDigits); + for (const fund of funds) { + const classDigits = [ + ...new Set(fund.classes.map((classe) => cnpjDigits(classe.cnpj_classe)).filter(Boolean)), + ]; + const matched = requested === cnpjDigits(fund.cnpj) || classDigits.includes(requested); + if (matched && classDigits.length === 1) { + return classDigits[0] ?? null; + } + } + return null; +} + export function relatedQuoteCnpjs(funds: CatalogFund[], requestedDigits: string): string[] { const requested = cnpjDigits(requestedDigits); const out: string[] = []; @@ -326,17 +340,47 @@ function quoteServedLabel(funds: CatalogFund[], cnpj: string, idSubclasse: strin }; } -function parseIsoMonth(value: string): { year: number; month: number } | { error: string } { +function parseIsoDate(value: string): { year: number; month: number; day: string } | { error: string } { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) { return { error: "start/end must be YYYY-MM-DD" }; } const year = Number(match[1]); const month = Number(match[2]); - if (month < 1 || month > 12) { - return { error: "start/end month must be 1-12" }; + const day = Number(match[3]); + const parsed = new Date(Date.UTC(year, month - 1, day)); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) { + return { error: "start/end must be a real calendar date" }; + } + return { year, month, day: value }; +} + +function inDateRange(dtComptc: string, start: string | undefined, end: string | undefined): boolean { + if (start && dtComptc < start) { + return false; } - return { year, month }; + return !(end && dtComptc > end); +} + +function dedupeDailyRows( + series: ReturnType[], + preferCnpj: string, +): ReturnType[] { + const chosen = new Map>(); + for (const row of series) { + const key = `${row.dt_comptc}|${row.id_subclasse}`; + const prev = chosen.get(key); + if (!prev || cnpjDigits(row.cnpj) === preferCnpj) { + chosen.set(key, row); + } + } + return [...chosen.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, row]) => row); } function stampsInclusive( @@ -481,10 +525,6 @@ async function dailySeries( return rows.map(mapDaily); } -function continuationTarget(cadastro: CatalogFund[], requested: string, needles: string[]): string | null { - return cadastro.length > 0 && needles.length === 2 ? requested : null; -} - function groupDailySeries( series: ReturnType[], cadastro: CatalogFund[], @@ -492,7 +532,7 @@ function groupDailySeries( requested: string, needles: string[], ) { - const target = continuationTarget(cadastro, requested, needles); + const target = continuationClassCnpj(cadastro, requested); const groups = new Map[]>(); for (const row of series) { const rowDigits = cnpjDigits(row.cnpj); @@ -642,8 +682,8 @@ export async function cvmFund(args: { if (!args.start || !args.end) { return errorResult("pass both `start` and `end`, or omit both"); } - const start = parseIsoMonth(args.start); - const end = parseIsoMonth(args.end); + const start = parseIsoDate(args.start); + const end = parseIsoDate(args.end); if ("error" in start) { return errorResult(start.error); } @@ -664,14 +704,21 @@ export async function cvmFund(args: { } const cadastro = await loadCatalogBestEffort(digits); const needles = relatedQuoteCnpjs(cadastro, digits); + const prefer = continuationClassCnpj(cadastro, digits) ?? digits; + const dateStart = args.start && args.end ? args.start : undefined; + const dateEnd = args.start && args.end ? args.end : undefined; 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(needles, stamp.year, stamp.month, remaining); - series.push(...chunk); + const chunk = (await dailySeries(needles, stamp.year, stamp.month, DAILY_SCAN_CAP)).filter( + (row) => + (!args.id_subclasse || row.id_subclasse === args.id_subclasse) && + inDateRange(row.dt_comptc, dateStart, dateEnd), + ); + series.push(...chunk.slice(0, remaining)); remaining = Math.max(limit - series.length, 0); if (remaining === 0) { break; @@ -684,9 +731,7 @@ export async function cvmFund(args: { throw error; } } - const filtered = args.id_subclasse - ? series.filter((row) => row.id_subclasse === args.id_subclasse) - : series; + const filtered = dedupeDailyRows(series, prefer); const grouped = groupDailySeries(filtered, cadastro, Boolean(args.compact), digits, needles); const last = window.at(-1); return jsonResult({ @@ -696,9 +741,12 @@ export async function cvmFund(args: { months: window.length, from: window[0] ? formatYm(window[0].year, window[0].month) : null, to: last ? formatYm(last.year, last.month) : null, + start: dateStart ?? null, + end: dateEnd ?? null, cnpj: digits, needles, missing, + truncated: remaining === 0, ...grouped, ...(args.compact ? {} : { series: filtered }), });