From 3d8e0fc6b092b356bec69a81b17f17d1fcacbb1a Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 16 Sep 2026 21:03:31 -0300 Subject: [PATCH 1/3] feat: add official B3 listed-funds catalog Unknown *11 tickers were guessed as FII. The official fundsListedProxy JSON (not HTML) splits ETF / ETF-RF / FII / FI-Infra so SPBZ11 and SPXR11 classify as equity ETFs. --- CHANGELOG.md | 9 + ROADMAP.md | 3 +- docs/MCP_SURFACE.md | 4 +- docs/RESOLVER.md | 20 +- docs/SOURCES_AND_ENDPOINTS.md | 7 +- docs/source-notes/b3-listed-funds.md | 62 +++++ src/findata/api/mcp_app.py | 14 +- src/findata/api/routers/b3.py | 38 ++- src/findata/api/routers/resolver.py | 13 +- src/findata/cli.py | 78 ++++++ src/findata/resolver/__init__.py | 2 + src/findata/resolver/b3_catalog.py | 250 ++++++++++++++++++ src/findata/resolver/engine.py | 11 +- src/findata/resolver/seed.py | 11 +- src/findata/sources/b3/__init__.py | 19 +- src/findata/sources/b3/listed_funds.py | 232 +++++++++++++++++ tests/test_b3_listed_funds.py | 341 +++++++++++++++++++++++++ tests/test_integration.py | 11 + tests/test_mcp_surface.py | 2 +- 19 files changed, 1096 insertions(+), 31 deletions(-) create mode 100644 docs/source-notes/b3-listed-funds.md create mode 100644 src/findata/resolver/b3_catalog.py create mode 100644 src/findata/sources/b3/listed_funds.py create mode 100644 tests/test_b3_listed_funds.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d293b34..b87f37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ adheres to [Semantic Versioning](https://semver.org/). ### Added +- **Official B3 listed-funds catalog.** `fundsListedProxy/Search/GetListFunds` + (same base64-JSON family as `indexProxy`, not an HTML scrape) lists ETF, + ETF-RF, ETF-FII, ETF-CRIPTO, FII, FI-Infra and related B3 pages. + `GET /b3/listed-funds`, `findata b3 listed`, and `findata resolve` expose it. + REST/MCP `resolve_asset` injects the catalog for unknown `*11` tickers so + `SPBZ11`/`SPXR11` classify as ETF de renda variável instead of the suffix-11 + FII heuristic. The library `resolve_asset()` stays offline unless the caller + passes `providers=[b3_listed_provider]`. `IFRA11` is `FI-INFRA` on B3, not + `ETF-RF`. Source note: `docs/source-notes/b3-listed-funds.md`. - **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, diff --git a/ROADMAP.md b/ROADMAP.md index 6f1ee16..40dd7c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,7 +62,8 @@ Status: **v0.3.1 — alpha.** CI live at [`.github/workflows/ci.yml`](.github/wo - **Redis cache** — drop-in replacement for the in-memory LRU for multi-replica deploys. - **ANBIMA indexes** — IMA, IMA-B, IDkA, IHFA. -- **B3 native** — scrape official CSVs/COTAHIST to remove the `yfinance` dep. +- **B3 native** — COTAHIST + listed-funds catalog already official JSON/fixed-width; + remaining work is dropping the `yfinance` live-quote extra. - **IBGE expansion** — PNAD Contínua, produção industrial, comércio varejista. - **TypeScript SDK** — generate from the OpenAPI spec. - **Webhooks / streaming** — SSE for "give me the new PTAX the moment BCB diff --git a/docs/MCP_SURFACE.md b/docs/MCP_SURFACE.md index ad018cb..7a96a9a 100644 --- a/docs/MCP_SURFACE.md +++ b/docs/MCP_SURFACE.md @@ -57,13 +57,13 @@ safe. **The 95 REST routes that back the CLI and HTTP consumers never change.** |---|---:|---:| | MCP tools | 95 | **25** (26 with code mode) | | `tools/list` size | ~85k chars (~21k tok) | **~30k chars (~7k tok)** | -| REST operations | 95 | **97** | +| REST operations | 95 | **98** | ## The 25 curated tools ```text registry_lookup ← start here: CNPJ / ticker / code / name → entities -resolve_asset ← classify an asset: macro asset class + exposure +resolve_asset ← classify an asset: asset class + exposure; *11 uses B3 catalog bcb_series bcb_ptax bcb_focus (BCB: 12 → 3) cvm_company cvm_financials cvm_fund cvm_structured_fund (CVM: 22 → 4) diff --git a/docs/RESOLVER.md b/docs/RESOLVER.md index df3d97f..7d8a5a6 100644 --- a/docs/RESOLVER.md +++ b/docs/RESOLVER.md @@ -94,16 +94,20 @@ Quando `confidence < ~0.9` ou status `candidate`, é gancho de revisão humana. o test set sem rede. 2. **Mais Retorno** (dados BR de fundo/CNPJ/classe CVM) — provider externo opcional via API de dados / MCP; ver limites Free abaixo. -3. **outro provider** (CVM dados abertos / B3). -4. **web_search restrito** a `maisretorno.com`, `b3.com.br`, +3. **B3 listed-funds catalog** (oficial, `fundsListedProxy`) — distingue + `ETF` / `ETF-RF` / `FII` / `FI-INFRA` para ticker `*11`. Ligado em REST, + MCP e `findata resolve`. O `resolve_asset()` de biblioteca continua + offline até o caller passar `providers=[b3_listed_provider]`. +4. **outro provider** (CVM dados abertos / outros). +5. **web_search restrito** a `maisretorno.com`, `b3.com.br`, `yahoofinance.com.br`, `debentures.com.br`. Cada degrau que retorna resultado **substitui** a classificação atual (o provider controla campos, `source` e `confidence`); o resolver só antepõe o -`cascade` anterior ao `cascade` devolvido. Os degraus 2 a 4 são um ponto de -extensão injetável (`AssetProvider`), consultado só quando o resultado do -núcleo está fraco. Hoje **só o degrau 1 está ligado** (os externos são stubs a -conectar no deploy). +`cascade` anterior ao `cascade` devolvido. Os degraus 2 e 4–5 são pontos de +extensão injetáveis (`AssetProvider`), consultados só quando o resultado do +núcleo está fraco. O degrau 3 (catálogo B3) está ligado nas superfícies +HTTP/CLI; o núcleo offline (degrau 1) não muda. ### Mais Retorno: plano Free e cotas @@ -166,4 +170,6 @@ segundo não. web search restrito). - Confirmação ISIN-level da incentivada (12.431) via ANBIMA/debentures.com.br no degrau de cascata — hoje fica `candidate`. -- Ampliar o seed curado de ETFs conforme novos ETFs forem listados na B3. +- Ampliar o seed curado só para underlying/exposição que o tipo B3 não + carrega (Tesouro vs debênture; S&P 500 vs Ibovespa). O veículo + (ETF vs FII vs FI-Infra) vem do catálogo oficial. diff --git a/docs/SOURCES_AND_ENDPOINTS.md b/docs/SOURCES_AND_ENDPOINTS.md index c29d49d..f7542e3 100644 --- a/docs/SOURCES_AND_ENDPOINTS.md +++ b/docs/SOURCES_AND_ENDPOINTS.md @@ -18,7 +18,7 @@ Para testar interativamente, rode `findata serve` e abra `/api/docs` ou `/redoc` | IBGE | Indicadores econômicos, IPCA e grupos/subitens | `/ibge/indicators`, `/ibge/indicators/{name}`, `/ibge/ipca/breakdown`, `/ibge/ipca/groups` | Não | | IPEA Data | Catálogo e séries macroeconômicas OData | `/ipea/catalog`, `/ipea/search`, `/ipea/series/{sercodigo}`, `/ipea/metadata/{sercodigo}` | Não | | Open Finance Brasil | Diretório público, participantes, recursos, JWKS e Portal de Dados | `/openfinance/resources`, `/openfinance/participants`, `/openfinance/endpoints`, `/openfinance/directory/api-resources`, `/openfinance/portal/datasets` | Não para dados públicos | -| B3 | Cotações, COTAHIST oficial, composição teórica e evolução mensal de índices | `/b3/quote/{ticker}`, `/b3/history/{ticker}`, `/b3/quotes`, `/b3/cotahist/year/{year}`, `/b3/indices`, `/b3/indices/{symbol}`, `/b3/indices/{symbol}/monthly` | Não | +| B3 | Cotações, COTAHIST oficial, composição teórica e evolução mensal de índices, catálogo oficial de fundos listados (ETF / ETF-RF / FII / FI-Infra) | `/b3/quote/{ticker}`, `/b3/history/{ticker}`, `/b3/quotes`, `/b3/cotahist/year/{year}`, `/b3/indices`, `/b3/indices/{symbol}`, `/b3/indices/{symbol}/monthly`, `/b3/listed-funds` | Não | | Yahoo Finance | Endpoint experimental de gráfico de preços | `/yahoo/chart/{symbol}` | Não; fonte não oficial | | ANBIMA | IMA, ETTJ, debêntures e TPF via arquivos públicos | `/anbima/ima`, `/anbima/ettj`, `/anbima/debentures`, `/anbima/tpf` | Não para os arquivos usados | | Receita Federal | Arrecadação por período, UF e tributo | `/receita/arrecadacao`, `/receita/tributos` | Não | @@ -38,6 +38,8 @@ curl 'http://localhost:8000/cvm/funds/daily?cnpj=38729027000192&year=2026&month= curl 'http://localhost:8000/b3/quote/PETR4' curl 'http://localhost:8000/b3/indices/IBOV/monthly?start=2026-01-01&end=2026-05-11' curl 'http://localhost:8000/b3/cotahist/year/2025?limit=5' +curl 'http://localhost:8000/b3/listed-funds?ticker=SPXR11' +curl 'http://localhost:8000/b3/listed-funds?type=ETF-RF' curl 'http://localhost:8000/openfinance/participants?role=DADOS&limit=20' curl 'http://localhost:8000/registry/lookup?q=PETR4' ``` @@ -57,6 +59,9 @@ findata cvm cadastro --cnpj 38.729.027/0001-92 findata cvm daily 38729027000192 -y 2026 -m 8 findata b3 quote PETR4 findata b3 index-monthly IBOV --start 2026-01-01 --end 2026-05-11 +findata b3 listed --type ETF +findata b3 listed SPBZ11 +findata resolve SPBZ11 findata anbima ima -i IMA-B findata registry lookup "33.000.167/0001-01" ``` diff --git a/docs/source-notes/b3-listed-funds.md b/docs/source-notes/b3-listed-funds.md new file mode 100644 index 0000000..55a7599 --- /dev/null +++ b/docs/source-notes/b3-listed-funds.md @@ -0,0 +1,62 @@ +# B3 listed-funds catalog + +Status: official JSON adapter. Not an HTML scrape. + +## Use case + +The B3 listed-funds pages (`fundsListedPage/ETF`, `ETF-RF`, `FII`, `FI-INFRA`, +…) are the official split between equity ETFs, fixed-income ETFs, FIIs and +FI-Infra. A ticker ending in `11` does not encode that split — `SPXR11` and +`HGLG11` share a suffix. The resolver's offline core still guesses FII for an +unknown `*11`; REST, MCP and `findata resolve` then consult this catalog. + +## Endpoint + +The Angular app calls: + +```text +https://sistemaswebb3-listados.b3.com.br/fundsListedProxy/Search/GetListFunds/ +``` + +The query is UTF-8 JSON, same pattern as `indexProxy`: + +```json +{ + "language": "pt-br", + "pageNumber": 1, + "pageSize": 100, + "typeFund": "ETF", + "keyword": "" +} +``` + +`typeFund` is the string from `fundsListedPage/assets/funds.json` (`ETF`, +`ETF-RF`, `FII`, `FI-INFRA`, …), not a numeric `typeCEM`. `pageSize` 100 is the +largest size that still returns rows; 200 comes back empty. + +Checked live on 2026-09-16: `SPBZ` and `SPXR` are `ETF` (renda variável); +`LFTS` is `ETF-RF`; `IFRA` is `FI-INFRA`, not `ETF-RF`. + +## Guardrails + +- Use `findata.http_client.get_json`. Do not parse `fundsListedPage` HTML. +- Do not treat ETF1 or other commercial classifiers as a source. +- Unit tests must mock HTTP with `respx`. +- The catalog type does not set S&P-500 geography by itself. The provider may + infer `exposure=Internacional` from the official `fundName` (`S&P`, `IE`, + `QUANTO`, …). Underlying (Tesouro vs debênture) stays on the curated seed. + +## Surfaces + +```bash +findata b3 listed --type ETF +findata b3 listed SPXR11 +findata resolve SPBZ11 +``` + +```text +GET /b3/listed-funds +GET /b3/listed-funds?type=ETF-RF +GET /b3/listed-funds?ticker=SPXR11 +GET /resolver/resolve?ticker=SPBZ11 +``` diff --git a/src/findata/api/mcp_app.py b/src/findata/api/mcp_app.py index 30e35b9..fb6cffb 100644 --- a/src/findata/api/mcp_app.py +++ b/src/findata/api/mcp_app.py @@ -42,7 +42,7 @@ from findata.api._b3_common import MAX_TICKERS, resolve_quotes from findata.registry import lookup -from findata.resolver import resolve_asset +from findata.resolver import b3_listed_provider, resolve_asset from findata.sources.anbima import indices as anbima_src from findata.sources.aneel import leiloes from findata.sources.b3 import cotahist, indices @@ -327,10 +327,18 @@ async def resolve_asset_tool( a confirmed/candidate certainty status), ``source``, ``confidence``, the ``cascade`` walked, and structured ``signals`` (which rule fired on what evidence) — deterministic and cacheable. Pass any subset of identifiers; a - bare ticker/CNPJ given as ``name`` is auto-detected. Use this (not + bare ticker/CNPJ given as ``name`` is auto-detected. Unknown ``*11`` tickers + are checked against the official B3 listed-funds catalog (ETF vs ETF-RF vs + FII vs FI-Infra) before the suffix-11 FII heuristic is kept. Use this (not ``registry_lookup``) when you need the asset's class, not its registry entity. """ - return await resolve_asset(name=name, ticker=ticker, cnpj=cnpj, isin=isin) + return await resolve_asset( + name=name, + ticker=ticker, + cnpj=cnpj, + isin=isin, + providers=[b3_listed_provider], + ) # ── BCB: Banco Central ──────────────────────────────────────────── diff --git a/src/findata/api/routers/b3.py b/src/findata/api/routers/b3.py index 48a1f96..aeb85d2 100644 --- a/src/findata/api/routers/b3.py +++ b/src/findata/api/routers/b3.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Path, Query from findata.api._b3_common import MAX_TICKERS, resolve_quotes -from findata.sources.b3 import cotahist, indices +from findata.sources.b3 import cotahist, indices, listed_funds router = APIRouter(prefix="/b3", tags=["B3 - Bolsa"]) @@ -159,3 +159,39 @@ async def index_monthly_evolution( ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + + +# ── Listed funds — official B3 catalog (ETF / ETF-RF / FII / FI-Infra) ── + + +@router.get("/listed-funds") +async def listed_funds_catalog( + type_fund: str | None = Query( + default=None, + alias="type", + description="Official B3 typeFund (ETF, ETF-RF, FII, FI-INFRA, …)", + ), + ticker: str | None = Query( + default=None, + description="Listed-fund ticker or acronym (SPXR11, SPBZ, HGLG11)", + ), +) -> Any: + """Official B3 listed-funds catalog via fundsListedProxy JSON. + + Pass ``ticker`` to look up one fund across official types (ETF before FII). + Pass ``type`` to list every fund in that B3 page. Omit both to list the + known ``typeFund`` values. + """ + try: + if ticker: + fund = await listed_funds.lookup_listed_fund(ticker, type_fund) + if fund is None: + raise HTTPException( + status_code=404, detail=f"Not in B3 listed-funds catalog: {ticker}" + ) + return fund + if type_fund: + return await listed_funds.get_listed_funds(type_fund) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return await listed_funds.list_fund_types() diff --git a/src/findata/api/routers/resolver.py b/src/findata/api/routers/resolver.py index 8f880b6..2adb21b 100644 --- a/src/findata/api/routers/resolver.py +++ b/src/findata/api/routers/resolver.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Query -from findata.resolver import AssetClassification, resolve_asset +from findata.resolver import AssetClassification, b3_listed_provider, resolve_asset router = APIRouter(prefix="/resolver", tags=["Resolver"]) @@ -31,6 +31,13 @@ async def resolve( Multimercado, Alternativos, Estruturados) + ``exposure`` (eixo ortogonal de geografia: Brasil/Internacional) + subclasse, underlying, debênture/Lei 12.431, ``source``, ``confidence``, ``signals`` e a cascata percorrida. - Determinístico e cacheável. + Determinístico no núcleo; tickers ``*11`` sem seed passam pelo catálogo + oficial B3 (ETF vs FII vs FI-Infra). """ - return await resolve_asset(name=name, ticker=ticker, cnpj=cnpj, isin=isin) + return await resolve_asset( + name=name, + ticker=ticker, + cnpj=cnpj, + isin=isin, + providers=[b3_listed_provider], + ) diff --git a/src/findata/cli.py b/src/findata/cli.py index 8c09dba..f68d1d2 100644 --- a/src/findata/cli.py +++ b/src/findata/cli.py @@ -334,6 +334,84 @@ def b3_index_monthly( rprint(f"[dim](showing last {max_shown} of {len(rows)} monthly points)[/dim]") +@b3_app.command("listed") +def b3_listed( + ticker: str | None = typer.Argument( + None, help="Ticker or acronym (SPXR11, SPBZ). Omit to list a type." + ), + type_fund: str | None = typer.Option( + None, "--type", "-t", help="Official B3 typeFund: ETF, ETF-RF, FII, FI-INFRA, …" + ), +) -> None: + """Official B3 listed-funds catalog (fundsListedProxy JSON, not HTML).""" + from findata.sources.b3.listed_funds import ( + get_listed_funds, + list_fund_types, + lookup_listed_fund, + ) + + if ticker: + fund = _run(lookup_listed_fund(ticker, type_fund)) + if fund is None: + rprint(f"[yellow]Not in B3 listed-funds catalog: {ticker}[/yellow]") + return + rprint(f"[bold]{fund.ticker}[/bold] {fund.type_fund} · {fund.fund_name}") + rprint(f" acronym: {fund.acronym}") + rprint(f" trading: {fund.trading_name}") + if fund.description: + rprint(f" tipo B3: {fund.description}") + return + + if type_fund is None: + table = Table(title="B3 listed-funds types") + table.add_column("typeFund", style="cyan") + table.add_column("Descrição") + for key, label in _run(list_fund_types()).items(): + table.add_row(key, label) + rprint(table) + return + + rows = _run(get_listed_funds(type_fund)) + if not rows: + rprint(f"[yellow]No B3 listed funds for type {type_fund}.[/yellow]") + return + table = Table(title=f"B3 listed funds — {type_fund} ({len(rows)})") + table.add_column("Ticker", style="cyan") + table.add_column("Nome") + table.add_column("Pregão") + max_shown = 80 + for fund in rows[:max_shown]: + table.add_row(fund.ticker, fund.fund_name, fund.trading_name) + rprint(table) + if len(rows) > max_shown: + rprint(f"[dim](showing {max_shown} of {len(rows)} funds)[/dim]") + + +@app.command("resolve") +def resolve_cmd( + query: str = typer.Argument(help="Ticker or name (SPBZ11, IFRA11, FI ITAUINFRA)"), + offline: bool = typer.Option( + False, "--offline", help="Skip the official B3 listed-funds catalog" + ), +) -> None: + """Classify an asset. Uses the B3 catalog for unknown *11 tickers unless --offline.""" + from findata.resolver import b3_listed_provider, resolve_asset + + providers = [] if offline else [b3_listed_provider] + result = _run(resolve_asset(name=query, providers=providers)) + ident = result.identifier_resolved + label = ident.ticker or ident.name or query + rprint(f"[bold]{label}[/bold] {result.macro_class} · {result.kind}") + if result.subclasse: + rprint(f" subclasse: {result.subclasse}") + if result.exposure: + rprint(f" exposure: {result.exposure}") + rprint(f" source: {result.source} confidence: {result.confidence:.2f}") + rprint(f" cascade: {', '.join(result.cascade)}") + if result.notes: + rprint(f" {result.notes}") + + # ── Tesouro commands ─────────────────────────────────────────────── tesouro_app = typer.Typer(help="Tesouro Direto treasury bonds", no_args_is_help=True) diff --git a/src/findata/resolver/__init__.py b/src/findata/resolver/__init__.py index 8e86f13..e6da8fc 100644 --- a/src/findata/resolver/__init__.py +++ b/src/findata/resolver/__init__.py @@ -13,6 +13,7 @@ from __future__ import annotations +from findata.resolver.b3_catalog import b3_listed_provider from findata.resolver.engine import AssetProvider, classify, resolve_asset from findata.resolver.models import ( AssetClassification, @@ -31,6 +32,7 @@ "IdentifierResolved", "NormalizedInput", "TaxInfo", + "b3_listed_provider", "classify", "normalize", "resolve_asset", diff --git a/src/findata/resolver/b3_catalog.py b/src/findata/resolver/b3_catalog.py new file mode 100644 index 0000000..001a481 --- /dev/null +++ b/src/findata/resolver/b3_catalog.py @@ -0,0 +1,250 @@ +"""Official B3 listed-funds provider for ``resolve_asset``. + +Injected by REST, MCP and ``findata resolve``. Not the default of the +library ``resolve_asset`` call — the spec test set stays offline. + +Only ``*11`` tickers are looked up. The catalog type is the official +ETF / ETF-RF / FII / FI-INFRA split; geography is still inferred from the +official ``fundName`` when the type does not carry it (``ETF-INT-RF`` does). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from findata.resolver.models import ( + AssetClassification, + CvmInfo, + DebentureInfo, + IdentifierResolved, + Signal, + TaxInfo, +) +from findata.resolver.normalize import NormalizedInput, fold, tokenize +from findata.sources.b3.listed_funds import ListedFund, lookup_listed_fund + +_BR_TZ = ZoneInfo("America/Sao_Paulo") +_CATALOG_CONFIDENCE = 0.91 +_INTL_NAME_MARKERS = ( + "S&P", + "SP500", + "NASDAQ", + "MSCI", + "GLOBAL", + "WORLD", + "WORLDWIDE", + "INTERNACIONAL", + "INTERNATIONAL", + "EXTERIOR", + "QUANTO", +) + +# Official B3 typeFund → allocation fields the listing itself supports. +_TYPE_HINTS: dict[str, dict[str, Any]] = { + "ETF": { + "kind": "etf", + "macro_class": "Renda Variável", + "subclasse": "ETF de ações", + "underlying_nature": "acoes", + "estrutura": "ETF", + "exposure": None, + "notes": "Catálogo B3: ETF de renda variável. Exposição geográfica não vem do tipo.", + }, + "ETF-RF": { + "kind": "etf", + "macro_class": "Renda Fixa", + "subclasse": "ETF de renda fixa", + "underlying_nature": "credito", + "estrutura": "ETF", + "exposure": "Brasil", + "notes": "Catálogo B3: ETF de renda fixa.", + }, + "ETF-INT-RF": { + "kind": "etf", + "macro_class": "Renda Fixa", + "subclasse": "ETF de renda fixa internacional", + "underlying_nature": "credito", + "estrutura": "ETF", + "exposure": "Internacional", + "notes": "Catálogo B3: ETF de renda fixa internacional.", + }, + "ETF-FII": { + "kind": "etf", + "macro_class": "Renda Variável", + "subclasse": "ETF de FII", + "underlying_nature": "imoveis", + "estrutura": "ETF", + "exposure": "Brasil", + "notes": "Catálogo B3: ETF de FII.", + }, + "ETF-CRIPTO": { + "kind": "etf", + "macro_class": "Alternativos", + "subclasse": "ETF de cripto", + "underlying_nature": "outro", + "estrutura": "ETF", + "exposure": None, + "notes": "Catálogo B3: ETF de cripto.", + }, + "ETF-MOEDA": { + "kind": "etf", + "macro_class": "Alternativos", + "subclasse": "ETF de moeda", + "underlying_nature": "cambio", + "estrutura": "ETF", + "exposure": None, + "notes": "Catálogo B3: ETF de moeda.", + }, + "FII": { + "kind": "fii", + "macro_class": "Renda Variável", + "subclasse": "FII", + "underlying_nature": "imoveis", + "estrutura": "FII", + "exposure": "Brasil", + "notes": "Catálogo B3: fundo imobiliário listado.", + }, + "FI-INFRA": { + "kind": "fundo", + "macro_class": "Renda Fixa", + "subclasse": "FI-Infra", + "underlying_nature": "debentures", + "estrutura": "FI-INFRA", + "exposure": "Brasil", + "notes": "Catálogo B3: FI-Infra (incentivado). Não é o tipo ETF-RF.", + "incentivada": True, + }, + "FIAGRO": { + "kind": "fundo", + "macro_class": "Alternativos", + "subclasse": "FIAGRO", + "underlying_nature": "outro", + "estrutura": "FIAGRO", + "exposure": "Brasil", + "notes": "Catálogo B3: FIAGRO.", + }, + "FIAGRO-FII": { + "kind": "fundo", + "macro_class": "Renda Variável", + "subclasse": "FIAGRO", + "underlying_nature": "imoveis", + "estrutura": "FIAGRO", + "exposure": "Brasil", + "notes": "Catálogo B3: FIAGRO imobiliário.", + }, + "FIAGRO-FIDC": { + "kind": "fundo", + "macro_class": "Renda Fixa", + "subclasse": "FIAGRO", + "underlying_nature": "recebiveis", + "estrutura": "FIAGRO", + "exposure": "Brasil", + "notes": "Catálogo B3: FIAGRO de direitos creditórios.", + }, + "FIAGRO-FIP": { + "kind": "fundo", + "macro_class": "Alternativos", + "subclasse": "FIAGRO", + "underlying_nature": "private_equity", + "estrutura": "FIAGRO", + "exposure": "Brasil", + "notes": "Catálogo B3: FIAGRO de participações.", + }, + "FIP": { + "kind": "fundo", + "macro_class": "Alternativos", + "subclasse": "Private Equity", + "underlying_nature": "private_equity", + "estrutura": "FIP", + "exposure": None, + "notes": "Catálogo B3: FIP listado.", + }, + "FIDC": { + "kind": "fundo", + "macro_class": "Renda Fixa", + "subclasse": "Crédito Estruturado", + "underlying_nature": "recebiveis", + "estrutura": "FIDC", + "exposure": "Brasil", + "notes": "Catálogo B3: FIDC listado.", + }, +} + + +def _name_signals_internacional(fund_name: str) -> bool: + folded = fold(fund_name) + if any(fold(marker) in folded for marker in _INTL_NAME_MARKERS): + return True + return "IE" in set(tokenize(fund_name)) + + +def classification_from_listed_fund(norm: NormalizedInput, fund: ListedFund) -> AssetClassification: + """Map one official B3 listed-fund row onto the resolver contract.""" + hint = _TYPE_HINTS.get(fund.type_fund) + if hint is None: + hint = { + "kind": "fundo", + "macro_class": "Indefinido", + "subclasse": fund.type_fund, + "underlying_nature": "outro", + "estrutura": fund.type_fund, + "exposure": None, + "notes": f"Catálogo B3: tipo {fund.type_fund} sem mapeamento de alocação.", + } + exposure = hint["exposure"] + notes = hint["notes"] + if exposure is None and _name_signals_internacional(fund.fund_name): + exposure = "Internacional" + notes = f"{notes} Exposição Internacional inferida do nome oficial B3." + debenture = None + tax: dict[str, Any] = {} + if hint.get("incentivada"): + debenture = DebentureInfo( + incentivada_1243=True, + lei_12431_status="confirmed", + indexador=None, + ) + tax = {"isento": True, "isento_status": "confirmed_exempt"} + return AssetClassification( + identifier_resolved=IdentifierResolved( + cnpj=norm.cnpj, + ticker=norm.ticker or fund.ticker, + isin=norm.isin, + name=norm.name_raw or fund.fund_name, + ), + kind=hint["kind"], + cvm=CvmInfo(estrutura=hint["estrutura"]), + macro_class=hint["macro_class"], + subclasse=hint["subclasse"], + exposure=exposure, + underlying_nature=hint["underlying_nature"], + debenture=debenture, + tax=TaxInfo(**tax), + source="b3", + confidence=_CATALOG_CONFIDENCE, + as_of=datetime.now(_BR_TZ).date().isoformat(), + cascade=["b3:listed-funds"], + signals=[ + Signal( + rule="b3_listed_funds", + evidence=f"ticker={fund.ticker}", + detail=f"type={fund.type_fund}", + ) + ], + notes=notes, + ) + + +async def b3_listed_provider( + norm: NormalizedInput, current: AssetClassification +) -> AssetClassification | None: + """Cascade step: official B3 catalog for ``*11`` tickers.""" + del current + if not norm.ticker or norm.ticker_digits_suffix != "11": + return None + fund = await lookup_listed_fund(norm.ticker) + if fund is None: + return None + return classification_from_listed_fund(norm, fund) diff --git a/src/findata/resolver/engine.py b/src/findata/resolver/engine.py index 3ace3ff..02c5bf8 100644 --- a/src/findata/resolver/engine.py +++ b/src/findata/resolver/engine.py @@ -7,9 +7,11 @@ 2. **Structural rules** (this module) — name/ticker patterns that *are* derivable: COE, debenture, CRA/CRI, bank paper, Tesouro, IE/global, FII, FIA/Ações, Multimercado, FIDC/FIP, plain tickers. -3. **External providers** (optional, injected) — Mais Retorno, CVM/B3, - restricted web search. Not bundled here (they are client-side / networked); - the resolver takes a chain of async callbacks so a deployment can wire them. +3. **External providers** (optional, injected) — official B3 listed-funds + catalog (:mod:`findata.resolver.b3_catalog`), Mais Retorno, other CVM/B3 + steps, restricted web search. The library ``resolve_asset`` stays offline + unless the caller passes ``providers``. REST, MCP and ``findata resolve`` + inject the B3 catalog so an unknown ``*11`` is not guessed as FII. Mais Retorno uses the operator's own account/quota (see ``docs/RESOLVER.md``). A non-``None`` provider result replaces the current classification (provider owns fields/``source``/``confidence``); the @@ -336,7 +338,8 @@ def _etf_payload(n: NormalizedInput, etf_evidence: str) -> dict[str, Any]: def _ticker_payload(n: NormalizedInput) -> dict[str, Any]: """Classify a bare ticker by its digit suffix (no name signal won).""" suffix = n.ticker_digits_suffix - # 11 not in any curated ETF/RF list → overwhelmingly a FII. + # 11 not in the curated ETF/RF seed → FII heuristic. REST/MCP/CLI then + # consult the official B3 listed-funds catalog before keeping this guess. if suffix == "11": return { "kind": "fii", diff --git a/src/findata/resolver/seed.py b/src/findata/resolver/seed.py index e6a074c..378ad1b 100644 --- a/src/findata/resolver/seed.py +++ b/src/findata/resolver/seed.py @@ -3,11 +3,12 @@ Two honest cases need a curated table, and only these: -1. **ETFs** — an ETF's macro follows its *underlying*, and the ticker carries no - underlying signal. ``IVVB11`` (S&P 500 equities → RV) and ``IFRA11`` - (infra debentures → RF) both end in ``11``; nothing in the symbol separates - them. The B3 ETF universe is small (~100 listed) and stable, so a curated - ticker→underlying map is the deterministic, auditable answer. +1. **ETFs** — listing *type* now comes from the official B3 catalog + (``ETF`` vs ``ETF-RF`` vs ``FII`` vs ``FI-INFRA``). The seed remains for + facts the listing type does not carry: underlying (Tesouro vs debênture) + and geography (``IVVB11`` is RV + Internacional). ``IVVB11`` and + ``IFRA11`` both end in ``11``; the catalog splits the vehicle, the seed + splits the economic nature. 2. **Global-mandate funds with no structural tell** — ``ARBOR FIC FIA`` is an equities wrapper (FIA → RV) whose mandate is global, but the name has no ``IE`` and no "global"/"world" keyword. Only fund-level knowledge sets its diff --git a/src/findata/sources/b3/__init__.py b/src/findata/sources/b3/__init__.py index a9e9224..73cee9d 100644 --- a/src/findata/sources/b3/__init__.py +++ b/src/findata/sources/b3/__init__.py @@ -2,9 +2,10 @@ | Module | Source | Cadence | |---------------|-----------------------------------------|----------------------| -| `quotes.py` | Yahoo Finance (via yfinance) | live + intraday | -| `cotahist.py` | B3 SerHist fixed-width archive (1986+) | annual / month / day | -| `indices.py` | B3 indexProxy/indexStatisticsProxy JSON | portfolios + monthly | +| `quotes.py` | Yahoo Finance (via yfinance) | live + intraday | +| `cotahist.py` | B3 SerHist fixed-width archive (1986+) | annual / month / day | +| `indices.py` | B3 indexProxy/indexStatisticsProxy JSON | portfolios + monthly | +| `listed_funds.py` | B3 fundsListedProxy JSON | ETF / FII / FI-Infra | The optional ``[b3]`` extra installs ``yfinance`` for live quotes. ``cotahist`` and ``indices`` use only stdlib + httpx (already in core). @@ -25,6 +26,13 @@ get_index_portfolio, list_known_indices, ) +from findata.sources.b3.listed_funds import ( + FUND_TYPES, + ListedFund, + get_listed_funds, + list_fund_types, + lookup_listed_fund, +) from findata.sources.b3.quotes import ( StockHistoryPoint, StockQuote, @@ -34,11 +42,13 @@ ) __all__ = [ + "FUND_TYPES", "KNOWN_INDICES", "CotahistTrade", "IndexConstituent", "IndexMonthlyPoint", "IndexPortfolio", + "ListedFund", "StockHistoryPoint", "StockQuote", "get_cotahist_day", @@ -47,7 +57,10 @@ "get_history", "get_index_monthly_evolution", "get_index_portfolio", + "get_listed_funds", "get_multiple_quotes", "get_quote", + "list_fund_types", "list_known_indices", + "lookup_listed_fund", ] diff --git a/src/findata/sources/b3/listed_funds.py b/src/findata/sources/b3/listed_funds.py new file mode 100644 index 0000000..29e8b7d --- /dev/null +++ b/src/findata/sources/b3/listed_funds.py @@ -0,0 +1,232 @@ +"""B3 listed-funds catalog — official ``fundsListedProxy`` JSON. + +The public Angular app at +``https://sistemaswebb3-listados.b3.com.br/fundsListedPage/{type}`` loads +rows from ``fundsListedProxy/Search/GetListFunds/``. Same +family as :mod:`findata.sources.b3.indices` (``indexProxy``): a JSON API +behind a base64 query string, not an HTML scrape. + +``typeFund`` is the string published in the app's ``assets/funds.json`` +(``ETF``, ``ETF-RF``, ``FII``, ``FI-INFRA``, …), not a numeric ``typeCEM``. +B3 rejects ``pageSize`` above 100 (200 returns an empty page). + +Example URL:: + + https://sistemaswebb3-listados.b3.com.br/fundsListedProxy/Search/GetListFunds/ + +Each row comes back as:: + + { + "id": 16037, + "typeName": null, + "acronym": "SPXR", + "fundName": "IT NOW S&P 500 FUTURES QUANTO BRL FUNDO DE ÍNDICE …", + "tradingName": "IT NOW SP BR", + } + +The listed share ticker is the 4-letter acronym plus ``11`` (``SPXR11``). +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +from pydantic import BaseModel + +from findata.http_client import get_json + +FUNDS_LISTED_PROXY = "https://sistemaswebb3-listados.b3.com.br/fundsListedProxy/Search/GetListFunds" + +# Official ``fundType`` values from fundsListedPage/assets/funds.json, minus +# securitizer issuer pages (CRI/CRA/SEC/OTS) that are not listed funds. +FUND_TYPES: dict[str, str] = { + "ETF": "ETFs de Renda Variável", + "ETF-RF": "ETFs de Renda Fixa", + "ETF-INT-RF": "ETFs de Renda Fixa Internacional", + "ETF-FII": "ETFs de FII", + "ETF-CRIPTO": "ETFs de Criptos", + "ETF-MOEDA": "ETFs de Moedas", + "FII": "Fundos de Investimento Imobiliário", + "FI-INFRA": "Fundo Incentivado de Investimento em Infraestrutura", + "FIAGRO": "Fundo de Investimento em Cadeias Agroindústrias", + "FIAGRO-FII": "FIAGRO Imobiliário", + "FIAGRO-FIDC": "FIAGRO Direitos Creditórios", + "FIAGRO-FIP": "FIAGRO Participações", + "FIP": "Fundos de Investimento em Participações", + "FIDC": "Fundos de Investimento em Direitos Creditórios", + "FIA": "Fundo de Investimentos em Ações", + "FI-RF": "Fundo de Investimento Renda Fixa", + "FI-MOEDA": "Fundo de Investimento de Moeda", + "SETORIAL": "Fundo de Investimento Setoriais", + "FIM-INFRA": "Fundo de Investimento Multimercado em Infraestrutura", + "FIM-RF-C-REND": "Fundo de Investimento Multimercado em Renda Fixa", + "FIM-RF-S-REND": "Fundo de Investimento Multimercado em Renda Fixa", + "FIM-RV-C-REND": "Fundo de Investimento Multimercado", + "FIM-RV-S-REND": "Fundo de Investimento Multimercado", +} + +# Lookup walks this order so an ETF is not first claimed as FII. +LOOKUP_TYPES: tuple[str, ...] = ( + "ETF", + "ETF-RF", + "ETF-INT-RF", + "ETF-FII", + "ETF-CRIPTO", + "ETF-MOEDA", + "FI-INFRA", + "FII", + "FIAGRO", + "FIAGRO-FII", + "FIAGRO-FIDC", + "FIAGRO-FIP", + "FIP", + "FIDC", +) + +_DEFAULT_PAGE_SIZE = 100 +_LOOKUP_PAGE_SIZE = 20 +_MAX_PAGES = 15 +_LISTED_SHARE_SUFFIX = "11" +_STANDARD_ACRONYM_LEN = 4 +_CACHE_TTL = 3600 + + +class ListedFund(BaseModel): + """One fund on the official B3 listed-funds catalog.""" + + ticker: str + acronym: str + fund_name: str + trading_name: str + type_fund: str + type_name: str | None = None + b3_id: int | None = None + description: str | None = None + + +def acronym_from_ticker(ticker: str) -> str: + """Strip the trailing digit suffix (``SPXR11`` → ``SPXR``).""" + folded = ticker.strip().upper() + stripped = folded.rstrip("0123456789") + return stripped or folded + + +def listed_share_ticker(acronym: str) -> str: + """B3 listed-fund share code: 4-letter acronym plus ``11``.""" + if len(acronym) == _STANDARD_ACRONYM_LEN: + return f"{acronym}{_LISTED_SHARE_SUFFIX}" + return acronym + + +def _canonical_type(type_fund: str) -> str: + key = type_fund.strip().upper() + if key not in FUND_TYPES: + known = ", ".join(sorted(FUND_TYPES)) + raise ValueError(f"unknown B3 listed-fund type {type_fund!r}; expected one of: {known}") + return key + + +def _encode_query( + type_fund: str, + *, + page_number: int = 1, + page_size: int = _DEFAULT_PAGE_SIZE, + keyword: str = "", +) -> str: + payload = { + "language": "pt-br", + "pageNumber": page_number, + "pageSize": page_size, + "typeFund": type_fund, + "keyword": keyword, + } + return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + + +def _as_int(value: Any) -> int | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _row_to_fund(row: dict[str, Any], type_fund: str) -> ListedFund | None: + acronym = (row.get("acronym") or "").strip().upper() + if not acronym: + return None + return ListedFund( + ticker=listed_share_ticker(acronym), + acronym=acronym, + fund_name=(row.get("fundName") or "").strip(), + trading_name=(row.get("tradingName") or "").strip(), + type_fund=type_fund, + type_name=(row.get("typeName") or None), + b3_id=_as_int(row.get("id")), + description=FUND_TYPES[type_fund], + ) + + +async def _fetch_page( + type_fund: str, + *, + page_number: int, + page_size: int, + keyword: str, +) -> dict[str, Any]: + encoded = _encode_query( + type_fund, page_number=page_number, page_size=page_size, keyword=keyword + ) + payload = await get_json(f"{FUNDS_LISTED_PROXY}/{encoded}", cache_ttl=_CACHE_TTL) + if not isinstance(payload, dict): + raise ValueError("B3 listed-funds response was not an object") + return payload + + +def _results(payload: dict[str, Any]) -> list[dict[str, Any]]: + rows = payload.get("results") or [] + return [row for row in rows if isinstance(row, dict)] + + +async def get_listed_funds(type_fund: str) -> list[ListedFund]: + """Fetch every fund B3 lists under one official ``typeFund``.""" + kind = _canonical_type(type_fund) + first = await _fetch_page(kind, page_number=1, page_size=_DEFAULT_PAGE_SIZE, keyword="") + page_meta = first.get("page") or {} + total_pages = int(page_meta.get("totalPages") or 1) + collected = list(_results(first)) + for page in range(2, min(total_pages, _MAX_PAGES) + 1): + nxt = await _fetch_page(kind, page_number=page, page_size=_DEFAULT_PAGE_SIZE, keyword="") + collected.extend(_results(nxt)) + return [fund for row in collected if (fund := _row_to_fund(row, kind)) is not None] + + +async def lookup_listed_fund(ticker: str, type_fund: str | None = None) -> ListedFund | None: + """Resolve a ticker (``SPXR11`` or ``SPXR``) against the official catalog. + + Uses B3's own keyword filter (one request per type) and requires an exact + acronym match. Types are tried in :data:`LOOKUP_TYPES` unless ``type_fund`` + is given. + """ + acronym = acronym_from_ticker(ticker) + if not acronym: + return None + kinds = (_canonical_type(type_fund),) if type_fund else LOOKUP_TYPES + for kind in kinds: + payload = await _fetch_page( + kind, page_number=1, page_size=_LOOKUP_PAGE_SIZE, keyword=acronym + ) + for row in _results(payload): + fund = _row_to_fund(row, kind) + if fund is not None and fund.acronym == acronym: + return fund + return None + + +async def list_fund_types() -> dict[str, str]: + """Return ``typeFund → official description`` for types this adapter lists.""" + return dict(FUND_TYPES) diff --git a/tests/test_b3_listed_funds.py b/tests/test_b3_listed_funds.py new file mode 100644 index 0000000..f097da7 --- /dev/null +++ b/tests/test_b3_listed_funds.py @@ -0,0 +1,341 @@ +"""B3 listed-funds catalog (fundsListedProxy) — offline tests.""" + +from __future__ import annotations + +import base64 +import json +import re + +import httpx +import pytest +import respx +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +from findata.api.app import app +from findata.cli import app as cli_app +from findata.http_client import clear_cache +from findata.resolver import classify, resolve_asset +from findata.resolver.b3_catalog import classification_from_listed_fund +from findata.resolver.normalize import normalize +from findata.sources.b3.listed_funds import ( + FUND_TYPES, + FUNDS_LISTED_PROXY, + ListedFund, + _encode_query, + acronym_from_ticker, + get_listed_funds, + list_fund_types, + listed_share_ticker, + lookup_listed_fund, +) + +_LIST_URL = re.compile( + r"https://sistemaswebb3-listados\.b3\.com\.br/fundsListedProxy/Search/GetListFunds/.+" +) +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _clean_caches() -> None: + clear_cache() + + +def _decode_request(request: httpx.Request) -> dict[str, object]: + encoded = request.url.path.rsplit("/", 1)[-1] + return json.loads(base64.b64decode(encoded).decode("utf-8")) + + +def _page( + *rows: dict[str, object], page_number: int = 1, total_pages: int = 1 +) -> dict[str, object]: + return { + "page": { + "pageNumber": page_number, + "pageSize": 100, + "totalRecords": len(rows) if total_pages == 1 else 3, + "totalPages": total_pages, + }, + "results": list(rows), + } + + +def test_encode_query_is_round_trippable_base64_json() -> None: + encoded = _encode_query("etf-rf", page_number=2, keyword="LFTS") + decoded = json.loads(base64.b64decode(encoded).decode("utf-8")) + assert decoded["typeFund"] == "etf-rf" + assert decoded["pageNumber"] == 2 + assert decoded["pageSize"] == 100 + assert decoded["language"] == "pt-br" + assert decoded["keyword"] == "LFTS" + assert encoded in f"{FUNDS_LISTED_PROXY}/{encoded}" + + +def test_acronym_and_listed_ticker_round_trip() -> None: + assert acronym_from_ticker("spxr11") == "SPXR" + assert acronym_from_ticker("SPBZ") == "SPBZ" + assert listed_share_ticker("SPXR") == "SPXR11" + assert listed_share_ticker("ABCDE") == "ABCDE" + + +async def test_list_fund_types_is_a_copy() -> None: + types = await list_fund_types() + assert types["ETF"] == "ETFs de Renda Variável" + assert types["ETF-RF"] == "ETFs de Renda Fixa" + assert types["FI-INFRA"] == FUND_TYPES["FI-INFRA"] + assert types is not FUND_TYPES + + +@respx.mock +async def test_get_listed_funds_maps_rows_and_paginates() -> None: + def _handler(request: httpx.Request) -> httpx.Response: + query = _decode_request(request) + assert query["typeFund"] == "ETF" + if query["pageNumber"] == 1: + return httpx.Response( + 200, + json=_page( + { + "id": 23167, + "typeName": None, + "acronym": "SPBZ", + "fundName": "BTG PACTUAL S&P 500 FUTURES QUANTO BRL FUNDO DE ÍNDICE", + "tradingName": "BTG SPHEDGE", + }, + { + "id": 990, + "acronym": "BOVA", + "fundName": "ISHARES IBOVESPA", + "tradingName": "ISHARES BOVA", + }, + total_pages=2, + page_number=1, + ), + ) + return httpx.Response( + 200, + json=_page( + { + "id": 16037, + "acronym": "SPXR", + "fundName": "IT NOW S&P 500 FUTURES QUANTO BRL FUNDO DE ÍNDICE", + "tradingName": "IT NOW SP BR", + }, + page_number=2, + total_pages=2, + ), + ) + + respx.get(_LIST_URL).mock(side_effect=_handler) + funds = await get_listed_funds("etf") + tickers = [fund.ticker for fund in funds] + assert tickers == ["SPBZ11", "BOVA11", "SPXR11"] + spbz = funds[0] + assert spbz.type_fund == "ETF" + assert spbz.b3_id == 23167 + assert spbz.description == "ETFs de Renda Variável" + + +@respx.mock +async def test_lookup_prefers_etf_over_later_types() -> None: + def _handler(request: httpx.Request) -> httpx.Response: + query = _decode_request(request) + if query["typeFund"] == "ETF" and query["keyword"] == "SPXR": + return httpx.Response( + 200, + json=_page( + { + "id": 16037, + "acronym": "SPXR", + "fundName": "IT NOW S&P 500 FUTURES QUANTO BRL FUNDO DE ÍNDICE", + "tradingName": "IT NOW SP BR", + } + ), + ) + return httpx.Response(200, json=_page()) + + respx.get(_LIST_URL).mock(side_effect=_handler) + fund = await lookup_listed_fund("SPXR11") + assert fund is not None + assert fund.type_fund == "ETF" + assert fund.ticker == "SPXR11" + + +@respx.mock +async def test_lookup_miss_returns_none() -> None: + respx.get(_LIST_URL).mock(return_value=httpx.Response(200, json=_page())) + assert await lookup_listed_fund("ZZZZ11") is None + + +async def test_unknown_type_is_value_error() -> None: + with pytest.raises(ValueError, match="unknown B3 listed-fund type"): + await get_listed_funds("NOT-A-TYPE") + + +def test_spbz11_offline_core_is_still_the_fii_heuristic() -> None: + result = classify(normalize(ticker="SPBZ11")) + assert result.kind == "fii" + assert result.source == "openfindata" + + +@respx.mock +async def test_spbz11_with_b3_provider_is_etf_rv_internacional() -> None: + def _handler(request: httpx.Request) -> httpx.Response: + query = _decode_request(request) + if query["typeFund"] == "ETF" and query["keyword"] == "SPBZ": + return httpx.Response( + 200, + json=_page( + { + "id": 23167, + "acronym": "SPBZ", + "fundName": "BTG PACTUAL S&P 500 FUTURES QUANTO BRL FUNDO DE ÍNDICE", + "tradingName": "BTG SPHEDGE", + } + ), + ) + return httpx.Response(200, json=_page()) + + respx.get(_LIST_URL).mock(side_effect=_handler) + from findata.resolver import b3_listed_provider + + result = await resolve_asset(ticker="SPBZ11", providers=[b3_listed_provider]) + assert result.kind == "etf" + assert result.macro_class == "Renda Variável" + assert result.exposure == "Internacional" + assert result.source == "b3" + assert result.cascade[0] == "openfindata:rules" + assert "b3:listed-funds" in result.cascade + assert result.signals[-1].detail == "type=ETF" + + +def test_classification_maps_official_types() -> None: + norm = normalize(ticker="IFRA11") + fund = ListedFund( + ticker="IFRA11", + acronym="IFRA", + fund_name="ITAÚ FIF INCEN EM INFRA", + trading_name="FI ITAUINFRA", + type_fund="FI-INFRA", + description="FI-Infra", + ) + result = classification_from_listed_fund(norm, fund) + assert result.kind == "fundo" + assert result.macro_class == "Renda Fixa" + assert result.cvm.estrutura == "FI-INFRA" + assert result.debenture is not None + assert result.debenture.incentivada_1243 is True + assert result.tax.isento is True + + +@respx.mock +def test_listed_funds_route_list_and_lookup() -> None: + def _handler(request: httpx.Request) -> httpx.Response: + query = _decode_request(request) + if query.get("keyword") == "LFTS": + return httpx.Response( + 200, + json=_page( + { + "id": 8806, + "acronym": "LFTS", + "fundName": "INVESTO TEVA TESOURO SELIC ETF", + "tradingName": "INVESTO LFTS", + } + ), + ) + if query["typeFund"] == "ETF-RF" and query["keyword"] == "": + return httpx.Response( + 200, + json=_page( + { + "id": 8806, + "acronym": "LFTS", + "fundName": "INVESTO TEVA TESOURO SELIC ETF", + "tradingName": "INVESTO LFTS", + } + ), + ) + return httpx.Response(200, json=_page()) + + respx.get(_LIST_URL).mock(side_effect=_handler) + client = TestClient(app) + types = client.get("/b3/listed-funds") + assert types.status_code == 200 + assert types.json()["ETF"] == "ETFs de Renda Variável" + + listed = client.get("/b3/listed-funds", params={"type": "ETF-RF"}) + assert listed.status_code == 200 + assert listed.json()[0]["ticker"] == "LFTS11" + + found = client.get("/b3/listed-funds", params={"ticker": "LFTS11", "type": "ETF-RF"}) + assert found.status_code == 200 + assert found.json()["type_fund"] == "ETF-RF" + + missing = client.get("/b3/listed-funds", params={"ticker": "ZZZZ11", "type": "ETF-RF"}) + assert missing.status_code == 404 + + bad = client.get("/b3/listed-funds", params={"type": "NOPE"}) + assert bad.status_code == 400 + + +@respx.mock +def test_resolver_route_uses_b3_catalog_for_unknown_11() -> None: + def _handler(request: httpx.Request) -> httpx.Response: + query = _decode_request(request) + if query["typeFund"] == "ETF" and query["keyword"] == "SPXR": + return httpx.Response( + 200, + json=_page( + { + "id": 16037, + "acronym": "SPXR", + "fundName": "IT NOW S&P 500 FUTURES QUANTO BRL FUNDO DE ÍNDICE", + "tradingName": "IT NOW SP BR", + } + ), + ) + return httpx.Response(200, json=_page()) + + respx.get(_LIST_URL).mock(side_effect=_handler) + client = TestClient(app) + response = client.get("/resolver/resolve", params={"ticker": "SPXR11"}) + assert response.status_code == 200 + body = response.json() + assert body["kind"] == "etf" + assert body["macro_class"] == "Renda Variável" + assert body["source"] == "b3" + + +@respx.mock +def test_cli_listed_and_resolve() -> None: + def _handler(request: httpx.Request) -> httpx.Response: + query = _decode_request(request) + if query["typeFund"] == "ETF" and query["keyword"] == "SPBZ": + return httpx.Response( + 200, + json=_page( + { + "id": 23167, + "acronym": "SPBZ", + "fundName": "BTG PACTUAL S&P 500 FUTURES QUANTO BRL FUNDO DE ÍNDICE", + "tradingName": "BTG SPHEDGE", + } + ), + ) + return httpx.Response(200, json=_page()) + + respx.get(_LIST_URL).mock(side_effect=_handler) + listed = runner.invoke(cli_app, ["b3", "listed", "SPBZ11"]) + assert listed.exit_code == 0 + assert "SPBZ11" in listed.stdout + assert "ETF" in listed.stdout + + types = runner.invoke(cli_app, ["b3", "listed"]) + assert types.exit_code == 0 + assert "ETF-RF" in types.stdout + + resolved = runner.invoke(cli_app, ["resolve", "SPBZ11"]) + assert resolved.exit_code == 0 + assert "Renda Variável" in resolved.stdout + assert "etf" in resolved.stdout diff --git a/tests/test_integration.py b/tests/test_integration.py index c60683b..61381ca 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -112,3 +112,14 @@ async def test_get_history(self) -> None: data = await quotes.get_history("VALE3", period="5d") assert len(data) > 0 assert all(p.close > 0 for p in data) + + async def test_listed_funds_etf_includes_spxr(self) -> None: + from findata.sources.b3.listed_funds import get_listed_funds, lookup_listed_fund + + funds = await get_listed_funds("ETF") + tickers = {fund.ticker for fund in funds} + assert "SPXR11" in tickers + assert "SPBZ11" in tickers + found = await lookup_listed_fund("SPXR11", "ETF") + assert found is not None + assert found.type_fund == "ETF" diff --git a/tests/test_mcp_surface.py b/tests/test_mcp_surface.py index 897577b..1eb3e1d 100644 --- a/tests/test_mcp_surface.py +++ b/tests/test_mcp_surface.py @@ -28,7 +28,7 @@ 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 +EXPECTED_REST_OPERATIONS = 98 # all REST routes (unconditional); bump when the surface changes _HTTP_METHODS = {"get", "post", "put", "delete", "patch"} From fab2e3778416bb3f4fd5d6909020b090a22ba3fc Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 16 Sep 2026 21:14:21 -0300 Subject: [PATCH 2/3] fix: do not keep FII after B3 catalog miss A *11 ticker absent from the official listed-funds pages is Indefinido, not the suffix heuristic. Offline classify() is unchanged. --- CHANGELOG.md | 7 +- docs/RESOLVER.md | 3 +- src/findata/resolver/b3_catalog.py | 95 ++++++++++++++++++++++++-- src/findata/sources/b3/listed_funds.py | 9 +++ tests/test_b3_listed_funds.py | 17 +++++ 5 files changed, 123 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b87f37d..274bd52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,10 @@ adheres to [Semantic Versioning](https://semver.org/). `GET /b3/listed-funds`, `findata b3 listed`, and `findata resolve` expose it. REST/MCP `resolve_asset` injects the catalog for unknown `*11` tickers so `SPBZ11`/`SPXR11` classify as ETF de renda variável instead of the suffix-11 - FII heuristic. The library `resolve_asset()` stays offline unless the caller - passes `providers=[b3_listed_provider]`. `IFRA11` is `FI-INFRA` on B3, not - `ETF-RF`. Source note: `docs/source-notes/b3-listed-funds.md`. + FII heuristic. A catalog miss no longer keeps that FII guess (`Indefinido`). + The library `resolve_asset()` stays offline unless the caller passes + `providers=[b3_listed_provider]`. `IFRA11` is `FI-INFRA` on B3, not `ETF-RF`. + Source note: `docs/source-notes/b3-listed-funds.md`. - **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, diff --git a/docs/RESOLVER.md b/docs/RESOLVER.md index 7d8a5a6..022b871 100644 --- a/docs/RESOLVER.md +++ b/docs/RESOLVER.md @@ -96,7 +96,8 @@ Quando `confidence < ~0.9` ou status `candidate`, é gancho de revisão humana. opcional via API de dados / MCP; ver limites Free abaixo. 3. **B3 listed-funds catalog** (oficial, `fundsListedProxy`) — distingue `ETF` / `ETF-RF` / `FII` / `FI-INFRA` para ticker `*11`. Ligado em REST, - MCP e `findata resolve`. O `resolve_asset()` de biblioteca continua + MCP e `findata resolve`. Miss no catálogo **não** mantém o chute FII do + sufixo 11 (`Indefinido`). O `resolve_asset()` de biblioteca continua offline até o caller passar `providers=[b3_listed_provider]`. 4. **outro provider** (CVM dados abertos / outros). 5. **web_search restrito** a `maisretorno.com`, `b3.com.br`, diff --git a/src/findata/resolver/b3_catalog.py b/src/findata/resolver/b3_catalog.py index 001a481..65174a5 100644 --- a/src/findata/resolver/b3_catalog.py +++ b/src/findata/resolver/b3_catalog.py @@ -27,6 +27,7 @@ _BR_TZ = ZoneInfo("America/Sao_Paulo") _CATALOG_CONFIDENCE = 0.91 +_MISS_CONFIDENCE = 0.4 _INTL_NAME_MARKERS = ( "S&P", "SP500", @@ -170,8 +171,62 @@ "exposure": "Brasil", "notes": "Catálogo B3: FIDC listado.", }, + "FIA": { + "kind": "fundo", + "macro_class": "Renda Variável", + "subclasse": "Ações", + "underlying_nature": "acoes", + "estrutura": "FIA", + "exposure": "Brasil", + "notes": "Catálogo B3: FIA listado.", + }, + "FI-RF": { + "kind": "fundo", + "macro_class": "Renda Fixa", + "subclasse": "Renda Fixa", + "underlying_nature": "credito", + "estrutura": "FI-RF", + "exposure": "Brasil", + "notes": "Catálogo B3: fundo de renda fixa listado.", + }, + "FI-MOEDA": { + "kind": "fundo", + "macro_class": "Alternativos", + "subclasse": "Moeda", + "underlying_nature": "cambio", + "estrutura": "FI-MOEDA", + "exposure": None, + "notes": "Catálogo B3: fundo de moeda listado.", + }, + "SETORIAL": { + "kind": "fundo", + "macro_class": "Alternativos", + "subclasse": "Setorial", + "underlying_nature": "outro", + "estrutura": "SETORIAL", + "exposure": "Brasil", + "notes": "Catálogo B3: fundo setorial listado.", + }, } +_FIM_HINT: dict[str, Any] = { + "kind": "fundo", + "macro_class": "Multimercado", + "subclasse": "Multimercado", + "underlying_nature": "multiativos", + "estrutura": "FIM", + "exposure": "Brasil", + "notes": "Catálogo B3: FIM listado.", +} +for _fim_type in ( + "FIM-INFRA", + "FIM-RF-C-REND", + "FIM-RF-S-REND", + "FIM-RV-C-REND", + "FIM-RV-S-REND", +): + _TYPE_HINTS[_fim_type] = _FIM_HINT + def _name_signals_internacional(fund_name: str) -> bool: folded = fold(fund_name) @@ -237,14 +292,46 @@ def classification_from_listed_fund(norm: NormalizedInput, fund: ListedFund) -> ) +def _not_listed_classification(norm: NormalizedInput) -> AssetClassification: + """Suffix-11 heuristic is not a listing. Catalog miss → not FII.""" + return AssetClassification( + identifier_resolved=IdentifierResolved( + cnpj=norm.cnpj, ticker=norm.ticker, isin=norm.isin, name=norm.name_raw + ), + kind="outro", + cvm=CvmInfo(), + macro_class="Indefinido", + source="b3", + confidence=_MISS_CONFIDENCE, + as_of=datetime.now(_BR_TZ).date().isoformat(), + cascade=["b3:listed-funds"], + signals=[ + Signal( + rule="b3_listed_funds", + evidence=f"ticker={norm.ticker}", + detail="not_listed", + ) + ], + notes=( + "Catálogo B3: ticker *11 não listado em ETF/FII/FI-Infra/FIAGRO/FIP/FIDC/" + "FIM. Não assumir FII pelo sufixo." + ), + ) + + +def _is_suffix_11_heuristic(current: AssetClassification) -> bool: + return any(signal.rule == "ticker_suffix_11" for signal in current.signals) + + async def b3_listed_provider( norm: NormalizedInput, current: AssetClassification ) -> AssetClassification | None: """Cascade step: official B3 catalog for ``*11`` tickers.""" - del current if not norm.ticker or norm.ticker_digits_suffix != "11": return None fund = await lookup_listed_fund(norm.ticker) - if fund is None: - return None - return classification_from_listed_fund(norm, fund) + if fund is not None: + return classification_from_listed_fund(norm, fund) + if _is_suffix_11_heuristic(current): + return _not_listed_classification(norm) + return None diff --git a/src/findata/sources/b3/listed_funds.py b/src/findata/sources/b3/listed_funds.py index 29e8b7d..1c6be77 100644 --- a/src/findata/sources/b3/listed_funds.py +++ b/src/findata/sources/b3/listed_funds.py @@ -83,6 +83,15 @@ "FIAGRO-FIP", "FIP", "FIDC", + "FIA", + "FI-RF", + "FI-MOEDA", + "SETORIAL", + "FIM-INFRA", + "FIM-RF-C-REND", + "FIM-RF-S-REND", + "FIM-RV-C-REND", + "FIM-RV-S-REND", ) _DEFAULT_PAGE_SIZE = 100 diff --git a/tests/test_b3_listed_funds.py b/tests/test_b3_listed_funds.py index f097da7..5c29254 100644 --- a/tests/test_b3_listed_funds.py +++ b/tests/test_b3_listed_funds.py @@ -167,6 +167,23 @@ async def test_lookup_miss_returns_none() -> None: assert await lookup_listed_fund("ZZZZ11") is None +@respx.mock +async def test_catalog_miss_does_not_keep_suffix_11_fii() -> None: + respx.get(_LIST_URL).mock(return_value=httpx.Response(200, json=_page())) + from findata.resolver import b3_listed_provider + + offline = classify(normalize(ticker="ZZZZ11")) + assert offline.kind == "fii" + result = await resolve_asset(ticker="ZZZZ11", providers=[b3_listed_provider]) + assert result.kind == "outro" + assert result.macro_class == "Indefinido" + assert result.source == "b3" + assert result.signals[-1].detail == "not_listed" + still_offline = await resolve_asset(ticker="ZZZZ11") + assert still_offline.kind == "fii" + assert still_offline.source == "openfindata" + + async def test_unknown_type_is_value_error() -> None: with pytest.raises(ValueError, match="unknown B3 listed-fund type"): await get_listed_funds("NOT-A-TYPE") From 2e12f8383655551a9c64ac27546cb7211a63ef11 Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 16 Sep 2026 21:14:37 -0300 Subject: [PATCH 3/3] docs: note B3 catalog miss is Indefinido --- docs/source-notes/b3-listed-funds.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source-notes/b3-listed-funds.md b/docs/source-notes/b3-listed-funds.md index 55a7599..bb94247 100644 --- a/docs/source-notes/b3-listed-funds.md +++ b/docs/source-notes/b3-listed-funds.md @@ -9,6 +9,7 @@ The B3 listed-funds pages (`fundsListedPage/ETF`, `ETF-RF`, `FII`, `FI-INFRA`, FI-Infra. A ticker ending in `11` does not encode that split — `SPXR11` and `HGLG11` share a suffix. The resolver's offline core still guesses FII for an unknown `*11`; REST, MCP and `findata resolve` then consult this catalog. +A catalog miss does not keep the FII suffix guess (`Indefinido`). ## Endpoint