Featured image of post Automatizando a Seleção de FIIs com NodeJs

Automatizando a Seleção de FIIs com NodeJs

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:

  1. Coleta de Dados:

  2. Consolidação em Planilhas:

    • Transferíamos manualmente os dados para uma planilha, copiando e colando as informações.
  3. 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.
  4. 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:

  1. 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.

  2. 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.
  3. 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.
  4. 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! 🚀

Criado com Hugo
Tema Stack desenvolvido por Jimmy