A informação mais importante sobre um FII está no relatório gerencial mensal enviado à CVM — e quase ninguém lê. Neste artigo você vai aprender a extrair, parsear e analisar esses relatórios via API, com Python, transformando 100 páginas de PDF em métricas quantitativas que cabem numa planilha.
Por Que os Relatórios CVM Importam
Todo FII listado na B3 é obrigado a publicar mensalmente um relatório gerencial na CVM, com:
- Composição patrimonial detalhada (caixa, CRI, LCI, imóveis, fundos, recebíveis)
- Taxa de administração e amortização
- Rentabilidade da cota e do patrimônio
- Dividend yield mensal calculado
- Passivos (distribuições a pagar, obrigações imobiliárias)
- Versionamento (relatórios retificados)
Esses dados existem porque a CVM exige — mas tradicionalmente eles vivem dentro de PDFs longos e mal estruturados, o que afasta o investidor médio.
A boa notícia: a brapi importa esses relatórios automaticamente e expõe via JSON em /api/v2/fii/reports. Você sai do "ler PDF" para um DataFrame em Python em ~10 linhas.
Este artigo cobre exclusivamente o endpoint /api/v2/fii/reports. Para
cotações, indicadores ou dividendos, veja a documentação completa.
O Que Vem na Resposta
Vamos olhar primeiro a estrutura do dado:
curl "https://brapi.dev/api/v2/fii/reports?symbols=HGLG11&limit=2&sortBy=referenceDate&sortOrder=desc"Resposta abreviada:
{
"reports": [
{
"symbol": "HGLG11",
"name": "CSHG Logística FII",
"cnpj": "11.728.688/0001-47",
"administratorName": "Credit Suisse Hedging-Griffo Cor. de Valores S.A.",
"administratorCnpj": "61.809.182/0001-30",
"referenceDate": "2025-03-31",
"version": 1,
// -- Patrimônio agregado --
"totalAssets": 10147254000.00, // Total de ativos (R$)
"equity": 9232380000.00, // Patrimônio líquido (R$)
"sharesOutstanding": 53850000, // Cotas emitidas
"navPerShare": 171.48, // VP por cota (R$)
// -- Custos e rentabilidade --
"adminFeeRate": 0.006, // Taxa de admin (% a.a.)
"monthlyReturn": -0.0032, // Retorno mensal da cota (%)
"monthlyPatrimonialReturn": 0.0089, // Retorno mensal patrimonial (%)
"monthlyDividendYield": 0.0072, // DY mensal (%)
"amortizationRate": 0.0, // Amortização (%)
"totalInvestors": 488272, // Cotistas
// -- Composição do ativo --
"cash": 452100000.00, // Caixa (R$)
"governmentBonds": 0.0, // Títulos públicos (R$)
"privateBonds": 0.0, // Títulos privados (R$)
"fixedIncomeFunds": 0.0, // Fundos de renda fixa (R$)
"totalInvested": 9695154000.00, // Total investido (R$)
"realEstateAssets": 8754300000.00, // Imóveis (R$)
"cri": 0.0, // CRI (R$)
"lci": 0.0, // LCI (R$)
"fiiHoldings": 312000000.00, // Cotas de outros FIIs (R$)
"receivables": 98500000.00, // Recebíveis (R$)
"rentalReceivables": 28354000.00, // Aluguéis a receber (R$)
// -- Passivo --
"distributionsPayable": 116730000.00,
"adminFeesPayable": 4892000.00,
"realEstateObligations": 412300000.00,
"totalLiabilities": 914874000.00
}
],
"pagination": { /* ... */ },
"requestedAt": "2026-05-09T14:00:00.000Z"
}Cada campo dá uma métrica que tradicionalmente exige horas lendo PDF. Vamos transformá-los em ferramentas analíticas.
O Que Vamos Construir
Um pipeline Python que:
- Puxa os últimos 12 meses de relatórios para uma carteira de FIIs
- Calcula composição patrimonial percentual (% em CRI vs imóveis vs caixa)
- Detecta mudanças relevantes entre meses (movimentação de portfólio)
- Compara taxa de administração com a mediana do segmento
- Identifica inconsistências (ex.: rentabilidade da cota muito diferente da patrimonial)
- Exporta um dossiê comparativo para Excel
Pré-requisitos
mkdir fii-reports && cd fii-reports
python -m venv .venv && source .venv/bin/activate
pip install requests pandas openpyxl matplotlib
export BRAPI_TOKEN="seu_token_pro"Passo 1: Cliente Mínimo
import os
import requests
from datetime import date, timedelta
BASE = "https://brapi.dev/api/v2/fii"
TOKEN = os.getenv("BRAPI_TOKEN", "")
HEADERS = {"Authorization": f"Bearer {TOKEN}"} if TOKEN else {}
def fetch_reports(
symbols: list[str],
start_date: str | None = None,
end_date: str | None = None,
all_versions: bool = False,
) -> list[dict]:
"""Busca relatórios CVM para uma lista de FIIs.
A API aceita até 20 símbolos por request e pagina internamente.
Por padrão retorna a versão mais recente de cada referenceDate.
"""
params = {
"symbols": ",".join(symbols),
"sortBy": "referenceDate",
"sortOrder": "desc",
"limit": 50,
}
if start_date:
params["startDate"] = start_date
if end_date:
params["endDate"] = end_date
if all_versions:
params["allVersions"] = "true"
reports: list[dict] = []
page = 1
while True:
params["page"] = page
resp = requests.get(f"{BASE}/reports", params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
data = resp.json()
reports.extend(data.get("reports", []))
pagination = data.get("pagination", {})
if not pagination.get("hasNextPage"):
break
page += 1
return reports
def last_n_months(n: int = 12) -> tuple[str, str]:
"""Retorna (startDate, endDate) cobrindo os últimos N meses."""
today = date.today()
end = today.isoformat()
# ~30 dias por mês
start = (today - timedelta(days=n * 31)).isoformat()
return start, endPasso 2: Composição Patrimonial Percentual
Uma das primeiras coisas a fazer com um relatório é entender onde o dinheiro está:
import pandas as pd
from brapi_reports import fetch_reports, last_n_months
# Campos do ativo que somam o patrimônio investido
ASSET_BUCKETS = {
"Imóveis": "realEstateAssets",
"CRI": "cri",
"LCI": "lci",
"Cotas de FIIs": "fiiHoldings",
"Caixa": "cash",
"Títulos Públicos": "governmentBonds",
"Títulos Privados": "privateBonds",
"Fundos RF": "fixedIncomeFunds",
"Recebíveis": "receivables",
"Aluguéis a receber": "rentalReceivables",
}
def composition_pct(report: dict) -> dict[str, float]:
"""Calcula % de cada bucket sobre o total de ativos."""
total = report.get("totalAssets") or 0
if total == 0:
return {label: 0.0 for label in ASSET_BUCKETS}
return {
label: ((report.get(field) or 0) / total) * 100
for label, field in ASSET_BUCKETS.items()
}
def composition_dataframe(symbols: list[str]) -> pd.DataFrame:
"""DataFrame com a composição mais recente de cada FII."""
start, end = last_n_months(2)
reports = fetch_reports(symbols, start_date=start, end_date=end)
# Pega o relatório mais recente de cada symbol
latest: dict[str, dict] = {}
for r in reports:
sym = r["symbol"]
if sym not in latest or r["referenceDate"] > latest[sym]["referenceDate"]:
latest[sym] = r
rows = []
for sym, report in latest.items():
row = {"symbol": sym, "referenceDate": report["referenceDate"]}
row.update(composition_pct(report))
rows.append(row)
return pd.DataFrame(rows).set_index("symbol")Uso:
df = composition_dataframe(["HGLG11", "MXRF11", "KNRI11", "BTLG11", "VISC11"])
print(df.round(1))Saída esperada:
referenceDate Imóveis CRI LCI Cotas de FIIs Caixa ...
symbol
HGLG11 2025-03-31 86.3 0.0 0.0 3.1 4.5 ...
MXRF11 2025-03-31 0.0 78.4 2.1 5.3 8.2 ...
KNRI11 2025-03-31 74.5 8.2 0.0 12.0 2.8 ...
BTLG11 2025-03-31 82.1 0.0 0.0 0.0 7.5 ...
VISC11 2025-03-31 91.8 0.0 0.0 0.0 1.5 ...Olhando as linhas, em 5 segundos você sabe:
- HGLG11 e VISC11 são tijolo puro (>85% imóveis)
- MXRF11 é papel puro (>78% em CRI)
- KNRI11 é híbrido (74% imóveis + 8% CRI + 12% cotas)
Passo 3: Detectando Mudanças no Portfólio
Um FII de tijolo que de repente aumenta caixa em 30% pode estar:
- Vendendo um imóvel (positivo? negativo?)
- Captando dinheiro novo (próxima emissão?)
- Acumulando para uma aquisição
Vamos detectar essas movimentações:
import pandas as pd
from brapi_reports import fetch_reports, last_n_months
from composition import ASSET_BUCKETS, composition_pct
def detect_composition_changes(
symbol: str, threshold_pp: float = 5.0
) -> pd.DataFrame:
"""Mostra mudanças mês-a-mês acima de threshold_pp pontos percentuais.
Útil para identificar movimentações relevantes (ex.: venda de imóvel,
aumento de caixa, aquisição de CRI novo).
"""
start, end = last_n_months(12)
reports = fetch_reports([symbol], start_date=start, end_date=end)
reports.sort(key=lambda r: r["referenceDate"])
rows = []
for r in reports:
row = {"referenceDate": r["referenceDate"]}
row.update(composition_pct(r))
rows.append(row)
df = pd.DataFrame(rows).set_index("referenceDate")
# Diferença mês-a-mês em pontos percentuais
diff = df.diff()
# Pega só meses com mudanças relevantes em algum bucket
significant = diff.abs().max(axis=1) >= threshold_pp
return diff[significant].round(2)
# Uso
print("Mudanças relevantes em HGLG11 (últimos 12 meses):")
print(detect_composition_changes("HGLG11", threshold_pp=3.0))Saída exemplo:
Imóveis CRI LCI Cotas de FIIs Caixa ...
referenceDate
2024-09-30 -3.5 0.0 0.0 0.0 +3.2 <- venda de imóvel?
2024-12-31 +4.1 0.0 0.0 -3.8 -0.5
2025-02-28 -0.2 0.0 0.0 0.0 -2.8Cada linha vira uma pergunta que você pode levar para a leitura qualitativa do relatório completo. Mais eficiente do que ler todos os meses.
Passo 4: Taxa de Administração vs Mediana do Segmento
A taxa de administração é o maior dreno silencioso de retorno de longo prazo. Vamos comparar a taxa de cada FII com a mediana do segmento:
import pandas as pd
from brapi_reports import fetch_reports, last_n_months
def admin_fee_benchmark(symbols: list[str]) -> pd.DataFrame:
"""Compara taxa de admin com a mediana do conjunto."""
start, end = last_n_months(2)
reports = fetch_reports(symbols, start_date=start, end_date=end)
# Mais recente por symbol
latest = {}
for r in reports:
sym = r["symbol"]
if sym not in latest or r["referenceDate"] > latest[sym]["referenceDate"]:
latest[sym] = r
df = pd.DataFrame([
{
"symbol": sym,
"adminFeeRate": (r.get("adminFeeRate") or 0) * 100,
"equity_bn": (r.get("equity") or 0) / 1_000_000_000,
}
for sym, r in latest.items()
]).set_index("symbol")
median = df["adminFeeRate"].median()
df["vs_median_bps"] = (df["adminFeeRate"] - median) * 100 # em bps
return df.sort_values("adminFeeRate")Uso:
fees = admin_fee_benchmark([
"HGLG11", "BTLG11", "XPLG11", "LVBI11", "BRCO11" # FIIs de logística grandes
])
print(fees.round(2))Saída exemplo:
adminFeeRate equity_bn vs_median_bps
symbol
BTLG11 0.55 4.8 -25.0 <- mais barato
HGLG11 0.60 9.2 -20.0
XPLG11 0.80 3.5 0.0 <- mediana
LVBI11 0.95 2.1 +15.0
BRCO11 1.10 1.8 +30.0 <- mais caroEm logística, 20 bps a menos por ano é diferença material em 10 anos de carteira.
Passo 5: Sanity Check — Cota vs Patrimônio
Um sinal que poucos investidores olham: a rentabilidade da cota vs a rentabilidade patrimonial.
monthlyReturn: rentabilidade da cota negociada na bolsa.monthlyPatrimonialReturn: rentabilidade do patrimônio efetivo (ativos – passivos).
Quando os dois divergem muito, há descolamento entre preço e fundamentos:
import pandas as pd
from brapi_reports import fetch_reports, last_n_months
def cota_vs_patrimonial(symbol: str) -> pd.DataFrame:
start, end = last_n_months(12)
reports = fetch_reports([symbol], start_date=start, end_date=end)
reports.sort(key=lambda r: r["referenceDate"])
rows = [
{
"referenceDate": r["referenceDate"],
"cota_pct": (r.get("monthlyReturn") or 0) * 100,
"patrimonial_pct": (r.get("monthlyPatrimonialReturn") or 0) * 100,
"spread_pp": ((r.get("monthlyReturn") or 0) - (r.get("monthlyPatrimonialReturn") or 0)) * 100,
}
for r in reports
]
return pd.DataFrame(rows).set_index("referenceDate").round(2)
print(cota_vs_patrimonial("HGLG11"))Saída exemplo:
cota_pct patrimonial_pct spread_pp
referenceDate
2024-06-30 1.2 0.9 +0.3
2024-07-31 -2.5 0.8 -3.3 <- cota caiu mas patrimônio cresceu
2024-08-31 -1.8 0.7 -2.5 <- mesma direção
2024-09-30 3.1 0.6 +2.5 <- recuperação
...Spreads negativos persistentes (cota cai enquanto patrimônio cresce) podem indicar:
- Janela de oportunidade (mercado pessimista temporariamente)
- Mudança estrutural (saída de cotistas grandes, mudança de gestão)
- Risco oculto que o mercado precificou antes do reporte
Combine com a leitura qualitativa do relatório para distinguir.
Passo 6: Dossiê Excel Comparativo
Vamos juntar tudo num arquivo Excel que você pode usar como dossiê mensal:
import pandas as pd
from brapi_reports import fetch_reports, last_n_months
from composition import composition_dataframe, ASSET_BUCKETS
from fees import admin_fee_benchmark
def build_dossier(symbols: list[str], output_path: str = "fii_dossier.xlsx") -> None:
start, end = last_n_months(12)
reports = fetch_reports(symbols, start_date=start, end_date=end)
df_reports = pd.DataFrame(reports)
composition = composition_dataframe(symbols)
fees = admin_fee_benchmark(symbols)
# Resumo executivo
latest_by_symbol = (
df_reports.sort_values("referenceDate")
.groupby("symbol")
.tail(1)
.set_index("symbol")
)
summary = pd.DataFrame({
"Última referência": latest_by_symbol["referenceDate"],
"Patrimônio (R$ bi)": latest_by_symbol["equity"] / 1e9,
"VP/Cota (R$)": latest_by_symbol["navPerShare"],
"Taxa Admin (% a.a.)": latest_by_symbol["adminFeeRate"] * 100,
"DY mensal (%)": latest_by_symbol["monthlyDividendYield"] * 100,
"Cotistas": latest_by_symbol["totalInvestors"],
}).round(2)
with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
summary.to_excel(writer, sheet_name="Resumo")
composition.round(1).to_excel(writer, sheet_name="Composição (%)")
fees.round(2).to_excel(writer, sheet_name="Taxas vs Mediana")
df_reports.to_excel(writer, sheet_name="Raw - 12 meses", index=False)
print(f"Dossiê salvo em {output_path}")
# Exemplo de uso
if __name__ == "__main__":
build_dossier(
symbols=["HGLG11", "BTLG11", "XPLG11", "LVBI11", "BRCO11", "VRTA11"],
output_path="dossier_logistica_2026-05.xlsx",
)Resultado: um Excel com 4 abas, pronto para enviar pro time, ou só pra você abrir 1x por mês e tirar conclusões.
Casos de Uso Avançados
A) Filtragem por Composição Mínima
Quer apenas FIIs híbridos com pelo menos 60% em imóveis e menos de 10% em CRI?
def filter_by_composition(
candidates: list[str],
min_real_estate_pct: float = 60.0,
max_cri_pct: float = 10.0,
) -> list[str]:
df = composition_dataframe(candidates)
mask = (df["Imóveis"] >= min_real_estate_pct) & (df["CRI"] <= max_cri_pct)
return df.index[mask].tolist()Combine com o screener para refinar duas vezes: primeiro filtros quantitativos no indicators, depois filtros estruturais no reports.
B) Detecção de Versionamento (Relatórios Retificados)
Use allVersions=true para capturar relatórios retificados — sinal de que o FII teve algum erro relevante na primeira versão:
reports = fetch_reports(["HGLG11"], all_versions=True)
# Agrupa por (symbol, referenceDate) e conta versões
from collections import defaultdict
versions = defaultdict(list)
for r in reports:
key = (r["symbol"], r["referenceDate"])
versions[key].append(r["version"])
retificacoes = {k: v for k, v in versions.items() if max(v) > 1}
print(f"Retificações detectadas: {len(retificacoes)}")C) Histórico de Cotistas
Crescimento ou retração de totalInvestors é um indicador de "popularidade" — útil em conjunto com indicadores fundamentais:
def evolution_investors(symbol: str) -> pd.DataFrame:
start, end = last_n_months(24)
reports = fetch_reports([symbol], start_date=start, end_date=end)
reports.sort(key=lambda r: r["referenceDate"])
return pd.DataFrame([
{
"referenceDate": r["referenceDate"],
"investors": r.get("totalInvestors") or 0,
"growth_mom_pct": None, # preenchido abaixo
}
for r in reports
])Limitações Importantes
Os relatórios CVM não trazem dados de vacância física explícita, nem rent roll por inquilino. Esses dados quando existem estão em PDFs complementares enviados pelos administradores. Para análise qualitativa profunda de FIIs de tijolo, complemente com leitura dos relatórios em PDF.
O campo monthlyDividendYield é o DY mensal calculado sobre o NAV por cota,
não sobre o preço de mercado. Para DY sobre preço, use
/api/v2/fii/indicators que retorna
dividendYield12m e dividendYield1m calculados sobre o preço de fechamento.
Conclusão
Os relatórios CVM são a melhor fonte primária sobre um FII — e historicamente eram inacessíveis a quem não tinha tempo para ler PDFs todo mês. Com o endpoint /api/v2/fii/reports e ~200 linhas de Python, você passa a ter:
- Dossiê mensal automatizado para cada FII da carteira
- Detecção de mudanças relevantes na composição patrimonial
- Benchmark de taxas vs mediana do segmento
- Identificação de descolamento cota-patrimônio
A diferença entre um investidor de FII passivo e um investidor que sabe o que tem na carteira é exatamente isso: três horas de Python por mês.
Próximos passos: se você ainda não construiu um screener para descobrir candidatos antes da análise CVM, veja o tutorial de FII Screener com Python. Para um overview do mercado, comece pelo guia completo de FIIs.
Disclaimer: este artigo é educacional e não constitui recomendação de investimento. Leia sempre o regulamento e prospecto de cada fundo antes de investir.
