Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ 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. 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,
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/MCP_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 14 additions & 7 deletions docs/RESOLVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,21 @@ 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`. 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`,
`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

Expand Down Expand Up @@ -166,4 +171,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.
7 changes: 6 additions & 1 deletion docs/SOURCES_AND_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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'
```
Expand All @@ -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"
```
Expand Down
63 changes: 63 additions & 0 deletions docs/source-notes/b3-listed-funds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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.
A catalog miss does not keep the FII suffix guess (`Indefinido`).

## Endpoint

The Angular app calls:

```text
https://sistemaswebb3-listados.b3.com.br/fundsListedProxy/Search/GetListFunds/<base64>
```

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
```
14 changes: 11 additions & 3 deletions src/findata/api/mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ────────────────────────────────────────────
Expand Down
38 changes: 37 additions & 1 deletion src/findata/api/routers/b3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -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()
13 changes: 10 additions & 3 deletions src/findata/api/routers/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand All @@ -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],
)
78 changes: 78 additions & 0 deletions src/findata/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/findata/resolver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +32,7 @@
"IdentifierResolved",
"NormalizedInput",
"TaxInfo",
"b3_listed_provider",
"classify",
"normalize",
"resolve_asset",
Expand Down
Loading
Loading