Featured image of post Automating REIT Selection with NodeJs

Automating REIT Selection with NodeJs

Introduction

Analyzing Real Estate Investment Trusts (REITs) can be a tiresome and repetitive task. It involves visiting multiple platforms, manually collecting data, organizing everything into spreadsheets, and applying custom calculations. To simplify and streamline this process, I created an automated tool using NodeJs.

This command-line application leverages web scraping to extract data directly from sources like Funds Explorer, applies filters, calculates custom metrics, and generates reports in JSON and Excel formats. The REIT selection process is based on the S-Rank, a strategy inspired by Value Investing and developed by the Clube do Valor.

The Manual Process

Before creating this application, the workflow for selecting REITs was entirely manual. Here’s how it worked:

  1. Data Collection:

  2. Consolidation into Spreadsheets:

    • Data was manually transferred into a spreadsheet by copying and pasting the information.
  3. Data Analysis:

    • Manual filters were applied to eliminate REITs outside the desired criteria (such as minimum liquidity or specific sectors).
    • Metrics like average and median dividend yield were also calculated manually.
  4. Final Reports:

    • Filtered data was organized into ready-to-analyze reports, usually using Excel.

How the Application Works

The application follows a well-defined workflow to automate the process:

  1. Data Requests:
    Utilizes web scraping to access endpoints from the Funds Explorer site and collect information about REITs and dividends.

    • REIT ranking screen on Funds Explorer, which displays the full list of available funds.

    • Dividend payment history screen, showing monthly earnings for each fund.

  2. Filter Application:
    The application automatically eliminates REITs that:

    • Have less than R$200,000 of daily liquidity.
    • Belong to specific sectors such as hotels or educational.
    • Show a high discrepancy between average and median DY.
  3. Custom Calculations:
    For each REIT, the application calculates:

    • P/B Ratio: Price-to-Book ratio.
    • Median DY: Median of dividend yield over the last 12 months.
    • Custom Rankings: Such as the S-Rank, combining the previous criteria.
  4. Report Generation:
    The results are organized and exported in:

    • JSON: For programmatic analysis.
    • Excel: For quick and manual consultations.

Project Structure

data/
  cache.db       # SQLite database used for caching
files/           # Directory for generated output files (JSON and XLSX)
src/
  cache.provider.ts # Class for cache handling
  funds.service.ts  # Class for REIT data fetching
  main.ts           # Main application entry point
  interfaces.ts     # TypeScript interface definitions
  types.ts          # TypeScript type definitions
  util.ts           # Utility functions used in the project
package.json      # Project configuration and dependencies
tsconfig.json     # TypeScript configuration

Technical Explanation of Files

1. src/main.ts - Main Entry Point

This file manages user interaction and organizes the application’s main workflow. It:

  • Displays an interactive menu for users to select actions.
  • Invokes functions for data fetching, filtering, and report generation.
  • Uses readline to capture console inputs.
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 - Data Collection Service

Encapsulates logic for:

  • Fetching the nonce required for authentication with the Funds Explorer API.
  • Gathering REIT data (e.g., rankings, dividends) via web scraping.
  • Applying filters to raw data for analysis.

Key Methods:

  • getNonce: Retrieves the authentication token.
  • getFunds: Fetches a list of REITs from Funds Explorer.
  • getDividends: Obtains the dividend history for a given REIT.
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 - Cache Management

Handles local caching using SQLite to minimize redundant API calls and optimize performance.

Key Methods:

  • setCache: Saves key-value pairs to the cache.
  • getCache: Retrieves stored values, ensuring they haven’t expired.
  • clearAll: Removes all cached entries.
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 - Helper Functions

Includes utility functions for:

  • Sorting data arrays (e.g., by P/B ratio or dividend yields).
  • Calculating statistical measures like average and median dividend yields.
  • Generating reports in JSON and Excel formats.
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 and src/types.ts - Data Structure Definitions

Defines interfaces and types used across the application.

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;
};

Example of Generated Spreadsheet

Project Repository

The complete project code is available on GitHub:
🔗 Access the repository here

If you have any questions or suggestions, leave your comment! 🚀