Introdução
Analisar Fundos de Investimento Imobiliário (FIIs) pode ser uma tarefa cansativa e repetitiva. É preciso visitar várias plataformas, coletar dados manualmente, organizar tudo em planilhas e ainda aplicar cálculos personalizados. Para agilizar esse processo e torná-lo mais eficiente, criei uma ferramenta automatizada usando NodeJs.
Essa aplicação de linha de comando utiliza web scraping para extrair dados diretamente de fontes como o Funds Explorer, aplica filtros, calcula métricas personalizadas e gera relatórios prontos em formatos JSON e Excel. A seleção de FIIs é baseada no S-Rank, uma estratégia inspirada no Value Investing e desenvolvida pelo Clube do Valor.
O Processo Manual
Antes de criar essa aplicação, o fluxo para selecionar FIIs era completamente manual. Aqui está como funcionava:
-
Coleta de Dados:
- Visitávamos sites especializados como Funds Explorer, Investidor10 e Clube FII para obter informações.
-
Consolidação em Planilhas:
- Transferíamos manualmente os dados para uma planilha, copiando e colando as informações.
-
Análise dos Dados:
- Aplicávamos filtros manuais para eliminar FIIs fora dos critérios desejados (como liquidez mínima ou setores específicos).
- Também calculávamos métricas como média e mediana do dividend yield.
-
Relatórios Finais:
- Organizávamos os dados filtrados em relatórios prontos para análise, geralmente usando Excel.
Como Funciona a Aplicação
A aplicação segue um fluxo bem definido para automatizar o processo:
-
Requisição de Dados:
Utiliza web scraping para acessar endpoints do site Funds Explorer e coletar informações sobre FIIs e dividendos.-
Tela de ranking de FIIs no Funds Explorer, que exibe a lista completa dos fundos disponíveis.
-
Tela de histórico de dividendos pagos, que mostra os rendimentos mensais de cada fundo.
-
-
Aplicação de Filtros:
A aplicação elimina automaticamente FIIs que:- Não possuem liquidez mínima de R$ 200 mil diários.
- Pertencem a setores específicos, como hotéis ou educacional.
- Apresentam discrepância alta entre DY médio e mediano.
-
Cálculos Personalizados:
Para cada fundo, a aplicação calcula:- P/VPA: Relação entre preço e valor patrimonial.
- DY Mediana: Mediana do dividend yield dos últimos 12 meses.
- Rankings personalizados, como o S-Rank, combinando os critérios anteriores.
-
Geração de Relatórios:
Os resultados são organizados e exportados em:- JSON: Para análise programática.
- Excel: Para consultas rápidas e manuais.
Estrutura do Projeto
data/
cache.db # Banco de dados SQLite usado para cache
files/ # Diretório para saída de arquivos gerados (JSON e XLSX)
src/
cache.provider.ts # Classe para manipular o cache
funds.service.ts # Classe para consumir os dados dos FIIs
main.ts # Entrada principal da aplicação
interfaces.ts # Definições de interfaces TypeScript
types.ts # Definições de tipos TypeScript
util.ts # Funções utilitárias usadas no projeto
package.json # Configurações do projeto e dependências
tsconfig.json # Configurações do TypeScript
Explicação Técnica dos Arquivos
1. src/main.ts
- Entrada Principal
Este arquivo gerencia a interação com o usuário e organiza o fluxo principal da aplicação. Ele:
Exibe um menu interativo para o usuário selecionar ações. Invoca funções de busca, filtragem e geração de relatórios. Utiliza o readline para capturar entradas no console.
import readline from 'readline';
import CacheProvider from './cache.provider';
import { FundService } from './funds.service';
import { FundInterface } from './interfaces';
import { CompleteFundType } from './types';
import {
delay,
generateJSONFile,
generateXlsxFile,
getMean,
getMedian,
sortFunds
} from './util';
const cacheProvider = new CacheProvider();
const fundService = new FundService(cacheProvider);
async function fetchFiiData() {
try {
const nonce = await fundService.getNonce();
let funds = await fundService.getFunds(nonce);
console.log(`Total de fundos encontrados: ${funds.length}`);
// Eliminando fundos com menos de R$ 200 mil negociados diariamente
funds = funds.filter(
(el: FundInterface) => Number(el.liquidezmediadiaria) >= 200000
);
// Removendo FIIs de certos setores
funds = funds.filter(
(el: FundInterface) =>
el.setor_slug &&
el.setor_slug !== 'indefinido' &&
el.setor_slug !== 'educacional' &&
el.setor_slug !== 'fundo-de-desenvolvimento' &&
el.setor_slug !== 'imoveis-residenciais' &&
el.setor_slug !== 'hoteis' &&
el.setor_slug !== 'imoveis-comerciais-outros' &&
el.setor_slug !== 'outros'
);
console.log(`Total de fundos após filtros: ${funds.length}`);
// Buscando dividendos pagos dos últimos 12 meses
let completeFunds: CompleteFundType[] = [];
for (let i = 0; i < funds.length; i++) {
const fund = funds[i];
const dividends = await fundService.getDividends(fund.ticker, nonce);
const dividendsValues = dividends.map((el) => Number(el.yeld));
const completeFund: CompleteFundType = {
...fund,
dividendos: dividends,
dyMedia: getMean(dividendsValues),
dyMediana: getMedian(dividendsValues)
};
completeFunds.push(completeFund);
console.log(
`Fundo: ${fund.ticker}, Número de dividendos: ${dividends.length}`
);
// Delay para evitar sobrecarga no servidor
const nextFund = funds[i + 1];
if (nextFund && !fundService.areDividendsCached(nextFund.ticker)) {
await delay(3000);
}
}
// Eliminando FIIs com menos de 1 ano de idade
completeFunds = completeFunds.filter((el) => el.dividendos!.length >= 12);
// Filtrando FIIs com discrepância baixa entre média e mediana de DY
console.log(
`Total de fundos antes da análise de discrepância: ${completeFunds.length}`
);
completeFunds = completeFunds.filter((el) => {
const discrepancy =
Math.abs((el.dyMedia! - el.dyMediana!) / el.dyMediana!) * 100;
return discrepancy <= 20;
});
console.log(
`Total de fundos após análise de discrepância: ${completeFunds.length}`
);
// Ordenando por P/VPA
completeFunds = completeFunds.filter((el) => el.p_vpa);
completeFunds = sortFunds(completeFunds, 'p_vpa', true);
completeFunds = completeFunds.map((el, index) => ({
...el,
pVPA_ranking: index + 1
}));
// Ordenando por mediana DY
completeFunds = completeFunds.filter((el) => el.dyMediana);
completeFunds = sortFunds(completeFunds, 'dyMediana', false);
completeFunds = completeFunds.map((el, index) => ({
...el,
mediana_ranking: index + 1
}));
// Compondo ranking final (S-Rank)
completeFunds = completeFunds.map((el) => ({
...el,
sRank_ranking: el.pVPA_ranking! + el.mediana_ranking!
}));
completeFunds = sortFunds(completeFunds, 'sRank_ranking', true);
console.log('----- FUNDOS COM SUAS MÉDIAS E MEDIANAS DE DY -----');
completeFunds.forEach((el) => {
console.log(
`Fundo: ${el.ticker}, Média DY: ${el.dyMedia}, Mediana DY: ${el.dyMediana}, Média pré-calculada: ${el.media_yield_12m}`
);
});
// Removendo dividendos do objeto final para salvar
completeFunds.forEach((el) => {
delete el.dividendos;
});
const mappedFunds = completeFunds.map((el: CompleteFundType) => ({
'Ranking S-Rank': el.sRank_ranking,
Ticker: el.ticker,
Valor: el.valor,
'P/VPA': el.p_vpa,
'Liquidez Média Diária': el.liquidezmediadiaria,
'Média DY 12M': el.dyMedia,
'Mediana DY 12M': el.dyMediana,
'Ranking P/VPA': el.pVPA_ranking,
'Ranking Mediana': el.mediana_ranking
}));
const now = new Date().toISOString().replace(/:/g, '_');
generateJSONFile(mappedFunds, `fundos-srank-${now}.json`);
generateXlsxFile(mappedFunds, `fundos-srank-${now}.xlsx`);
console.log('Arquivos JSON e XLSX gerados com sucesso.');
} catch (error) {
console.error('Erro ao buscar os dados dos FIIs:', error);
}
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const actions: Record<
string,
(() => void) | (() => Promise<void>) | undefined
> = {
'1': fetchFiiData,
'2': cacheProvider.clearAll.bind(cacheProvider),
'0': () => {
console.log('Saindo...');
rl.close();
}
};
const promptUser = () =>
rl.question(
'Digite uma opção (1 - Buscar fundos, 2 - Limpar Cache, 0 - Sair): ',
async (input: string) => {
const sanitizedInput = input.trim();
const action = actions[sanitizedInput];
if (action) {
try {
await action();
} catch (error) {
console.error('Erro durante a execução:', error);
}
if (sanitizedInput !== '0') {
promptUser();
}
} else {
console.log('Opção inválida. Tente novamente.');
promptUser();
}
}
);
promptUser();
2. src/funds.service.ts
- Serviço de Coleta de Dados
Essa classe encapsula a lógica para:
- Buscar o nonce necessário para autenticação nas requisições.
- Coletar dados dos FIIs e dividendos via web scraping.
- Aplicar filtros aos dados coletados.
Principais Métodos:
getNonce
: Obtém o token para autenticar requisições.getFunds
: Busca a lista de FIIs do endpoint.getDividends
: Recupera o histórico de dividendos de um fundo específico.
import axios from 'axios';
import { DividendInterface, FundInterface } from './interfaces';
import { sortDividendsArray } from './util';
import CacheProvider from './cache.provider';
export class FundService {
private cacheService: CacheProvider;
private baseUrl: string = 'https://www.fundsexplorer.com.br';
constructor(cacheService: CacheProvider) {
this.cacheService = cacheService;
}
/**
* Obtém o nonce necessário para autenticação. Usa cache para evitar chamadas repetidas.
* @returns O nonce obtido.
*/
public async getNonce(): Promise<string> {
const cacheKey = 'nonce';
const cachedData = this.cacheService.getCache<{ nonce: string }>(cacheKey);
if (cachedData) {
console.log('Nonce recuperado do cache.');
return cachedData.nonce;
}
const url = `${this.baseUrl}/wp-content/themes/fundsexplorer/dist/frontend.min.js`;
const response = await axios.get(url);
const jsContent: string = response.data;
const nonceMatch = jsContent.match(
/["']x-funds-nonce["']\s*:\s*["']([a-f0-9]+)["']/
);
if (!nonceMatch) {
console.warn('Nonce não encontrado no arquivo JS.');
return '';
}
console.log('Nonce extraído do arquivo JS.');
const data = { nonce: nonceMatch[1] };
this.cacheService.setCache({
key: cacheKey,
value: data,
ttlInSeconds: 3600
});
return data.nonce;
}
/**
* Obtém os fundos usando o nonce.
* @param nonce Token de autenticação.
* @returns Lista de fundos.
*/
public async getFunds(nonce: string): Promise<FundInterface[]> {
const cacheKey = 'funds';
const cachedFunds = this.cacheService.getCache<FundInterface[]>(cacheKey);
if (cachedFunds) {
console.log('Fundos recuperados do cache.');
return cachedFunds;
}
const url = `${this.baseUrl}/wp-json/funds/v1/get-ranking`;
const config = {
url,
method: 'get',
maxBodyLength: Infinity,
headers: { 'x-funds-nonce': nonce }
};
const response = await axios.request(config);
try {
const funds = JSON.parse(response.data);
this.cacheService.setCache({
key: cacheKey,
value: funds,
ttlInSeconds: 3600 * 5
});
return funds;
} catch (error) {
console.error('Erro ao processar os dados dos fundos:', error);
throw new Error('Falha ao processar os dados dos fundos.');
}
}
/**
* Obtém os dividendos de um fundo específico.
* @param fundName Nome do fundo.
* @param nonce Token de autenticação.
* @returns Lista de dividendos.
*/
public async getDividends(
fundName: string,
nonce: string
): Promise<DividendInterface[]> {
const cacheKey = `dividends_${fundName}`;
const cachedDividends =
this.cacheService.getCache<DividendInterface[]>(cacheKey);
if (cachedDividends) {
console.log(`Dividendos do fundo ${fundName} recuperados do cache.`);
return cachedDividends;
}
const url = `${this.baseUrl}/wp-json/funds/v1/dividends-by-period?mes=-1&ano=0&ticker=${fundName}`;
const config = {
url,
method: 'get',
maxBodyLength: Infinity,
headers: { 'x-funds-nonce': nonce }
};
const response = await axios.request(config);
try {
const dividends: DividendInterface[] = JSON.parse(response.data);
const filteredDividends = dividends.filter(
(el) => el.tipo === 'Rendimento' && el.setor
);
const sortedDividends = sortDividendsArray(filteredDividends);
const slicedDividends = sortedDividends.slice(0, 12);
this.cacheService.setCache({
key: cacheKey,
value: slicedDividends,
ttlInSeconds: 3600 * 5
});
return slicedDividends;
} catch (error) {
console.error(
`Erro ao processar os dividendos do fundo ${fundName}:`,
error
);
throw new Error(`Falha ao processar os dividendos do fundo ${fundName}.`);
}
}
/**
* Verifica se os dividendos de um fundo estão cacheados.
* @param fundName Nome do fundo.
* @returns `true` se os dividendos estiverem cacheados, `false` caso contrário.
*/
public areDividendsCached(fundName: string): boolean {
const cacheKey = `dividends_${fundName}`;
return !!this.cacheService.getCache<DividendInterface[]>(cacheKey);
}
}
3. src/cache.provider.ts
- Gerenciamento de Cache
O cache é gerenciado em um banco de dados SQLite. Isso melhora o desempenho da aplicação, evitando requisições desnecessárias.
Principais Métodos:
- setCache: Salva um valor no cache.
- getCache: Recupera um valor armazenado, verificando se ainda é válido.
- clearAll: Remove todas as entradas do cache.
import Database from 'better-sqlite3';
class CacheProvider {
private db: Database.Database;
constructor() {
// Inicializa o banco de dados SQLite
this.db = new Database('./data/cache.db');
this.setup();
}
/**
* Configura a tabela de cache no banco de dados.
*/
private setup(): void {
this.db
.prepare(
`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at INTEGER NOT NULL,
ttl INTEGER NOT NULL
)
`
)
.run();
}
/**
* Armazena um valor no cache com tempo de expiração.
* @param key Chave única para o cache.
* @param value Valor a ser armazenado.
* @param ttlInSeconds Tempo de vida em segundos.
*/
public setCache({
key,
value,
ttlInSeconds
}: {
key: string;
value: any;
ttlInSeconds: number;
}): void {
const createdAt = Date.now();
const ttl = ttlInSeconds * 1000; // Milissegundos
this.db
.prepare(
`
INSERT INTO cache (key, value, created_at, ttl)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
created_at = excluded.created_at,
ttl = excluded.ttl
`
)
.run(key, JSON.stringify(value), createdAt, ttl);
}
/**
* Recupera um valor do cache se ainda válido. Remove entradas expiradas automaticamente.
* @param key Chave do cache.
* @returns O valor armazenado ou `null` se expirado/inexistente.
*/
public getCache<T>(key: string): T | null {
const row: any = this.db
.prepare(
`
SELECT value, created_at, ttl FROM cache WHERE key = ?
`
)
.get(key);
if (!row) {
return null;
}
const { value, created_at, ttl } = row;
if (Date.now() > created_at + ttl) {
this.db.prepare('DELETE FROM cache WHERE key = ?').run(key);
return null;
}
try {
return JSON.parse(value) as T;
} catch (error) {
console.error(
`Erro ao parsear JSON do cache para a chave: ${key}`,
error
);
return null;
}
}
/**
* Remove todas as entradas expiradas do cache.
*/
public cleanExpiredCache(): void {
this.db
.prepare(
`
DELETE FROM cache WHERE created_at + ttl <= ?
`
)
.run(Date.now());
}
/**
* Remove todas as entradas do cache.
*/
public clearAll(): void {
this.db.prepare('DELETE FROM cache').run();
console.log('Cache cleared.');
}
}
export default CacheProvider;
4. src/util.ts
- Funções Auxiliares
Contém funções auxiliares para:
- Cálculos Estatísticos: Média, mediana e discrepância.
- Ordenação de Dados: Classifica FIIs com base em critérios.
- Geração de Relatórios: Cria arquivos JSON e Excel automaticamente.
import * as fs from 'fs';
import * as XLSX from 'xlsx';
import * as path from 'path';
import { DividendInterface } from './interfaces';
import { CompleteFundType } from './types';
export function sortFunds(
fundsArr: CompleteFundType[],
field: 'p_vpa' | 'dyMediana' | 'sRank_ranking',
asc = true
): CompleteFundType[] {
return [...fundsArr].sort((a, b) =>
asc ? a[field]! - b[field]! : b[field]! - a[field]!
);
}
export function sortDividendsArray(
dividends: DividendInterface[]
): DividendInterface[] {
return dividends.sort((a, b) => {
const [monthA, yearA] = a.referencia.split('/').map(Number);
const [monthB, yearB] = b.referencia.split('/').map(Number);
return yearA === yearB ? monthB - monthA : yearB - yearA;
});
}
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getMedian(arr: number[]): number | undefined {
if (!arr || arr.length === 0) {
console.warn('getMedian called with an empty array');
return undefined;
}
const sortedArr = [...arr].sort((a, b) => b - a);
const mid = Math.floor(sortedArr.length / 2);
return sortedArr.length % 2 === 0
? (sortedArr[mid - 1] + sortedArr[mid]) / 2
: sortedArr[mid];
}
export function getMean(arr: number[]): number | undefined {
if (!arr || arr.length === 0) {
console.warn('getMean called with an empty array');
return undefined;
}
return arr.reduce((sum, val) => sum + val, 0) / arr.length;
}
const BASE_DIR = path.resolve(__dirname, '../files');
const ensureFilesDirectory = (dirName: string): string => {
const dir = path.join(BASE_DIR, dirName);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
};
export function generateJSONFile(data: object, filename: string): void {
try {
const dir = ensureFilesDirectory('');
const filePath = path.join(dir, filename);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
console.log(`Arquivo ${filename} foi gerado com sucesso.`);
} catch (error) {
console.error('Erro ao gerar o arquivo JSON: ', error);
}
}
export function generateXlsxFile(
data: Record<string, any>[],
filename: string
): void {
try {
const dir = ensureFilesDirectory('');
const filePath = path.join(dir, filename);
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
XLSX.writeFile(wb, filePath);
console.log(`Arquivo ${filename} gerado com sucesso.`);
} catch (error) {
console.error('Erro ao gerar o arquivo XLSX:', error);
}
}
5. src/interfaces.ts
e src/types.ts
- Estruturas de Dados
Define as interfaces e tipos usados no projeto.
export interface FundInterface {
ano: string;
ativos: string;
cotacao_fechamento: string;
dividendo: string;
liquidezmediadiaria: string;
media_yield_3m: string;
media_yield_6m: string;
media_yield_12m: string;
numero_cotista: string;
p_vpa: number;
patrimonio: string;
pl: string;
post_id: string;
post_title: string;
pvp: string;
rentabilidade: string;
rentabilidade_mes: string;
setor: string;
setor_slug: string;
soma_yield_3m: string;
soma_yield_6m: string;
soma_yield_12m: string;
soma_yield_ano_corrente: string;
ticker: string;
tx_admin: string;
tx_gestao: string;
tx_performance: string;
valor: string;
variacao_cotacao_mes: string;
volatility: string;
vpa: string;
vpa_change: string;
vpa_rent: string;
vpa_rent_m: string;
vpa_yield: string;
yeld: string;
yield_vpa_3m: string;
yield_vpa_3m_sum: string;
yield_vpa_6m: string;
yield_vpa_6m_sum: string;
yield_vpa_12m: string;
yield_vpa_12m_sum: string;
}
export interface DividendInterface {
cotacao_fechamento: string;
data_base: string;
data_pagamento: string;
media_yield_12m: string;
post_excerpt: string;
post_title: string;
referencia: string;
setor: string;
tipo: string;
valor: string;
yeld: string;
}
import { DividendInterface, FundInterface } from './interfaces';
export type CompleteFundType = FundInterface & {
dividendos?: DividendInterface[];
dyMedia?: number;
dyMediana?: number;
pVPA_ranking?: number;
mediana_ranking?: number;
sRank_ranking?: number;
};
Exemplo de planilha gerada
Repositório do Projeto
O código completo do projeto está disponível no GitHub:
🔗 Acesse o repositório aqui
Se tiver dúvidas ou sugestões, deixe seu comentário! 🚀