Quanto vale o metro quadrado no Recife? Sete anos de ITBI (2020–2026)

1. Introdução

1.1 O problema

Comprar um imóvel é a maior decisão financeira da maioria das famílias, e quem acompanha o mercado do Recife ouve com frequência que “o metro quadrado só sobe”. Mas o que sobe? O preço em reais correntes, ou o valor de verdade, depois de descontada a inflação? E sobe em todos os bairros e tipos de imóvel, ou apenas em alguns segmentos que passam a ser mais negociados?

Este relatório investiga essas perguntas com os registros de ITBI (Imposto sobre a Transmissão de Bens Imóveis) publicados pela Prefeitura do Recife. São dados administrativos de transações efetivamente registradas, e não preços de anúncio, e cobrem a cidade inteira por sete anos, período que inclui a pandemia e o ciclo recente de juros altos. As perguntas que guiam a análise são:

  1. O volume de transações mudou ao longo do período?
  2. O preço por m² mudou em termos reais (descontada a inflação)?
  3. As mudanças nas medianas refletem o mercado ou apenas uma troca no tipo de imóvel negociado (efeito composição)?
  4. Quais bairros mais se valorizaram, e onde está o valor mais alto?
  5. O que explica o preço: padrão de acabamento, idade, tamanho, andar, localização?
  6. O que o “valor de avaliação” registrado no ITBI realmente representa?

1.2 Dados e método em resumo

Foram usados os sete arquivos anuais (2020 a 2026) do conjunto Imposto sobre a Transmissão de Bens Imóveis (ITBI) do Portal de Dados Abertos do Recife, importados diretamente da web. Os arquivos foram empilhados e padronizados (separador, formato decimal, nomes de bairro, datas), enriquecidos com variáveis novas (preço por m², idade, andar, edifício extraído de um campo de texto livre) e deflacionados pelo IPCA, obtido do Ipeadata, para que valores de anos diferentes sejam comparáveis. A análise foi feita sobre apartamentos e casas, depois de filtros documentados.

1.3 Abordagem adotada

  • Medianas em vez de médias, porque o preço por m² tem cauda longa.
  • Deflação pelo IPCA para separar valorização real de inflação.
  • Índice de composição fixa, que compara o preço dentro de segmentos equivalentes (padrão × faixa de área) para isolar o efeito composição.
  • Ranking de bairros com mínimo de transações por janela de tempo.
  • Análise dentro do mesmo prédio para medir o prêmio de andar.
  • Regressão hedônica (log-linear), que estima o efeito de cada característica mantendo as demais constantes.
  • Teste de sensibilidade às transações duplicadas.

1.4 Para quem isso importa

Compradores e vendedores ganham uma referência de preço em valores reais, por bairro e por segmento. Corretores e incorporadoras enxergam em que segmento o mercado de fato se movimentou. O poder público e analistas de mercado ganham um diagnóstico sobre o que a própria base do ITBI mede, o que importa para quem usa esses dados em estimativas de valor e de arrecadação.

2. Pacotes requeridos

Todos os pacotes são carregados aqui, no início. Para reproduzir a análise, instale os que faltarem com install.packages().

library(tidyverse)  # dplyr, tidyr, ggplot2, stringr, lubridate, purrr, forcats: manipulação, datas, texto e gráficos
library(janitor)    # clean_names() e remove_constant(): padroniza nomes de colunas e remove colunas sem informação
library(jsonlite)   # lê a resposta JSON da API do portal para descobrir os links dos arquivos
library(stringi)    # remove acentos para padronizar nomes de bairros
library(ipeadatar)  # baixa o IPCA do Ipeadata, usado para deflacionar valores
library(knitr)      # tabelas estáticas
library(kableExtra) # formatação das tabelas
library(DT)         # tabelas interativas
library(plotly)     # gráficos interativos
library(leaflet)    # mapa interativo
library(scales)     # formatos de eixos e rótulos (R$, %)
library(broom)      # resultados do modelo em formato de tabela
Pacote Para que foi usado
tidyverse Importação, limpeza, transformação e gráficos em todo o relatório
janitor Padronizar nomes de colunas; remover colunas constantes
jsonlite Consultar a API do portal para obter os links dos arquivos
stringi Remover acentos dos nomes dos bairros
ipeadatar Obter a série do IPCA para a deflação (pacote fora do conteúdo da disciplina)
knitr, kableExtra Tabelas formatadas
DT Tabela interativa do ranking de bairros
plotly Gráficos interativos (zoom, tooltip)
leaflet Mapa interativo dos preços por bairro
scales Formatação de valores em R$ e porcentagem
broom Transformar o modelo de regressão em tabela
# Funções auxiliares de formatação (padrão brasileiro) usadas nas tabelas e no texto
# trim = TRUE evita que format() complete com espaços à esquerda quando recebe vários valores
fmt_n   <- \(x) format(round(x), big.mark = ".", decimal.mark = ",", scientific = FALSE, trim = TRUE)
fmt_brl <- \(x) paste0("R$ ", format(round(x), big.mark = ".", decimal.mark = ",", scientific = FALSE, trim = TRUE))
fmt_pct <- \(x, d = 1) paste0(ifelse(x > 0, "+", ""), format(round(100 * x, d), decimal.mark = ",", trim = TRUE), "%")
fmt_d   <- \(x, d = 1) format(round(x, d), decimal.mark = ",", trim = TRUE)                 # decimal com vírgula
fmt_p   <- \(x, d = 0) paste0(format(round(100 * x, d), decimal.mark = ",", trim = TRUE), "%")   # proporção sem sinal

# percent() com padrão brasileiro (vírgula decimal): substitui scales::percent no restante do relatório
percent <- \(x, accuracy = NULL, ...) scales::percent(x, accuracy = accuracy, big.mark = ".", decimal.mark = ",", ...)

# Tabela padrão do relatório
tab <- function(x, ...) {
  x <- x |> mutate(across(any_of(c("ano", "Ano", "ano_arquivo")), as.character))  # anos sem separador de milhar
  kable(x, format = "html", format.args = list(big.mark = ".", decimal.mark = ","), ...) |>
    kable_styling(bootstrap_options = c("striped", "condensed", "hover"), full_width = FALSE)
}

3. Preparação dos dados

3.1 Fonte

Os dados vêm do Portal de Dados Abertos da Prefeitura do Recife, conjunto Imposto sobre a Transmissão de Bens Imóveis (ITBI): https://dados.recife.pe.gov.br/dataset/imposto-sobre-transmissao-de-bens-imoveis-itbi. O portal publica os dados sob a licença Open Data Commons Open Database License (ODbL). A série de inflação (IPCA, número-índice com base dez/1993 = 100, código PRECOS12_IPCA12) vem do Ipeadata (http://www.ipeadata.gov.br), acessado pelo pacote ipeadatar.

3.2 O que são os dados

O conjunto reúne a relação de transações imobiliárias com recolhimento de ITBI, com as características do imóvel e informações da operação. Há um arquivo CSV por ano; este trabalho usa os de 2020 a 2026. Cada linha é uma transação, e cada arquivo tem 22 ou 23 colunas. Algumas particularidades, descobertas ao longo da importação, moldaram a limpeza:

  • O separador é ponto e vírgula (;), e não vírgula. Lido como CSV comum, o arquivo vira uma coluna só.
  • O formato decimal muda entre os anos. Áreas e fração ideal usam vírgula decimal em todos os anos, mas o valor_avaliacao usa ponto de 2020 a 2024 e vírgula em 2025 e 2026. Tratar tudo da mesma forma inflaria os valores em 100 vezes sem emitir erro.
  • A coluna Distrito existe só em 2020–2024, o que faz o esquema diferir entre os anos.
  • Valores ausentes aparecem como células vazias. As coordenadas (latitude, longitude) faltam em parte relevante das transações; o bairro pode vir como “Indefinido” (raro).
  • Texto não padronizado: logradouros em minúsculas, bairros às vezes sem acento (Varzea), e o complemento mistura tipo de unidade, número, bloco e nome do edifício em texto livre.
  • O campo ano_construcao se comporta de forma diferente ao longo do tempo (veremos na seção 4.7).
  • Há linhas totalmente idênticas, que podem ser registros repetidos ou unidades indistinguíveis.
Variável Descrição
logradouro, numero, complemento, bairro, cod_logradouro Endereço (complemento em texto livre)
valor_avaliacao Valor de avaliação do imóvel registrado no ITBI (não é, necessariamente, o preço de venda)
area_terreno, area_construida Áreas em m² (em apartamentos, a área do terreno provavelmente se refere ao terreno do prédio)
fracao_ideal Fração ideal do terreno atribuída à unidade
ano_construcao Ano de construção informado
padrao_acabamento Simples, Médio ou Superior
tipo_construcao, tipo_imovel, tipo_ocupacao, estado_conservacao Características do imóvel
data_transacao, ano Data e ano da transação
sfh Valor associado ao Sistema Financeiro da Habitação (interpretação a confirmar no dicionário do portal)
latitude, longitude Coordenadas (com muitos ausentes)
distrito Distrito (somente 2020–2024)

3.3 Importação e limpeza

Passo 1: obter os arquivos

Os arquivos são baixados do portal pela API (CKAN) quando ainda não existem na pasta dados/. Isso torna a análise reproduzível e, ao mesmo tempo, evita baixar de novo a cada execução.

anos <- 2020:2026
arquivos <- tibble(ano_arquivo = anos,
                   destino = file.path("dados", paste0("itbi_", anos, ".csv")))
dir.create("dados", showWarnings = FALSE)

if (!all(file.exists(arquivos$destino))) {
  # A API lista os recursos (um por ano) e seus links de download
  meta <- fromJSON("https://dados.recife.pe.gov.br/api/3/action/package_show?id=imposto-sobre-transmissao-de-bens-imoveis-itbi")
  recursos <- meta$result$resources |>
    filter(str_detect(name, "202[0-6]")) |>
    mutate(ano_arquivo = as.integer(str_extract(name, "\\d{4}")))

  baixar <- arquivos |>
    left_join(select(recursos, ano_arquivo, url), by = "ano_arquivo") |>
    filter(!file.exists(destino))
  walk2(baixar$url, baixar$destino, \(u, d) download.file(u, d, mode = "wb", quiet = TRUE))
}
stopifnot(all(file.exists(arquivos$destino)))

Passo 2: ler e empilhar

Cada arquivo é lido inteiramente como texto, com o separador correto. Ler como texto evita que o R “adivinhe” tipos diferentes em anos diferentes; a conversão é feita de forma controlada no passo 4.

ler_itbi <- \(f) read_delim(f, delim = ";", col_types = cols(.default = "c"),
                            locale = locale(encoding = "UTF-8"))
itbi_lista <- map(arquivos$destino, ler_itbi) |> set_names(arquivos$ano_arquivo)

# Conferência: linhas e colunas por arquivo
imap_dfr(itbi_lista, \(d, n) tibble(Ano = n, Linhas = nrow(d), Colunas = ncol(d))) |> tab()
Ano Linhas Colunas
2020 10.837 23
2021 13.855 23
2022 14.852 23
2023 12.670 23
2024 15.243 23
2025 16.318 22
2026 13.852 22
# Colunas que existem em alguns anos e não em todos
colunas_por_ano <- map(itbi_lista, names)
so_alguns <- setdiff(reduce(colunas_por_ano, union), reduce(colunas_por_ano, intersect))
so_alguns_txt <- paste0("`", so_alguns, "`", collapse = ", ")   # para citar no texto
n_comuns <- length(reduce(colunas_por_ano, intersect))   # colunas presentes em todos os anos
n_uniao  <- length(reduce(colunas_por_ano, union))       # colunas que aparecem em algum ano

Os arquivos de 2025 e 2026 têm uma coluna a menos: Distrito existe apenas nos anos anteriores. Ao empilhar, os anos sem essa coluna recebem NA, e voltaremos a ela no passo 5.

itbi_raw <- bind_rows(itbi_lista, .id = "ano_arquivo") |>
  clean_names() |>                                   # nomes em minúsculas e sem espaços
  mutate(ano_arquivo = as.integer(ano_arquivo))

A base importada tem 97.627 transações e 24 colunas. Esse número se explica assim: os arquivos originais têm 22 colunas comuns a todos os anos, mais Distrito nos arquivos de 2020 a 2024 (23 colunas distintas no total), e eu acrescentei ano_arquivo, que registra de qual arquivo veio cada linha (23 + 1 = 24). Ou seja, o conjunto original tem 22 ou 23 colunas por arquivo e 97.627 linhas no total, bem acima dos mínimos exigidos pelo projeto (mais de 5 mil linhas e mais de 10 colunas).

Passo 3: padronizar texto

Os nomes de bairro variam em acentuação entre os anos (por exemplo, Poço e Poco). Removê-los e padronizar a caixa evita tratar o mesmo bairro como dois. “Indefinido” vira ausente.

normaliza <- \(x) x |> stri_trans_general("Latin-ASCII") |> str_squish() |> str_to_title()
itbi_raw <- itbi_raw |> mutate(bairro_pad = normaliza(bairro), .after = bairro)

Passo 4: converter tipos (a armadilha do decimal)

Veja a primeira linha de cada ano: o valor muda de ponto para vírgula decimal em 2025.

itbi_raw |>
  slice_head(n = 1, by = ano_arquivo) |>
  select(ano_arquivo, valor_avaliacao, area_construida, latitude) |>
  tab(caption = "Formato dos números, primeira linha de cada ano")
Formato dos números, primeira linha de cada ano
ano_arquivo valor_avaliacao area_construida latitude
2020 172000.00 102,11 -8.034668
2021 400000.00 118,64 -8.034996
2022 938127.63 172,8 -8.034174
2023 1068562.63 511,0 -8.034273
2024 1068562.63 511,0 -8.034273
2025 1068562,63 511 -8.034273
2026 448283,29 118,64 -8.034996

Converter com “vírgula = decimal, ponto = milhar” multiplicaria por 100 os valores de 2020 a 2024. Como nenhum campo traz separador de milhar, a solução segura é trocar a vírgula por ponto e converter com as.numeric(), o que funciona nos dois formatos. Em seguida, o R confere se alguma conversão falhou.

num <- \(x) as.numeric(str_replace(x, ",", "."))
cols_num <- c("valor_avaliacao", "area_terreno", "area_construida",
              "fracao_ideal", "sfh", "latitude", "longitude")

itbi <- itbi_raw |>
  mutate(across(all_of(cols_num), num),
         across(c(numero, ano_construcao, cod_logradouro, ano), as.integer),
         data_transacao = ymd(data_transacao),
         bairro = na_if(bairro_pad, "Indefinido")) |>
  select(-bairro_pad, -ano_arquivo)

# Conferência: conversões que viraram NA sem estar vazias na origem (esperado: 0)
falhas <- map_int(cols_num, \(c) sum(is.na(itbi[[c]]) & !is.na(itbi_raw[[c]])))

Conversões que falharam: 0. As datas batem com o ano da transação em todas as linhas? Sim.

Passo 5: o distrito (mesclar informações)

O distrito só existe de 2020 a 2024. Ele depende do logradouro, e não do bairro: ruas de um mesmo bairro podem pertencer a distritos diferentes. Por isso, 2025 e 2026 são preenchidos por uma tabela logradouro → distrito, usando só os logradouros sem ambiguidade. O restante fica ausente, sem inventar valores, e a coluna distrito_origem registra a procedência.

n_distinct_por_logradouro <- itbi |>
  filter(!is.na(distrito)) |>
  summarise(n = n_distinct(distrito), .by = cod_logradouro) |>
  pull(n)

lookup_distrito <- itbi |>
  filter(!is.na(distrito)) |>
  distinct(cod_logradouro, distrito) |>
  filter(n() == 1, .by = cod_logradouro) |>          # só logradouros com distrito único
  rename(distrito_ref = distrito)

itbi <- itbi |>
  left_join(lookup_distrito, by = "cod_logradouro") |>
  mutate(distrito_origem = case_when(!is.na(distrito)     ~ "original",
                                     !is.na(distrito_ref) ~ "preenchido",
                                     TRUE                 ~ "ausente"),
         distrito = coalesce(distrito, distrito_ref)) |>
  select(-distrito_ref)

itbi |> count(ano, distrito_origem) |>
  pivot_wider(names_from = distrito_origem, values_from = n, values_fill = 0) |>
  tab(caption = "Origem do distrito por ano")
Origem do distrito por ano
ano original ausente preenchido
2020 10.837 0 0
2021 13.855 0 0
2022 14.852 0 0
2023 12.670 0 0
2024 15.243 0 0
2025 0 1.364 14.954
2026 0 1.698 12.154

Entre os logradouros com distrito conhecido, 99,2% têm um único distrito, o que sustenta usar o logradouro como chave de correspondência.

Passo 6: colunas sem informação e duplicatas

cidade e uf têm um único valor (Recife/PE) e não carregam informação. Já as linhas totalmente idênticas não são apagadas: são marcadas. Podem ser registros repetidos, mas também unidades diferentes que o dado não distingue (18% das linhas repetidas não têm complemento, o dobro da base). A base principal exclui as repetições, e a análise de sensibilidade (seção 4.9) mostra se isso importa.

colunas_antes <- names(itbi)
itbi <- remove_constant(itbi)
colunas_removidas <- setdiff(colunas_antes, names(itbi))
colunas_removidas_txt <- paste0("`", colunas_removidas, "`", collapse = ", ")

itbi <- itbi |>
  mutate(n_copias = n(), copia_n = row_number(), .by = everything()) |>
  mutate(duplicado = copia_n > 1)                    # TRUE a partir da 2ª cópia

Colunas removidas: cidade, uf. Linhas excedentes marcadas como duplicadas: 1.717 (1,8% da base).

Passo 7: criar variáveis

itbi <- itbi |>
  mutate(
    valor_m2          = valor_avaliacao / area_construida,        # preço por m²
    idade_imovel      = ano - ano_construcao,                     # negativo = construção "futura"
    idade_pos         = pmax(idade_imovel, 0),
    construcao_futura = ano_construcao > ano,
    ano_mes           = floor_date(data_transacao, "month"),
    usa_sfh           = sfh > 0,
    predio_id         = paste(cod_logradouro, numero, sep = "_"), # identifica o prédio pelo endereço
    grupo_imovel      = case_when(
      tipo_imovel %in% c("Apartamento", "Casa") ~ "Residencial",
      tipo_imovel %in% c("Sala", "Loja", "Centro Comercial/Serviços") ~ "Comercial",
      TRUE ~ "Outros")
  )

Passo 8: separar o campo complemento

O complemento traz, em texto livre, tudo junto, como apto 1002 edf luar do rosarinho. Ele foi separado em tipo de unidade, unidade, bloco, torre e edifício com expressões regulares, e o andar foi estimado a partir dos dígitos da unidade (os dois últimos são o número do apartamento). Isso é uma aproximação, já que a numeração varia entre prédios.

itbi <- itbi |>
  mutate(
    tipo_unidade = str_extract(complemento, "^[a-z]+"),
    unidade      = str_match(complemento, "^[a-z]+\\.?\\s+(\\d+[a-z]?)\\b")[, 2],
    bloco        = str_match(complemento, "\\b(?:bloco|bl\\.?)\\s*([a-z0-9]+)")[, 2],
    torre        = str_match(complemento, "\\btorre\\s*([a-z0-9]+)")[, 2],
    # nome do edifício: o que vem depois de "edf"; se não houver, o que sobra do texto
    edificio_edf = str_match(complemento,
                             "\\bedf\\.?\\s+(.+?)(?=\\s*-|\\s+bloco|\\s+torre|$)")[, 2],
    residuo      = complemento |>
      str_remove("^[a-z]+\\.?(\\s+\\d+[a-z]?)?\\s*") |>
      str_remove_all("\\b(bloco|bl|torre)\\.?\\s*[a-z0-9]+") |>
      str_squish() |>
      str_remove("^conj\\.?\\s+(res\\.?\\s+)?") |>
      str_replace_all("-", " ") |> str_squish(),
    edificio     = coalesce(edificio_edf, na_if(residuo, "")),
    andar        = if_else(tipo_unidade == "apto" & str_detect(unidade, "^\\d{3,4}$"),
                           as.integer(str_sub(unidade, 1, -3)), NA_integer_)
  ) |>
  select(-edificio_edf, -residuo)

Passo 9: filtrar valores impossíveis

O preço por m² tem lixo nas pontas: valores positivos simbólicos (menos de R$ 1 por m²) e máximos de centenas de milhares de reais por m². Em vez de cortes arbitrários, foram usadas cercas de 3×IQR na escala logarítmica, calculadas dentro de cada tipo de imóvel (só para tipos com ao menos 100 transações). Isso só remove extremos.

limites <- itbi |>
  filter(valor_avaliacao > 0, area_construida > 0) |>
  summarise(n = n(), q1 = quantile(log(valor_m2), .25), q3 = quantile(log(valor_m2), .75),
            .by = tipo_imovel) |>
  filter(n >= 100) |>
  mutate(lim_inf = exp(q1 - 3 * (q3 - q1)), lim_sup = exp(q3 + 3 * (q3 - q1)))

itbi <- itbi |>
  left_join(select(limites, tipo_imovel, lim_inf, lim_sup), by = "tipo_imovel") |>
  mutate(valor_m2_invalido = valor_avaliacao <= 0 | area_construida <= 0,
         outlier_m2 = !valor_m2_invalido & !is.na(lim_inf) &
           (valor_m2 < lim_inf | valor_m2 > lim_sup))

Passo 10: deflacionar pelo IPCA (mesclar com outra fonte)

Para comparar 2020 com 2026, todos os valores são convertidos para reais do último mês com IPCA publicado. Transações em meses ainda sem índice usam fator 1 (aproximação, assinalada em sem_ipca). A série é guardada em dados/ipca.rds para não depender da internet nas execuções seguintes.

arq_ipca <- "dados/ipca.rds"
if (file.exists(arq_ipca)) {
  ipca <- readRDS(arq_ipca)
} else {
  ipca <- ipeadata("PRECOS12_IPCA12") |>
    transmute(ano_mes = as.Date(floor_date(date, "month")), ipca_idx = value) |>
    filter(ano_mes >= ymd("2020-01-01"))
  saveRDS(ipca, arq_ipca)
}

mes_base <- max(ipca$ano_mes)
base_idx <- ipca$ipca_idx[ipca$ano_mes == mes_base]
mes_base_txt <- format(mes_base, "%m/%Y")

itbi <- itbi |>
  left_join(ipca, by = "ano_mes") |>
  mutate(sem_ipca      = is.na(ipca_idx),
         ipca_idx      = coalesce(ipca_idx, base_idx),
         fator         = base_idx / ipca_idx,
         valor_real    = valor_avaliacao * fator,
         valor_m2_real = valor_m2 * fator)

Os valores reais deste relatório estão em R$ de 09/2026. Transações sem índice publicado: 100.

Passo 11: as bases de análise

# Base principal: apartamentos e casas, sem outliers de R$/m² e sem duplicatas
itbi_analise <- itbi |>
  filter(!valor_m2_invalido, !outlier_m2, grupo_imovel == "Residencial", !duplicado)

# Base de sensibilidade: igual, mas com as duplicatas
itbi_analise_sens <- itbi |>
  filter(!valor_m2_invalido, !outlier_m2, grupo_imovel == "Residencial")

funil <- tibble(
  Etapa  = c("Base importada (2020–2026)", "Valor e área válidos", "Sem outliers de R$/m²",
             "Apenas apartamentos e casas", "Sem duplicatas (base principal)"),
  Linhas = c(nrow(itbi), sum(!itbi$valor_m2_invalido),
             sum(!itbi$valor_m2_invalido & !itbi$outlier_m2),
             nrow(itbi_analise_sens), nrow(itbi_analise)))
tab(funil, caption = "Funil de filtros")
Funil de filtros
Etapa Linhas
Base importada (2020–2026) 97.627
Valor e área válidos 97.623
Sem outliers de R$/m² 96.394
Apenas apartamentos e casas 85.138
Sem duplicatas (base principal) 83.720

3.4 O conjunto de dados final

A base principal tem 83.720 transações de apartamentos e casas, com 48 colunas. Esse número é maior que o dos arquivos originais porque inclui as variáveis que criei: são 21 colunas originais (as 23 dos arquivos, exceto cidade e uf, que foram removidas por não variarem) e 27 colunas criadas neste trabalho (marcações de duplicata e de outlier, variáveis derivadas, o edifício e o andar extraídos do complemento, a origem do distrito e os valores deflacionados). A amostra abaixo mostra só as colunas centrais, em 10 linhas:

set.seed(42)
itbi_analise |>
  select(Data = data_transacao, Bairro = bairro, Tipo = tipo_imovel, Padrão = padrao_acabamento,
         `Área (m²)` = area_construida, `Idade` = idade_pos, Andar = andar,
         `Valor avaliação (R$)` = valor_avaliacao, `R$/m² real` = valor_m2_real) |>
  slice_sample(n = 10) |>
  tab(digits = 0)
Data Bairro Tipo Padrão Área (m²) Idade Andar Valor avaliação (R$) R$/m² real
2025-10-28 Guabiraba Apartamento Superior 60 0 2 220.000 3.845
2024-05-08 Boa Viagem Apartamento Superior 57 12 5 255.946 5.016
2026-01-14 Campo Grande Apartamento Médio 102 26 6 275.210 2.803
2024-10-17 Passarinho Apartamento Superior 71 1 12 200.000 3.061
2024-05-28 Rosarinho Apartamento Superior 201 15 14 1.200.000 6.617
2021-09-21 Areias Casa Simples 122 39 – 120.000 1.270
2025-07-11 Macaxeira Apartamento Simples 72 36 1 160.000 2.347
2026-08-05 Boa Vista Apartamento Médio 87 65 7 180.000 2.075
2020-10-01 Boa Viagem Apartamento Médio 100 16 6 364.126 5.168
2025-11-03 Casa Amarela Apartamento Superior 103 2 6 400.000 4.059

3.5 Resumo das variáveis de interesse

vars_num <- c(`Valor de avaliação (R$)` = "valor_avaliacao", `R$/m² nominal` = "valor_m2",
              `R$/m² real` = "valor_m2_real", `Área construída (m²)` = "area_construida",
              `Idade (anos)` = "idade_pos", `Andar (aprox.)` = "andar")

resumo_num <- itbi_analise |>
  select(all_of(vars_num)) |>
  pivot_longer(everything(), names_to = "Variável", values_to = "x") |>
  summarise(Válidos = sum(!is.na(x)), `% ausente` = 100 * mean(is.na(x)),
            Mínimo = min(x, na.rm = TRUE), Q1 = quantile(x, .25, na.rm = TRUE),
            Mediana = median(x, na.rm = TRUE), Média = mean(x, na.rm = TRUE),
            Q3 = quantile(x, .75, na.rm = TRUE), Máximo = max(x, na.rm = TRUE),
            .by = `Variável`)
tab(resumo_num, digits = 1, caption = "Variáveis numéricas (base principal)")
Variáveis numéricas (base principal)
Variável Válidos % ausente Mínimo Q1 Mediana Média Q3 Máximo
Valor de avaliação (R$) 83.720 0,0 20.000,0 249.316,8 356.713,8 526.936,8 560.000,0 32.670.848,0
R$/m² nominal 83.720 0,0 330,5 2.860,7 3.667,6 3.935,7 4.611,2 29.462,7
R$/m² real 83.720 0,0 356,0 3.324,6 4.349,4 4.562,0 5.319,7 38.094,3
Área construída (m²) 83.720 0,0 5,8 74,7 98,8 128,0 153,7 3.869,5
Idade (anos) 83.720 0,0 0,0 1,0 12,0 18,2 30,0 87,0
Andar (aprox.) 74.302 11,2 0,0 3,0 7,0 8,7 13,0 42,0
vars_cat <- c(Bairro = "bairro", Distrito = "distrito", `Tipo de imóvel` = "tipo_imovel",
              `Padrão de acabamento` = "padrao_acabamento", `Tipo de construção` = "tipo_construcao",
              `Estado de conservação` = "estado_conservacao", `Tipo de ocupação` = "tipo_ocupacao")

resumo_cat <- itbi_analise |>
  select(all_of(vars_cat)) |>
  mutate(across(everything(), as.character)) |>
  pivot_longer(everything(), names_to = "Variável", values_to = "valor") |>
  filter(!is.na(valor)) |>
  count(`Variável`, valor) |>
  summarise(Categorias = n(), `Mais comum` = valor[which.max(n)],
            `% da mais comum` = 100 * max(n) / sum(n), .by = `Variável`)
tab(resumo_cat, digits = 1, caption = "Variáveis categóricas (base principal)")
Variáveis categóricas (base principal)
Variável Categorias Mais comum % da mais comum
Bairro 89 Boa Viagem 26,6
Distrito 6 6 36,3
Estado de conservação 3 Bom 98,9
Padrão de acabamento 3 Superior 53,2
Tipo de construção 4 Apartamento > 4 Pavimentos 81,8
Tipo de imóvel 2 Apartamento 91,6
Tipo de ocupação 3 RESIDENCIAL 97,6

Em síntese: a base cobre 89 bairros, de 02/01/2020 a 02/10/2026. 92% são apartamentos, 27% ficam em Boa Viagem e 53% têm padrão Superior. O imóvel mediano tem 99 m² e custa R$ 4.349 por m² (em R$ de 09/2026). O estado de conservação quase não varia (99% “Bom”), por isso não será usado como explicação. Por fim, 35% das transações não têm coordenadas, o que limita o mapa.

Variáveis criadas neste trabalho:

Variável Como foi construída
valor_m2, valor_m2_real valor_avaliacao / area_construida; e o mesmo deflacionado pelo IPCA
idade_pos Ano da transação menos ano de construção, com mínimo 0
construcao_futura Ano de construção posterior ao da transação
edificio, unidade, bloco, torre, andar Extraídas do campo complemento
predio_id Código do logradouro e número, que identifica o prédio
distrito_origem Se o distrito é original, preenchido ou ausente
duplicado, outlier_m2, grupo_imovel Marcações para filtros e recortes

4. Análise exploratória

4.1 O volume de transações

vol <- itbi_analise |> count(ano_mes)

p_vol <- ggplot(vol, aes(ano_mes, n, text = paste0(format(ano_mes, "%m/%Y"), ": ", fmt_n(n), " transações"))) +
  geom_col(fill = "#2c7fb8") +
  scale_x_date(date_breaks = "6 months", date_labels = "%m/%y") +
  labs(title = "Transações de apartamentos e casas por mês",
       x = NULL, y = "Transações") +
  theme_minimal()
ggplotly(p_vol, tooltip = "text")
meses_baixos <- vol |> filter(n < 0.3 * median(n)) |> mutate(rotulo = format(ano_mes, "%m/%Y"))
txt_baixos <- if (nrow(meses_baixos) > 0) paste(meses_baixos$rotulo, collapse = ", ") else "nenhum"

por_ano <- itbi_analise |> count(ano, name = "Transações")
tab(por_ano, caption = "Transações por ano (2026 vai até a data final da base)")
Transações por ano (2026 vai até a data final da base)
ano Transações
2020 9.537
2021 12.097
2022 11.657
2023 10.692
2024 13.493
2025 14.196
2026 12.048

O ano de menor volume é 2020 e o de maior é 2025. Meses com volume muito abaixo do típico (menos de 30% da mediana mensal): 12/2025, 10/2026. Em geral isso indica mês incompleto na coleta (o último mês da base) ou lacuna de registro, não ausência de mercado, e deve ser lido com cautela.

4.2 O m² ficou mais caro de verdade?

serie_ano <- itbi_analise |>
  summarise(n = n(), nominal = median(valor_m2), real = median(valor_m2_real),
            real_q1 = quantile(valor_m2_real, .25), real_q3 = quantile(valor_m2_real, .75),
            .by = ano) |>
  arrange(ano)

var_nom  <- last(serie_ano$nominal) / first(serie_ano$nominal) - 1
var_real <- last(serie_ano$real)    / first(serie_ano$real)    - 1
queda_max <- min(serie_ano$real / first(serie_ano$real) - 1)

serie_long <- serie_ano |>
  select(ano, nominal, real) |>
  pivot_longer(-ano, names_to = "serie", values_to = "valor") |>
  mutate(serie = recode(serie, nominal = "Nominal (R$ correntes)",
                        real = paste0("Real (R$ de ", mes_base_txt, ")")))

p_serie <- ggplot() +
  geom_ribbon(data = serie_ano, aes(ano, ymin = real_q1, ymax = real_q3), fill = "#fc8d59", alpha = .15) +
  geom_line(data = serie_long, aes(ano, valor, color = serie), linewidth = 1.1) +
  geom_point(data = serie_long, aes(ano, valor, color = serie,
             text = paste0(ano, " | ", serie, ": ", fmt_brl(valor), "/m²")), size = 2.5) +
  scale_color_manual(values = c("#2c7fb8", "#d95f0e"), name = NULL) +
  scale_x_continuous(breaks = 2020:2026) +
  scale_y_continuous(labels = label_number(prefix = "R$ ", big.mark = ".")) +
  labs(title = "Mediana do R$/m²: nominal vs. real (apartamentos e casas)",
       subtitle = "Faixa laranja: intervalo interquartil do valor real",
       x = NULL, y = "R$ por m²") +
  theme_minimal() + theme(legend.position = "bottom")
ggplotly(p_serie, tooltip = "text") |> layout(legend = list(orientation = "h", y = -0.15))
serie_ano |>
  transmute(Ano = ano, Transações = n, `Nominal` = nominal, `Real` = real,
            `Índice real (2020 = 100)` = 100 * real / first(real)) |>
  tab(digits = 0, caption = paste0("Mediana de R$/m² (valores reais em R$ de ", mes_base_txt, ")"))
Mediana de R$/m² (valores reais em R$ de 09/2026)
Ano Transações Nominal Real Índice real (2020 = 100)
2020 9.537 3.366 4.797 100
2021 12.097 3.430 4.515 94
2022 11.657 3.470 4.185 87
2023 10.692 3.623 4.182 87
2024 13.493 3.760 4.159 87
2025 14.196 3.958 4.178 87
2026 12.048 4.532 4.600 96

O que isso mostra. Em valores correntes, a mediana do R$/m² variou +35% de 2020 a 2026. Descontada a inflação, a variação foi de -4,1%; no ponto mais baixo da série, o valor real estava -13% em relação a 2020. Ou seja, boa parte da alta nominal é só inflação. (2026 cobre apenas parte do ano, mas a mediana é pouco sensível a isso.)

4.3 O salto de 2026: mercado ou composição?

A mediana real de 2026 sobe em relação a 2025. Antes de chamar isso de valorização, é preciso verificar o que mudou no tipo de imóvel negociado. Aqui o foco é nos apartamentos, que são a grande maioria da base.

apt <- itbi_analise |>
  filter(tipo_imovel == "Apartamento") |>
  mutate(faixa_area = cut(area_construida, c(0, 50, 80, 120, 200, Inf),
                          labels = c("até 50 m²", "50–80 m²", "80–120 m²", "120–200 m²", "mais de 200 m²")))

comp_area <- apt |> count(ano, faixa_area) |> mutate(pct = n / sum(n), .by = ano)

p_comp <- ggplot(comp_area, aes(factor(ano), pct, fill = faixa_area,
                 text = paste0(ano, " | ", faixa_area, ": ", percent(pct, .1)))) +
  geom_col() +
  scale_y_continuous(labels = percent) +
  scale_fill_brewer(palette = "Blues", name = "Área") +
  labs(title = "Composição dos apartamentos negociados por faixa de área",
       x = NULL, y = "Participação") +
  theme_minimal()
ggplotly(p_comp, tooltip = "text")
peq <- apt |> summarise(p = mean(area_construida <= 80), .by = ano) |> arrange(ano)

A participação de apartamentos de até 80 m² foi de 26,8% em 2020 para 41,5% em 2026. A composição mudou, e imóveis menores costumam ter R$/m² maior. Para isolar o efeito, a tabela a seguir compara segmentos equivalentes (padrão × faixa de área) entre 2024 e 2026, nas células com ao menos 30 transações em cada ano:

celulas <- apt |>
  filter(ano %in% c(2024, 2026)) |>
  summarise(n = n(), med = median(valor_m2_real), .by = c(ano, padrao_acabamento, faixa_area)) |>
  pivot_wider(names_from = ano, values_from = c(n, med)) |>
  filter(n_2024 >= 30, n_2026 >= 30) |>
  mutate(var_pct = med_2026 / med_2024 - 1) |>
  arrange(padrao_acabamento, faixa_area)

celulas |>
  transmute(Padrão = padrao_acabamento, Área = faixa_area,
            `N 2024` = n_2024, `N 2026` = n_2026,
            `R$/m² 2024` = med_2024, `R$/m² 2026` = med_2026,
            `Variação real` = fmt_pct(var_pct, 1)) |>
  tab(digits = 0, caption = "Mediana real do R$/m² de apartamentos, por segmento")
Mediana real do R$/m² de apartamentos, por segmento
Padrão Área N 2024 N 2026 R$/m² 2024 R$/m² 2026 Variação real
Médio até 50 m² 718 130 4.416 4.143 -6,2%
Médio 50–80 m² 1.053 677 4.022 3.563 -11,4%
Médio 80–120 m² 1.548 1.035 3.732 3.538 -5,2%
Médio 120–200 m² 906 721 3.145 3.091 -1,7%
Médio mais de 200 m² 202 182 2.983 2.933 -1,7%
Simples até 50 m² 131 110 3.215 3.404 +5,9%
Simples 50–80 m² 355 280 2.657 2.611 -1,7%
Simples 80–120 m² 465 341 2.542 2.564 +0,9%
Simples 120–200 m² 98 94 2.330 2.376 +2,0%
Superior até 50 m² 184 1.192 6.524 5.665 -13,2%
Superior 50–80 m² 1.832 2.291 3.795 4.835 +27,4%
Superior 80–120 m² 1.921 1.943 4.579 4.768 +4,1%
Superior 120–200 m² 1.502 1.195 4.895 5.007 +2,3%
Superior mais de 200 m² 1.380 1.084 5.982 5.807 -2,9%
maior_alta  <- celulas |> slice_max(var_pct, n = 1)
maior_queda <- celulas |> slice_min(var_pct, n = 1)
n_alta  <- sum(celulas$var_pct > 0.02)
n_queda <- sum(celulas$var_pct < -0.02)

Entre as 14 células comparáveis, o número com alta real superior a 2% foi 4, e com queda superior a 2%, 5. A maior alta foi em apartamentos de padrão Superior com 50–80 m² (+27%), e a maior queda em padrão Superior com até 50 m² (-13%). A alta, portanto, não é generalizada: ela se concentra em segmentos específicos. Atenção às colunas N 2024 e N 2026: em algumas células o volume de transações mudou muito entre os dois anos, e variações nessas células misturam mudança de preço com entrada de imóveis de perfil diferente (novos lançamentos, por exemplo). Para quantificar isso, o gráfico compara a mediana bruta com um índice de composição fixa, que pondera as medianas das células pelos mesmos pesos em todos os anos.

cel_ano <- apt |>
  summarise(n = n(), med = median(valor_m2_real), .by = c(ano, padrao_acabamento, faixa_area)) |>
  filter(n >= 20)
pesos <- apt |> count(padrao_acabamento, faixa_area, name = "n_tot") |> mutate(w = n_tot / sum(n_tot))

indice_fixo <- cel_ano |>
  left_join(pesos, by = c("padrao_acabamento", "faixa_area")) |>
  summarise(preco_fixo = sum(med * w) / sum(w), .by = ano)

comp <- apt |>
  summarise(bruta = median(valor_m2_real), .by = ano) |>
  left_join(indice_fixo, by = "ano") |>
  arrange(ano) |>
  mutate(`Mediana bruta` = 100 * bruta / first(bruta),
         `Composição fixa` = 100 * preco_fixo / first(preco_fixo))

p_idx <- comp |>
  pivot_longer(c(`Mediana bruta`, `Composição fixa`), names_to = "serie", values_to = "indice") |>
  ggplot(aes(ano, indice, color = serie, text = paste0(ano, " | ", serie, ": ", round(indice, 1)))) +
  geom_line(linewidth = 1.1) + geom_point(size = 2.5) +
  geom_hline(yintercept = 100, linetype = "dashed", color = "grey50") +
  scale_x_continuous(breaks = 2020:2026) +
  scale_color_manual(values = c("#2c7fb8", "#d95f0e"), name = NULL) +
  labs(title = "Apartamentos: R$/m² real, mediana bruta vs. composição fixa (2020 = 100)",
       x = NULL, y = "Índice") +
  theme_minimal() + theme(legend.position = "bottom")
ggplotly(p_idx, tooltip = "text") |> layout(legend = list(orientation = "h", y = -0.15))
dif_final <- last(comp$`Mediana bruta`) - last(comp$`Composição fixa`)

No último ano, a mediana bruta está 4,7 pontos acima do índice de composição fixa. Quando as duas linhas se afastam, a diferença é efeito de composição: a mediana bruta mistura “o preço mudou” com “o imóvel negociado mudou”.

4.4 Quais bairros mais se valorizaram?

Comparamos 2020–2021 com 2025–2026, em valores reais, só para bairros com ao menos 30 transações em cada janela (para não premiar bairros com poucas vendas).

rank_bairro <- itbi_analise |>
  mutate(janela = case_when(ano %in% 2020:2021 ~ "ini", ano %in% 2025:2026 ~ "fim")) |>
  filter(!is.na(janela), !is.na(bairro)) |>
  summarise(n = n(), med = median(valor_m2_real), .by = c(bairro, janela)) |>
  pivot_wider(names_from = janela, values_from = c(n, med)) |>
  filter(n_ini >= 30, n_fim >= 30) |>
  mutate(var_pct = med_fim / med_ini - 1) |>
  arrange(desc(var_pct))

datatable(rank_bairro |> select(bairro, n_ini, n_fim, med_ini, med_fim, var_pct),
          rownames = FALSE,
          colnames = c("Bairro", "Transações 2020–21", "Transações 2025–26",
                       "R$/m² real 2020–21", "R$/m² real 2025–26", "Variação real"),
          caption = "Valorização real do R$/m² por bairro (clique nos títulos para ordenar)",
          options = list(pageLength = 10,
                         language = list(search = "Buscar:", lengthMenu = "Mostrar _MENU_",
                                         info = "_START_ a _END_ de _TOTAL_",
                                         paginate = list(previous = "Anterior", `next` = "Próximo")))) |>
  formatCurrency(c("med_ini", "med_fim"), currency = "R$ ", interval = 3, mark = ".", dec.mark = ",", digits = 0) |>
  formatPercentage("var_pct", digits = 1, dec.mark = ",")
extremos <- bind_rows(slice_head(rank_bairro, n = 8), slice_tail(rank_bairro, n = 8)) |>
  distinct(bairro, .keep_all = TRUE)

p_rank <- ggplot(extremos, aes(reorder(bairro, var_pct), var_pct, fill = var_pct > 0,
                 text = paste0(bairro, ": ", fmt_pct(var_pct, 1)))) +
  geom_col() + coord_flip() +
  scale_y_continuous(labels = percent) +
  scale_fill_manual(values = c(`TRUE` = "#1a9850", `FALSE` = "#d73027"), guide = "none") +
  labs(title = "Bairros com maior alta e maior queda real do R$/m²",
       subtitle = "2020–21 vs. 2025–26, mínimo de 30 transações por janela", x = NULL, y = "Variação real") +
  theme_minimal()
ggplotly(p_rank, tooltip = "text")
top1 <- slice_head(rank_bairro, n = 1); bot1 <- slice_tail(rank_bairro, n = 1)
n_bairros_rank <- nrow(rank_bairro)
n_bairros_alta <- sum(rank_bairro$var_pct > 0)

Entre os 48 bairros com amostra suficiente, 16 tiveram alta real. O maior avanço foi em Vasco Da Gama (+60%, de R$ 2.769 para R$ 4.423 por m²) e a maior queda em Ilha Do Leite (-36%). Variações grandes em bairros com poucas transações pedem cautela: o ranking é uma pista, não uma sentença.

4.5 Onde está o valor mais alto?

O mapa mostra, para cada bairro, a mediana do R$/m² real em 2025–2026 (cor) e o número de transações (tamanho). A posição de cada bairro é a mediana das coordenadas disponíveis; como 35% das transações não têm coordenada, bairros com poucos pontos podem ficar levemente deslocados.

mapa_dados <- itbi_analise |>
  filter(ano %in% 2025:2026, !is.na(bairro)) |>
  summarise(n = n(), med = median(valor_m2_real),
            lat = median(latitude, na.rm = TRUE), lon = median(longitude, na.rm = TRUE),
            .by = bairro) |>
  filter(n >= 30, !is.na(lat), !is.na(lon))

pal <- colorNumeric("YlOrRd", mapa_dados$med)

leaflet(mapa_dados) |>
  addTiles() |>   # mapa-base OpenStreetMap (não exige chave de API)
  addCircleMarkers(lng = ~lon, lat = ~lat, radius = ~pmax(4, sqrt(n) / 2.2),
                   color = ~pal(med), fillOpacity = .8, stroke = FALSE,
                   label = ~bairro,
                   popup = ~paste0("<b>", bairro, "</b><br>R$/m² real: ", fmt_brl(med),
                                   "<br>Transações: ", fmt_n(n))) |>
  addLegend("bottomright", pal = pal, values = ~med, title = "R$/m² real (mediana)",
            labFormat = labelFormat(prefix = "R$ ", big.mark = "."))

4.6 O que explica o preço?

Padrão de acabamento e idade

tab_idade <- itbi_analise |>
  mutate(faixa_idade = cut(idade_pos, c(-Inf, 2, 10, 20, 40, Inf),
                           labels = c("0–2 anos", "3–10", "11–20", "21–40", "mais de 40"))) |>
  summarise(n = n(), med = median(valor_m2_real), .by = c(padrao_acabamento, faixa_idade)) |>
  filter(n >= 30)

ggplot(tab_idade, aes(faixa_idade, padrao_acabamento, fill = med)) +
  geom_tile(color = "white") +
  geom_text(aes(label = fmt_n(med)), size = 3.5) +
  scale_fill_gradient(low = "#fff7bc", high = "#d95f0e", name = "R$/m²",
                      labels = label_number(big.mark = ".")) +
  labs(title = "Mediana do R$/m² real por padrão de acabamento e idade do imóvel",
       x = "Idade do imóvel", y = NULL) +
  theme_minimal()

O padrão de acabamento separa os preços de forma nítida, e a idade não reduz o preço de maneira simples: o R$/m² real não é mais alto nos imóveis mais novos, e sim numa faixa intermediária, caindo depois com o tempo. Por padrão: Médio (pico em 3–10; -29% até a faixa mais antiga); Simples (pico em 3–10; -42% até a faixa mais antiga); Superior (pico em 3–10; -16% até a faixa mais antiga). A queda com a idade é bem mais forte nos padrões Simples e Médio que no Superior. Os imóveis de 0 a 2 anos ficam abaixo do pico em todos os padrões, o que pode ter relação com o comportamento do campo ano_construcao (seção 4.7); por isso a idade dos imóveis mais novos deve ser lida com cautela.

Prêmio de andar dentro do mesmo prédio

Para isolar o efeito do andar da localização e do padrão, comparamos cada apartamento com a média do mesmo prédio no mesmo ano, usando prédios com ao menos 20 transações com andar conhecido.

predios_ok <- apt |>
  filter(!is.na(andar), !str_detect(predio_id, "_NA$")) |>
  count(predio_id, name = "n_p") |>
  filter(n_p >= 20)

premio_andar <- apt |>
  filter(!is.na(andar), predio_id %in% predios_ok$predio_id) |>
  mutate(ly = log(valor_m2_real), desvio = ly - mean(ly), .by = c(predio_id, ano)) |>
  mutate(faixa_andar = cut(andar, c(-1, 2, 5, 10, 15, 20, Inf),
                           labels = c("0–2", "3–5", "6–10", "11–15", "16–20", "21+"))) |>
  summarise(n = n(), premio = exp(mean(desvio)) - 1, .by = faixa_andar) |>
  arrange(faixa_andar)

p_andar <- ggplot(premio_andar, aes(faixa_andar, premio,
                  text = paste0("Andar ", faixa_andar, ": ", fmt_pct(premio, 1), " (", fmt_n(n), " transações)"))) +
  geom_col(fill = "#2c7fb8") +
  geom_hline(yintercept = 0) +
  scale_y_continuous(labels = percent) +
  labs(title = "Desvio do R$/m² em relação à média do mesmo prédio e ano, por andar",
       x = "Andar (aproximado)", y = "Desvio médio") +
  theme_minimal()
ggplotly(p_andar, tooltip = "text")
dif_andar <- max(premio_andar$premio) - min(premio_andar$premio)

O desvio vai de -2,3% nos andares mais baixos a +3,8% nos mais altos, uma diferença de 6,1 pontos percentuais, crescente a cada faixa de andar. Isso indica que o valor registrado no ITBI incorpora, ao menos em parte, o andar do apartamento, e que ele não é apenas uma tabela por área e localização. Como a média é calculada entre prédios e o andar é aproximado (derivado do número da unidade), a magnitude deve ser lida com cautela.

O que o “valor de avaliação” representa?

Um sinal está nos valores redondos. Preços negociados costumam ser redondos (R$ 450.000,00); valores calculados por tabela ou fórmula costumam trazer centavos. A proporção de valores múltiplos de R$ 1.000 mudou ao longo dos anos?

redondo <- itbi_analise |>
  mutate(redondo = valor_avaliacao %% 1000 == 0) |>
  summarise(n = n(), pct = mean(redondo), .by = ano) |>
  arrange(ano)

p_red <- ggplot(redondo, aes(ano, pct, text = paste0(ano, ": ", percent(pct, .1), " de valores redondos"))) +
  geom_line(color = "#2c7fb8", linewidth = 1.1) + geom_point(color = "#2c7fb8", size = 2.5) +
  scale_x_continuous(breaks = 2020:2026) +
  scale_y_continuous(labels = percent, limits = c(0, 1)) +
  labs(title = "Proporção de valores de avaliação múltiplos de R$ 1.000", x = NULL, y = "Participação") +
  theme_minimal()
ggplotly(p_red, tooltip = "text")
mesmo_valor <- itbi_analise |>
  filter(!is.na(complemento)) |>
  mutate(imovel_id = paste(predio_id, complemento)) |>
  filter(n_distinct(ano) > 1, .by = imovel_id) |>
  summarise(n_valores = n_distinct(valor_avaliacao), .by = imovel_id) |>
  summarise(imoveis = n(), prop = mean(n_valores == 1))

A proporção de valores redondos foi de 45% em 2020 para 64% em 2026. Além disso, entre os 6.997 imóveis que aparecem em anos diferentes, apenas 17% mantêm exatamente o mesmo valor: não se trata de uma tabela fixa. Uma hipótese compatível com esses números, que precisa ser confirmada na legislação municipal, é que a base de cálculo seja o maior entre o valor declarado na venda e um valor de referência da prefeitura. Nesse caso, a fatia de valores redondos (declarados) crescendo sugere que, com o tempo, o preço declarado passou a superar a referência com mais frequência. Seja como for, o ponto metodológico é firme: o valor_avaliacao é a base do ITBI e não um preço de mercado puro.

4.7 O campo ano_construcao mudou de comportamento

cons <- itbi |>
  summarise(`Construção futura` = mean(ano_construcao > ano),
            `Construção no ano da venda` = mean(ano_construcao == ano), .by = ano) |>
  arrange(ano) |>
  mutate(`Soma` = `Construção futura` + `Construção no ano da venda`)
fut_ultimo <- last(cons$`Construção futura`)   # para citar no texto

cons |>
  mutate(across(-ano, \(x) percent(x, .1))) |>
  rename(Ano = ano) |>
  tab(caption = "Participação de transações com ano de construção igual ou posterior ao da venda")
Participação de transações com ano de construção igual ou posterior ao da venda
Ano Construção futura Construção no ano da venda Soma
2020 5,4% 4,6% 10,0%
2021 3,9% 5,2% 9,1%
2022 3,8% 10,8% 14,6%
2023 3,8% 7,7% 11,4%
2024 0,3% 17,3% 17,6%
2025 0,0% 19,0% 19,0%
2026 0,0% 14,8% 14,8%

Nos primeiros anos, parte das transações traz ano de construção posterior à venda (imóveis possivelmente na planta); a partir de 2024 isso praticamente desaparece (0% em 2026), enquanto cresce a participação de imóveis com ano de construção igual ao da venda. Isso é compatível com uma mudança na forma de registrar o campo, embora não prove. A consequência prática é que não é seguro comparar “na planta” e “pronto” entre anos com esse campo, e a idade dos imóveis mais novos deve ser lida com cautela. Por isso a variável construcao_futura não foi usada como achado.

4.8 Regressão: o que explica o R$/m² mantendo as demais coisas constantes?

Para estimar o efeito de cada característica mantendo as demais constantes, ajustamos uma regressão do logaritmo do R$/m² real para apartamentos. No modelo log-linear, o efeito de uma variável binária ou do andar é (exp(coeficiente) − 1) × 100, em porcentagem; para as variáveis em log, o coeficiente é uma elasticidade.

dados_mod <- apt |>
  filter(!is.na(andar), !is.na(bairro)) |>
  mutate(bairro_grp = fct_lump_n(bairro, 15, other_level = "Outros bairros") |> fct_relevel("Outros bairros"),
         padrao    = fct_relevel(padrao_acabamento, "Médio"),
         ano_f     = factor(ano),
         log_area  = log(area_construida),
         log_idade = log1p(idade_pos),
         usa_sfh   = factor(usa_sfh, c(FALSE, TRUE), c("não", "sim")))

modelo <- lm(log(valor_m2_real) ~ padrao + log_area + log_idade + andar + usa_sfh + ano_f + bairro_grp,
             data = dados_mod)

efeitos <- tidy(modelo, conf.int = TRUE) |>
  filter(term != "(Intercept)") |>
  mutate(efeito = exp(estimate) - 1, ic_inf = exp(conf.low) - 1, ic_sup = exp(conf.high) - 1)

r2 <- glance(modelo)$r.squared

O modelo usa 74.295 apartamentos e explica 38% da variação do log do R$/m² real.

efeitos |>
  filter(!str_starts(term, "ano_f"), !str_starts(term, "bairro_grp")) |>
  transmute(Termo = case_when(
              term == "padraoSimples"  ~ "Padrão Simples (vs. Médio)",
              term == "padraoSuperior" ~ "Padrão Superior (vs. Médio)",
              term == "log_area"       ~ "Área (em log)",
              term == "log_idade"      ~ "Idade (em log)",
              term == "andar"          ~ "Cada andar adicional",
              term == "usa_sfhsim"     ~ "Usa SFH (vs. não)",
              TRUE ~ term),
            Coeficiente = round(estimate, 3),
            `Efeito no R$/m²` = if_else(term %in% c("log_area", "log_idade"),
                                        "elasticidade", percent(efeito, .1)),
            `IC 95%` = if_else(term %in% c("log_area", "log_idade"),
                               paste0(format(round(conf.low, 3), decimal.mark = ","), " a ", format(round(conf.high, 3), decimal.mark = ",")),
                               paste0(percent(ic_inf, .1), " a ", percent(ic_sup, .1)))) |>
  tab(caption = "Efeitos estimados sobre o R$/m² real (apartamentos)")
Efeitos estimados sobre o R$/m² real (apartamentos)
Termo Coeficiente Efeito no R$/m² IC 95%
Padrão Simples (vs. Médio) -0,131 -12,3% -13,1% a -11,5%
Padrão Superior (vs. Médio) 0,193 21,3% 20,7% a 21,9%
Área (em log) 0,035 elasticidade 0,030 a 0,039
Idade (em log) -0,059 elasticidade -0,061 a -0,057
Cada andar adicional 0,009 0,9% 0,9% a 1,0%
Usa SFH (vs. não) 0,007 0,7% 0,3% a 1,2%
coef_area <- efeitos$estimate[efeitos$term == "log_area"]

Dobrar a área do apartamento está associado a uma variação de 2,4% no R$/m², com as demais características constantes. Os efeitos de padrão e de andar aparecem na tabela. Já os efeitos de ano e de bairro vêm a seguir.

efeitos |>
  filter(str_starts(term, "ano_f")) |>
  transmute(Ano = str_remove(term, "ano_f"), `Efeito vs. 2020` = percent(efeito, .1)) |>
  tab(caption = "Efeito de cada ano sobre o R$/m² real, controlando as características")
Efeito de cada ano sobre o R$/m² real, controlando as características
Ano Efeito vs. 2020
2021 0,9%
2022 -6,4%
2023 -4,4%
2024 -6,6%
2025 -6,9%
2026 -1,8%
efeitos |>
  filter(str_starts(term, "bairro_grp")) |>
  arrange(desc(efeito)) |>
  transmute(Bairro = str_remove(term, "bairro_grp"), `Efeito vs. outros bairros` = percent(efeito, .1)) |>
  slice_head(n = 8) |>
  tab(caption = "Maiores prêmios de bairro (entre os 15 mais frequentes)")
Maiores prêmios de bairro (entre os 15 mais frequentes)
Bairro Efeito vs. outros bairros
Pina 35,9%
Boa Viagem 18,9%
Tamarineira 11,7%
Santo Amaro 11,2%
Boa Vista 10,9%
Madalena 10,7%
Torre 7,4%
Encruzilhada 7,2%

O efeito de ano é a valorização real “limpa”: o que sobra depois de controlar padrão, tamanho, idade, andar, financiamento e bairro. Quando ele difere da variação da mediana bruta (seção 4.2), a diferença é, mais uma vez, composição.

4.9 Teste de sensibilidade: as duplicatas importam?

sens <- bind_rows(
  itbi_analise      |> summarise(mediana = median(valor_m2_real), .by = ano) |> mutate(base = "Sem duplicatas"),
  itbi_analise_sens |> summarise(mediana = median(valor_m2_real), .by = ano) |> mutate(base = "Com duplicatas")) |>
  pivot_wider(names_from = base, values_from = mediana) |>
  arrange(ano) |>
  mutate(`Diferença` = `Com duplicatas` / `Sem duplicatas` - 1)

sens |>
  mutate(Diferença = percent(Diferença, .01)) |>
  rename(Ano = ano) |>
  tab(digits = 0, caption = "Mediana real do R$/m² com e sem as transações duplicadas")
Mediana real do R$/m² com e sem as transações duplicadas
Ano Sem duplicatas Com duplicatas Diferença
2020 4.797 4.801 0,08%
2021 4.515 4.508 -0,15%
2022 4.185 4.180 -0,13%
2023 4.182 4.182 -0,01%
2024 4.159 4.155 -0,07%
2025 4.178 4.178 0,00%
2026 4.600 4.592 -0,17%
dif_max <- max(abs(sens$Diferença))

A maior diferença entre as duas versões é de 0,17%. Como a diferença é pequena, as conclusões não dependem das duplicatas.

5. Conclusões

5.1 O problema

Perguntamos se o m² do Recife ficou, de fato, mais caro entre 2020 e 2026, onde e por quê, e o que os registros de ITBI realmente medem.

5.2 Como abordamos

Importamos sete arquivos anuais do Portal de Dados Abertos (97.627 transações), padronizamos separadores, decimais e nomes, separamos o campo de complemento, mesclamos com a série do IPCA e filtramos extremos de forma documentada, chegando a 83.720 transações de apartamentos e casas. A análise combinou medianas deflacionadas, um índice de composição fixa, ranking de bairros, comparação dentro do mesmo prédio e uma regressão hedônica.

5.3 O que descobrimos

  1. A alta nominal esconde a inflação. A mediana do R$/m² variou +35% em valores correntes, mas -4,1% em termos reais, e chegou a -13% abaixo de 2020 no ponto mais baixo.
  2. A alta recente é concentrada. Das 14 combinações comparáveis de padrão e tamanho, 4 subiram mais de 2% em termos reais entre 2024 e 2026 e 5 caíram mais de 2%. A maior alta foi em apartamentos Superior de 50–80 m² (+27%).
  3. O mix mudou. A participação de apartamentos de até 80 m² foi de 27% para 42%, e isso empurra a mediana bruta para cima independentemente de qualquer valorização.
  4. Bairros divergem. O maior avanço real foi em Vasco Da Gama (+60%) e a maior queda em Ilha Do Leite (-36%), entre 48 bairros com amostra suficiente.
  5. O valor registrado não é um preço puro. Apenas 17% dos imóveis que reaparecem em anos diferentes mantêm o mesmo valor, e a fatia de valores redondos foi de NA para NA.

5.4 Implicações

Quem usa o R$/m² do ITBI para decidir deve comparar em valores reais e em segmentos equivalentes, e não ler a mediana geral como “o preço do mercado”. Para compradores e vendedores, a leitura por bairro e por padrão é mais útil que o número da cidade. Para o poder público e analistas, o ITBI é uma base valiosa, mas o campo de valor tem um componente de referência cuja natureza convém esclarecer antes de usá-lo como proxy de preço.

5.5 Limitações e próximos passos

  • O valor_avaliacao não é, necessariamente, o preço de venda; a hipótese sobre sua composição precisa ser confirmada com a legislação e o dicionário do portal.
  • A base de 2026 é parcial, e há meses com volume atípico (12/2025, 10/2026).
  • Parte das coordenadas está ausente (NA), e o distrito de 2025–2026 é imputado por logradouro.
  • O campo ano_construcao muda de comportamento no tempo, o que impede comparar imóveis “na planta” e “prontos” entre anos; o andar é uma aproximação.
  • As duplicatas foram marcadas, não apagadas, e a análise de sensibilidade mostra o efeito; mesmo assim, não é possível saber, com estes dados, quais são registros repetidos de fato.

Para ir além: construir um índice de vendas repetidas (o mesmo imóvel vendido duas vezes), cruzar com renda e IDH por bairro, geocodificar os endereços sem coordenada, comparar com índices de preços de anúncio e publicar um painel interativo (shiny).

Apêndice: ambiente

sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26300)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=Portuguese_Brazil.utf8  LC_CTYPE=Portuguese_Brazil.utf8   
## [3] LC_MONETARY=Portuguese_Brazil.utf8 LC_NUMERIC=C                      
## [5] LC_TIME=Portuguese_Brazil.utf8    
## 
## time zone: America/Sao_Paulo
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] broom_1.0.13     scales_1.4.0     leaflet_2.2.3    plotly_4.12.1   
##  [5] DT_0.34.0        kableExtra_1.4.1 knitr_1.52       ipeadatar_0.2.1 
##  [9] stringi_1.8.9    jsonlite_2.0.0   janitor_2.2.1    lubridate_1.9.5 
## [13] forcats_1.0.1    stringr_1.6.0    dplyr_1.2.1      purrr_1.2.2     
## [17] readr_2.2.0      tidyr_1.3.2      tibble_3.3.1     ggplot2_4.0.3   
## [21] tidyverse_2.0.0 
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6        xfun_0.61           bslib_0.12.0       
##  [4] htmlwidgets_1.6.4   tzdb_0.5.0          vctrs_0.7.3        
##  [7] tools_4.6.1         crosstalk_1.2.2     generics_0.1.4     
## [10] stats4_4.6.1        parallel_4.6.1      pkgconfig_2.0.3    
## [13] data.table_1.18.6.1 RColorBrewer_1.1-3  S7_0.2.2           
## [16] lifecycle_1.0.5     compiler_4.6.1      farver_2.1.2       
## [19] textshaping_1.0.5   snakecase_0.11.1    htmltools_0.5.9    
## [22] sass_0.4.10         yaml_2.3.12         pillar_1.11.1      
## [25] crayon_1.5.3        jquerylib_0.1.4     cachem_1.1.0       
## [28] tidyselect_1.2.1    digest_0.6.39       bookdown_0.48      
## [31] labeling_0.4.3      rmdformats_1.0.4    fastmap_1.2.0      
## [34] grid_4.6.1          cli_3.6.6           magrittr_2.0.5     
## [37] withr_3.0.3         backports_1.5.1     bit64_4.8.6        
## [40] timechange_0.4.0    rmarkdown_2.32      httr_1.4.9         
## [43] bit_4.6.0           otel_0.2.0          hms_1.1.4          
## [46] evaluate_1.0.5      viridisLite_0.4.3   rlang_1.3.0        
## [49] glue_1.8.1          xml2_1.6.0          svglite_2.2.2      
## [52] rstudioapi_0.19.0   vroom_1.7.1         R6_2.6.1           
## [55] systemfonts_1.3.2