PrumoFS/financial-data
0
1/* Prumo FS - fundos CVM2 *3 * Site 100% estatico: os Parquet sao lidos por DuckDB-WASM direto no navegador,4 * via range request. Nao ha backend, e nenhum dado do usuario sai da maquina.5 *6 * Decisao de arquitetura: os marts saem do ETL no nivel do FUNDO, com a gestora7 * ja resolvida. Toda a agregacao por gestora acontece aqui, em SQL. E o que8 * permite trocar o modo de deduplicacao e o filtro de universo sem precisar de9 * um arquivo pre-agregado para cada combinacao de toggles.10 */11 12import * as duckdb from 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.32.0/+esm';13 14// ---------------------------------------------------------------- config --15 16// Por padrao os Parquet vao no proprio Space. Para servi-los de um Dataset do17// Hugging Face (util se os arquivos crescerem, ou se o Space nao responder18// Range), basta definir window.__DATA_BASE__ antes de carregar este modulo:19// <script>window.__DATA_BASE__='https://huggingface.co/datasets/USUARIO/REPO/resolve/main'</script>20const DATA_BASE = globalThis.__DATA_BASE__ || './data';21// Arquivos únicos. Os dois marts grandes vêm particionados e a lista de22// pedaços chega em meta.json — ver `particoes` lá e o registro em iniciar().23const ARQUIVOS = [24 'dim_fundo.parquet',25 'dim_gestora.parquet',26 'feeder_edges.parquet',27 'carteira_fundos.parquet',28 'carteira_ativos_gestora.parquet',29 'carac_fundo.parquet',30 'cartas.parquet',31 // Publico de proposito, ao contrario do `cartas`: so a CONTAGEM por gestora.32 // E o que permite a aba existir com cadeado para quem nao tem conta -- sem33 // ela, quem nao esta logado nunca saberia que ha carta ali para ver.34 'cartas_contagem.parquet',35 'pessoas.parquet',36 'janelas_gestora.parquet',37 'ativos_dim.parquet',38 'ativos_fundo.parquet',39 // Preco mensal da B3. PUBLICO: e dado de pregao, nao acervo nosso, e40 // mante-lo fora do R2 evita engordar o lado privado sem necessidade.41 'preco_mensal.parquet',42 'benchmark_diario.parquet',43 'conteudo_mencoes.parquet',44 'conteudo_termos.parquet',45 'serie_gestora_diaria.parquet',46 'serie_gestora_mensal.parquet',47 // Publico: e titulo, resumo, veiculo e link -- material que os proprios48 // veiculos publicam para ser citado fora do site. Nada de corpo de materia.49 'noticias.parquet',50 // Publico: e dado da SEC, aberto por lei nos EUA. Composicao de carteira de51 // fundo americano na parte brasileira dela.52 'eua_brasil.parquet',53 // Oito linhas: ticker que virou outro ticker. Sem ele, quem procura ELET3 ve54 // "nenhum fundo estrangeiro" porque a SEC ja usa AXIA3.55 'eua_alias.parquet',56 // Video: do que a casa falou em podcast e entrevista. PUBLICO, pela mesma57 // razao das noticias -- e legenda que o YouTube publica aberta, e cada linha58 // volta ao video pelo link. O trecho existe para o leitor poder DISCORDAR da59 // classificacao, que e a regra deste acervo: nenhuma tela afirma sem mostrar60 // o porque.61 // Publico de proposito, ao contrario do `video_mencoes`: so a CONTAGEM por62 // gestora. E o que permite a aba existir com cadeado -- sem ela, quem nao tem63 // conta nunca saberia que ha video ali. Mesmo par que `cartas`/`cartas_contagem`.64 'video_contagem.parquet',65 // Um video por linha, SEM trecho: e o que a tela de Conteudo lista. Publico66 // pelo mesmo criterio dos outros agregados -- titulo e data nao citam ninguem.67 'video_lista.parquet',68 'video_mencoes.parquet',69 'video_temas.parquet',70 'video_empresa_mencoes.parquet',71 'video_empresas.parquet',72 // Processos sancionadores da CVM. PUBLICOS: a CVM publica nome do acusado,73 // fase e objeto -- este site so liga isso a gestora e a integrante.74 'processos.parquet',75 'processos_acusado.parquet',76 // Voto em assembleia, identificado por cruzamento do mapa da companhia com a77 // carteira declarada no CDA. As duas pontas sao dado publico.78 'votos_assembleia.parquet',79 // Quem ganhou e quem perdeu mandato de gestao. Sai do cadastro da CVM, que e80 // publico -- nenhuma posicao de fundo entra aqui, so quem gere o que.81 'mandatos.parquet',82 // Sobreposicao de carteira entre casas, do CDA do ultimo mes COMPLETO.83 'carteira_similar.parquet',84 // A mesma medida um nivel abaixo, fundo a fundo. PRIVADO, ao contrario do de85 // cima: o criterio ja estava escrito -- aquele e publico porque e agregado por86 // gestora, sem nada por fundo. Este e por fundo.87 'carteira_similar_fundo.parquet',88 // OS MESMOS DOIS, PAR A PAR: a linha e a dupla, sem corte de 12 vizinhos por89 // casa. Sao arquivos proprios e nao uma consulta sobre os de cima porque90 // aqueles NAO dao para ranquear -- guardam os 12 vizinhos mais parecidos de91 // cada um, e um par forte fora do top 12 das duas pontas some dos dois lados92 // ao mesmo tempo (37% dos pares acima de 80%, no nivel de fundo). E o que a93 // pagina Sobreposicao le. O de fundo e privado, como o vizinho dele.94 'carteira_similar_par.parquet',95 'carteira_similar_fundo_par.parquet',96 // O PESO DE CADA PAPEL MES A MES, que a "sobreposicao ao longo do tempo" le.97 // Sao o mesmo dado do `ativos_fundo` numa ordem que serve a pergunta: por98 // CNPJ e por grupo, com grupo de linhas pequeno. Aquele e ordenado por ticker,99 // entao a serie de um par custava 169,7 MB -- o arquivo inteiro. Os dois sao100 // privados: o de gestora e a carteira de acoes da casa papel a papel, que e101 // mais do que o site publica em qualquer outro lugar.102 'sobrep_serie_fundo.parquet',103 'sobrep_serie_gestora.parquet',104 // Fato relevante da CVM, com resumo e sinal de modelo. PUBLICO: o documento105 // e publicado pela propria companhia para ser lido por qualquer um, e cada106 // linha volta ao PDF original -- o que e a regra deste acervo, porque uma107 // classificacao que o leitor nao pode conferir nao vale nada.108 'fatos_relevantes.parquet',109 // Que casa tem posicao na empresa que comunicou. Mart PROPRIO, e pequeno (170110 // KB), porque a alternativa era a tela cruzar `ativos_fundo` -- 43 MB -- no111 // navegador do leitor a cada mudanca de filtro.112 'fatos_gestora.parquet',113];114 115// Formato de cada mart opcional, para a view vazia ter as MESMAS colunas do116// arquivo de verdade. Se divergirem, o site deixa de quebrar no boot e passa a117// quebrar na aba — que é pior, porque some do teste e aparece para o leitor.118const VAZIOS = {119 preco_mensal: `select null::date mes, null::varchar ticker,120 null::double fechamento, null::double medio,121 null::smallint pregoes`,122 cartas_contagem: `select null::integer grupo_id, null::integer n`,123 video_mencoes: `select null::varchar caminho, null::integer nivel,124 null::varchar tema, null::varchar eixo, null::integer grupo_id,125 null::varchar gestora, null::varchar pessoa,126 null::varchar video_id, null::varchar titulo,127 null::varchar publicado, null::integer mencoes,128 null::varchar trecho`,129 video_contagem: `select null::integer grupo_id, null::integer n`,130 video_lista: `select null::varchar video_id, null::varchar titulo,131 null::varchar publicado, null::varchar gestora,132 null::integer grupo_id, null::varchar pessoa,133 null::integer n_temas, null::integer mencoes, null::varchar eixos`,134 video_empresa_mencoes: `select null::varchar ticker, null::varchar nome,135 null::integer grupo_id, null::varchar gestora,136 null::varchar pessoa, null::varchar video_id,137 null::varchar titulo, null::varchar publicado,138 null::integer mencoes, null::varchar trecho,139 null::varchar profundidade, null::integer bloco,140 null::integer argumento, null::double digito`,141 video_empresas: `select null::varchar ticker, null::varchar nome,142 null::bigint n_videos, null::bigint n_gestoras,143 null::double mencoes, null::double profundas`,144 video_temas: `select null::varchar termos, null::varchar caminho,145 null::varchar eixo, null::integer nivel,146 null::varchar tema, null::integer n_videos, null::integer n_gestoras,147 null::integer mencoes, null::integer n_videos_galho,148 null::integer n_gestoras_galho, null::integer n_corpus`,149 // `local` (aqui) e `data` (em votos_assembleia) sao PALAVRAS RESERVADAS no150 // DuckDB e precisam de aspas. Sem elas o esquema vazio nao compila -- e ele so151 // roda quando o mart falha ao abrir, entao o defeito ficava escondido ate o152 // pior momento possivel: no lugar da pagina vazia, a tela mostrava "Parser153 // Error: syntax error at or near local", que nao diz nada a quem le e ainda154 // culpa o lugar errado.155 processos: `select null::varchar nup, null::varchar objeto, null::varchar ementa,156 null::varchar tipo, null::varchar abertura, null::varchar fase,157 null::varchar subfase, null::varchar orgao, null::varchar "local",158 null::varchar movimentacao`,159 processos_acusado: `select null::varchar nup, null::varchar acusado,160 null::varchar situacao, null::varchar data_situacao,161 null::integer grupo_id, null::varchar gestora,162 null::varchar papel, null::varchar vinculo,163 null::boolean vinculo_atual, null::integer ano_min,164 null::integer ano_max`,165 // Este esquema ficou quatro colunas atras do mart: `descricao` e `pauta`166 // entraram com a classificacao, `protocolo` e `link` em 11/09/2026 com o mapa167 // de votacao na CVM. Sem conta -- ou com o mart fora do ar -- a aba pedia168 // colunas que a view vazia nao tinha e caia inteira.169 votos_assembleia: `select null::varchar cnpj_cia, null::varchar companhia,170 null::varchar "data", null::varchar tipo_assembleia,171 null::varchar materia, null::integer item,172 null::varchar descricao, null::varchar pauta,173 null::varchar id_mascarado,174 null::varchar cnpj_fundo, null::varchar fundo,175 null::integer grupo_id, null::varchar gestora,176 null::double apr, null::double rej, null::double abs,177 null::double total, null::varchar tickers,178 null::varchar protocolo, null::varchar link`,179 fatos_relevantes: `select null::varchar codigo_cvm, null::varchar companhia,180 null::varchar assunto, null::varchar data_ref,181 null::varchar entrega, null::varchar protocolo,182 null::varchar sequencia, null::varchar versao,183 null::varchar status, null::varchar resumo,184 null::varchar sinal, null::integer confianca,185 null::varchar por_que, null::boolean reversivel,186 null::varchar nota, null::varchar modelo,187 null::integer chars, null::varchar coletado_em,188 null::varchar tickers, null::varchar ticker_ref,189 null::integer fundos_com_papel,190 null::varchar entrega_iso, null::varchar link,191 null::varchar fonte`,192 fatos_gestora: `select null::varchar protocolo, null::varchar ticker,193 null::integer grupo_id, null::varchar gestora,194 null::double valor, null::bigint n_fundos, null::date mes_cda`,195 mandatos: `select null::varchar cnpj, null::varchar mes,196 null::integer grupo_de, null::integer grupo_para,197 null::varchar gestora_de, null::varchar gestora_para,198 null::varchar fundo, null::varchar classe,199 null::boolean exclusivo, null::double pl,200 null::integer n_lote, null::varchar tipo`,201 carteira_similar_fundo: `select null::varchar cnpj, null::varchar fundo,202 null::varchar gestora, null::integer grupo_id,203 null::varchar cnpj_par, null::varchar fundo_par,204 null::varchar gestora_par, null::integer grupo_id_par,205 null::boolean mesma_casa, null::varchar classe,206 null::varchar classe_par, null::double comum,207 null::bigint n_comum, null::double cos,208 null::bigint n_papeis, null::double valor_acoes`,209 carteira_similar: `select null::integer grupo_id, null::varchar gestora,210 null::integer grupo_id_par, null::varchar gestora_par,211 null::double comum, null::bigint n_comum, null::double cos,212 null::bigint n_papeis, null::double valor_acoes`,213 carteira_similar_par: `select null::smallint grupo_id_a,214 null::smallint grupo_id_b, null::double comum,215 null::bigint n_comum, null::date mes`,216 carteira_similar_fundo_par: `select null::varchar cnpj_a, null::varchar cnpj_b,217 null::double comum, null::bigint n_comum,218 null::date mes`,219 sobrep_serie_fundo: `select null::varchar cnpj, null::date dt_ref,220 null::varchar ticker, null::float w`,221 sobrep_serie_gestora: `select null::smallint grupo_id, null::date dt_ref,222 null::varchar ticker, null::float w`,223 cartas: `select null::integer grupo_id, null::varchar gestora, null::varchar periodo,224 null::varchar titulo, null::varchar url, null::varchar dominio,225 null::bigint bytes, null::varchar cnpjs`,226 noticias: `select null::varchar escopo, null::integer grupo_id, null::varchar gestora,227 null::date dt, null::varchar veiculo, null::varchar titulo,228 null::varchar resumo, null::varchar url, null::varchar sinal,229 null::varchar termo`,230 // Sem o mart, a secao de fundos estrangeiros simplesmente nao aparece.231 eua_brasil: `select null::date periodo, null::varchar cik, null::varchar casa,232 null::varchar serie_id, null::varchar fundo, null::double pl_fundo,233 null::varchar isin, null::varchar cusip, null::varchar ticker,234 null::varchar raiz, null::varchar emissor, null::varchar lei,235 null::varchar tipo, null::varchar lado, null::double valor_usd,236 null::double pct_pl, null::varchar via`,237 eua_alias: `select null::varchar de, null::varchar para, null::date desde,238 null::varchar obs`,239 // As sete ultimas colunas entraram no mart depois deste esquema e ninguem o240 // atualizou: sem conta, `blocoPessoas` pedia `resumo` e `url` a uma view que241 // nao os tinha, e a aba caia inteira em vez de aparecer sem a coluna "Quem e".242 pessoas: `select null::integer grupo_id, null::varchar gestora,243 null::varchar pessoa, null::varchar chave, null::integer ano,244 null::varchar cargo, null::varchar area, null::varchar formacao,245 null::double anos_instituicao, null::double anos_mercado,246 null::varchar fonte, null::varchar cpf,247 null::varchar escritorio_aai, null::varchar aai_cnpj,248 null::varchar aai_dt_entrada, null::varchar aai_uf,249 null::varchar aai_via, null::varchar resumo, null::varchar url`,250 ativos_dim: `select null::varchar ticker, null::varchar nome, null::varchar tipo,251 null::integer n_fundos, null::integer n_fundos_total,252 null::integer n_meses, null::date ultimo_mes`,253 ativos_fundo: `select null::varchar cnpj, null::date dt_ref, null::varchar ticker,254 null::varchar ds_ativo, null::varchar tp_aplic,255 null::double valor, null::double pct_pl`,256 conteudo_mencoes: `select null::varchar ticker, null::integer grupo_id,257 null::varchar gestora, null::varchar periodo,258 null::varchar titulo, null::varchar url,259 null::integer mencoes, null::varchar trecho,260 null::varchar profundidade, null::integer bloco,261 null::integer argumento, null::double digito`,262 conteudo_termos: `select null::varchar ticker, null::varchar nome, null::varchar tipo,263 null::integer n_fundos, null::varchar termos`,264 // Sem o mart, a comparacao simplesmente nao se oferece -- a caixa nao aparece.265 // As duas colunas de bolsa, porque o mart tem as duas: `ibov` e o indice266 // (o que o grafico compara) e `bova` e o preco do ETF, que fica para267 // conferencia. Declarar so uma aqui deixaria a tela quebrar no dia em que o268 // parquet faltasse -- que e exatamente o dia em que este esquema entra.269 benchmark_diario: `select null::date dt, null::double cdi_dia,270 null::double cdi_idx, null::double ibov,271 null::double bova`,272};273 274/** Nota do asterisco, versão de tela: uma linha. O que o leitor precisa saber275 * para não interpretar o gráfico errado, e nada além disso.276 *277 * A versão longa — por que índice encadeado e não média de cotas, quais dias278 * são descartados, que filtros não se aplicam a PL e cota — vive na279 * Metodologia. Ela estava aqui, com oito linhas embaixo de um gráfico, e280 * competia com o próprio gráfico pela atenção. */281function notaCota(escopo) {282 const link = ' <a href="#/metodo">Como é calculada</a>.';283 // A ressalva do benchmark aparece so quando ele esta LIGADO: quem nao marcou284 // nao precisa ler sobre uma linha que nao esta na tela.285 const b = benchLigados();286 const ref = !b.length ? '' :287 ' As linhas tracejadas são referência rebaseada a 100 no mesmo ponto da cota.'288 + (b.includes('ibov')289 ? ' A de bolsa é o <b>índice Ibovespa</b> (não um ETF), da série diária do'290 + ' IPEADATA, desde 2000.'291 : '')292 + (b.includes('cdi') ? ' O CDI é acumulado dia a dia desde 2000.' : '');293 if (escopo === 'fundo') {294 return 'Cota rebaseada a 100 no início do período para dividir o eixo com o ' +295 'patrimônio: o que se lê é a variação, não o valor da cota.' + link + ref;296 }297 return 'Cota média ponderada pelo patrimônio de cada fundo, encadeada dia a dia: ' +298 'não é a média das cotas.' + link + ref;299}300 301/** Tela estreita. O corte de 560px e o mesmo do styles.css, para a tarja nao302 * encolher num ponto e o layout em outro. */303function telaEstreita() {304 return window.innerWidth <= 560;305}306 307// Espelha os tokens de styles.css. Os gráficos precisam do valor em JS, e ter308// os hex espalhados pelo arquivo é como um tema começa a divergir de si mesmo.309const TEMA = {310 branco: '#1e201c',311 tinta: '#eceae4',312 tinta3: '#8d8c83',313 regua: '#2e302b',314 reguaFina: '#232520',315 teal: '#4fb07a',316 claret: '#e0705c',317 tealArea: 'rgba(79,176,122,.16)',318 claretArea: 'rgba(224,112,92,.14)',319 transp: 'rgba(18,19,17,0)',320 papel: '#121311',321 mono: '"IBM Plex Mono", ui-monospace, "SF Mono", Consolas, monospace',322};323 324const JANELA_LABEL = {325 '5d': '5 dias', '1m': '1 mês', '3m': '3 meses', '6m': '6 meses',326 '12m': '12 meses', 'ytd': 'No ano', 'tudo': 'Tudo', 'custom': 'Período',327};328const JANELA_MESES = { '5d': 0.25, '1m': 1, '3m': 3, '6m': 6, '12m': 12, 'ytd': 8, 'tudo': 320 };329 330const estado = {331 janela: '3m',332 // intervalo livre: só vale quando `janela === 'custom'`333 de: null, ate: null,334 modo: 'prorata',335 incluirExclusivos: false,336 meta: null,337 ordem: { col: 'net', dir: 'asc' }, // asc = maiores resgates primeiro? nao: ver aplicaOrdem338};339 340let db, conn;341/** A carga adiantada da extensao parquet. Ver o comentario em `iniciar`. */342let _parquetPronto = null;343 344// ------------------------------------------------------------ formatacao --345 346const nf = (n, d = 1) => {347 // Defesa: tipo largo do DuckDB (HUGEINT/DECIMAL) chega como buffer do Arrow e,348 // sem isto, um Uint32Array renderiza como "17,0,0,0" na tela.349 const x = typeof n === 'number' ? n : Number(n);350 if (!isFinite(x)) return '—';351 return x.toLocaleString('pt-BR', { minimumFractionDigits: d, maximumFractionDigits: d });352};353 354/** Valores em R$ com escala automatica. Fluxo de fundo vive entre milhoes e bilhoes. */355function brl(v, { sinal = true, casas = null } = {}) {356 if (v == null || !isFinite(v)) return '—';357 const a = Math.abs(v);358 const s = v < 0 ? '−' : (sinal && v > 0 ? '+' : '');359 if (a >= 1e9) return `${s}${nf(a / 1e9, casas ?? 2)} bi`;360 if (a >= 1e6) return `${s}${nf(a / 1e6, casas ?? 1)} mi`;361 if (a >= 1e3) return `${s}${nf(a / 1e3, casas ?? 0)} mil`;362 return `${s}${nf(a, 0)}`;363}364 365/** Rotulo de eixo: uma casa decimal SO quando ela distingue duas marcas.366 *367 * Os eixos chamavam `brl(v, {casas: 0})`, e acima de 1 bi isso apagava a fatia368 * que separa uma marca da seguinte: num eixo indo a R$ 2,5 bi as marcas 1,5 bi369 * e 2,3 bi imprimiam as duas "2 bi". Duas reguas diferentes com o mesmo rotulo370 * nao e arredondamento, e leitura errada -- quem le acha que o grafico ficou371 * parado onde ele subiu 800 milhoes.372 *373 * A casa entra so quando o valor nao e multiplo redondo da unidade, senao o374 * eixo vira "1,0 bi / 2,0 bi / 3,0 bi", que polui sem informar. */375function brlEixo(v) {376 if (v == null || !isFinite(v)) return '—';377 const a = Math.abs(v);378 const u = a >= 1e9 ? 1e9 : a >= 1e6 ? 1e6 : a >= 1e3 ? 1e3 : 1;379 return brl(v, { sinal: false, casas: Number.isInteger(a / u) ? 0 : 1 });380}381 382/** Nome proprio vindo de cadastro: a CVM e o FRE publicam em CAIXA ALTA.383 *384 * 11.996 dos 17.396 registros de `pessoas.parquet` chegam inteiros em385 * maiuscula, e um nome gritado no meio de uma pagina de papel le como erro.386 * As particulas ficam minusculas ("Marcio Coutinho de Oliveira Fontes"), que e387 * a convencao do portugues; o que ja vem com caixa mista e devolvido intacto,388 * porque ali a fonte escreveu do jeito que queria e nao cabe a nos reescrever. */389const PARTICULAS = new Set(['de', 'da', 'do', 'das', 'dos', 'e', 'di', 'du', 'del', 'della', 'van', 'von', 'y']);390function nomeProprio(s) {391 if (!s) return s;392 if (s !== s.toUpperCase()) return s; // ja tem caixa mista: nao mexe393 return s.toLowerCase().split(/(\s+)/).map((p, i) => {394 if (!p.trim()) return p;395 if (i > 0 && PARTICULAS.has(p)) return p;396 // "D'AVILA" e "SANT'ANA": a letra depois do apostrofo tambem sobe397 return p.replace(/(^|['’-])([a-zà-ÿ])/g, (_, a, b) => a + b.toUpperCase());398 }).join('');399}400 401/** Razao social vinda de cadastro, na MESMA caixa do nome da pessoa.402 *403 * A lista de pessoas junta duas fontes que escrevem diferente: o Anuario da FGV404 * traz a marca ("Bahia Asset Management"), o FRE traz a razao social gritada405 * ("BW GESTAO DE INVESTIMENTOS LTDA."). Lado a lado na mesma coluna, a segunda406 * nao le como nome de empresa -- le como erro de importacao.407 *408 * A regra e a do `nomeProprio` mais tres excecoes que so existem em nome de409 * empresa, e sem elas o conserto ficava pior que o problema:410 *411 * SIGLA "BB", "BTG", "JGP", "DTVM" viravam "Bb", "Btg", "Jgp", "Dtvm".412 * Detectadas por nao terem vogal, ou por terem duas letras --413 * num nome de empresa, duas letras sao iniciais, nao palavra.414 * TIPO SOCIETARIO "LTDA." e "S.A." sao forma juridica, nao parte do nome:415 * ficam em caixa de titulo ("Ltda.") e nao gritando.416 * PARTICULA ja tratada pelo `nomeProprio`, e vale igual aqui ("Gestao de417 * Recursos", nao "Gestao De Recursos").418 *419 * O que ja vem em caixa mista continua intocado, pela mesma razao de la: "BTG420 * Pactual" e "Bradesco Asset (BRAM)" sao a grafia que a propria casa usa, e421 * reescrever isso seria trocar informacao por uniformidade. */422const SIGLAS = new Set(['BB', 'BTG', 'XP', 'AZ', 'JGP', 'DTVM', 'CTVM', 'EQI',423 'BRAM', 'REAG', 'HSI', 'AAI', 'AI', 'RI', 'ETF', 'FII',424 'FIP', 'FIDC', 'DTC', 'BV', 'AF', 'BR', 'EB', 'WM', 'AM']);425 426// Preposicao de DUAS LETRAS. Elas precisam de lista propria porque a heuristica427// de sigla reivindica todo token de duas letras -- foi ela que produziu428// "Investimento NO Exterior" e "Fundo de Investimento EM Cotas", que e o nome429// de metade dos fundos do pais. Nome de PESSOA nao tem "em" nem "no", entao a430// lista fica separada da `PARTICULAS` em vez de engordar aquela.431const PARTICULAS_EMPRESA = new Set([...PARTICULAS,432 'em', 'no', 'na', 'nos', 'nas', 'ao', 'aos', 'com', 'para', 'por', 'sob',433 'sobre', 'entre', 'sem']);434const TIPO_SOCIETARIO = new Map([435 ['LTDA', 'Ltda'], ['LTDA.', 'Ltda.'], ['S.A.', 'S.A.'], ['S.A', 'S.A.'],436 ['S/A', 'S.A.'], ['SA', 'S.A.'], ['ME', 'ME'], ['EPP', 'EPP'],437 ['EIRELI', 'Eireli'],438]);439 440function nomeEmpresa(s) {441 if (!s) return s;442 if (s !== s.toUpperCase()) return s; // caixa mista: a casa escolheu443 // A particula depende da POSICAO -- "de" so fica minusculo quando nao abre o444 // nome --, e por isso a regra e reescrita aqui em vez de delegar token a445 // token ao `nomeProprio`: chamado com uma palavra so, ele sempre a ve na446 // posicao 0 e devolve "De".447 return s.split(/(\s+)/).map((p, i) => {448 if (!p.trim()) return p;449 const cru = p.toUpperCase();450 if (TIPO_SOCIETARIO.has(cru)) return TIPO_SOCIETARIO.get(cru);451 const min = p.toLowerCase();452 // A PARTICULA vem antes da sigla, e nao depois: "de", "da" e "do" tem duas453 // letras, entao a heuristica de sigla os reivindicava primeiro e devolvia454 // "BB Gestao DE Recursos" -- com a preposicao gritando no meio da frase.455 // Na posicao 0 ela e o comeco do nome ("De Paula Capital") e sobe; depois456 // dela e ligacao e desce. Sem o ramo do i === 0 a sigla a capturava e457 // devolvia "DE Paula Capital".458 if (PARTICULAS_EMPRESA.has(min)) return i > 0 ? min : min.charAt(0).toUpperCase() + min.slice(1);459 const letras = cru.replace(/[^A-ZÀ-Ý]/g, '');460 // Sem vogal, ou com duas letras so: e sigla, e sigla nao vira Palavra.461 if (letras && (SIGLAS.has(letras) || !/[AEIOUYÀ-Ý]/.test(letras)462 || letras.length === 2)) return cru;463 return min.replace(/(^|['’-])([a-zà-ÿ])/g, (_, a, b) => a + b.toUpperCase());464 }).join('');465}466 467/** O rotulo da lista de pessoas: "Cesar Correa · Tarpon Investimentos".468 *469 * Um lugar so para os dois lados do rotulo, porque eles apareciam em tres470 * telas -- a sugestao do cadastro, a tabela da gestora e a linha do tempo -- e471 * em duas delas a pessoa vinha corrigida e a empresa nao. */472function pessoaComCasa(pessoa, casa) {473 const n = nomeProprio(pessoa || '');474 const c = nomeEmpresa(casa || '');475 return c ? `${n} · ${c}` : n;476}477 478function pct(v, casas = 2) {479 if (v == null || !isFinite(v)) return '—';480 const s = v < 0 ? '−' : (v > 0 ? '+' : '');481 return `${s}${nf(Math.abs(v) * 100, casas)}%`;482}483 484const cls = v => (v > 0 ? 'pos' : v < 0 ? 'neg' : 'muted');485 486/** Fluxo como % do PL, usando o maior valor entre as duas pontas da janela.487 * Só com o PL inicial, um fundo que quadruplicou marca "+300%"; só com o final,488 * um fundo esvaziado por resgate marca percentuais absurdos. O maior dos dois489 * mantém a leitura ancorada no tamanho real do veículo. */490const baseRel = r => {491 const base = Math.max(r.pl_ini || 0, r.pl ?? r.pl_atual ?? 0);492 return base > 0 ? r.net / base : null;493};494const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>495 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));496 497function cnpjFmt(c) {498 if (!c || c.length !== 14) return c || '';499 return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5, 8)}/${c.slice(8, 12)}-${c.slice(12)}`;500}501 502const dataBR = s => (s ? s.split('-').reverse().join('/') : '—');503 504const MES_ABREV = ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'];505 506/** "2025-10-31" → "out/2025". Composição de carteira é sempre um fechamento de507 * mês, então o dia não informa nada — e a data cheia dava a impressão de que o508 * dado era daquele dia. */509const mesAnoBR = s => {510 if (!s) return '—';511 const [ano, mes] = s.split('-');512 return `${MES_ABREV[parseInt(mes, 10) - 1] ?? mes}/${ano}`;513};514 515/** Referência dos blocos de composição de carteira.516 *517 * A data NÃO é o mês mais recente que a CVM publicou, e isso é de propósito. A518 * CVM permite omitir posições da carteira por cerca de três meses, e o arquivo519 * do mês novo não traz a posição oculta marcada — ela simplesmente não está520 * lá, e aparece depois, quando a CVM republica aquele mês. Medido: jul/2026521 * trazia 9.531 fundos contra 20.252 em abr/2026, menos da metade. Mostrar o mês522 * novo seria mostrar meia carteira com cara de carteira inteira, então o ETL523 * corta no último mês completo e é essa data que aparece aqui. */524const refCarteira = dt => `(${mesAnoBR(dt)}*)`;525const LEGENDA_REF = '<div class="legenda-ref">* último mês completo: a CVM permite '526 + 'omitir posições por cerca de 3 meses, então o mês mais recente vem incompleto. '527 + '<a href="#/metodo">Por quê</a>.</div>';528 529/** Valor em reais exato, sem escala. Aplicação mínima de R$ 1.000 não pode530 * aparecer como "1 mil" — o cotista precisa do número. */531const brlExato = v => (v == null || !isFinite(v) ? '—'532 : v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }));533 534/** Taxa em % ao ano. A CVM já publica em pontos percentuais. */535const taxaPct = (v, casas = 2) => (v == null || !isFinite(v) ? null536 : `${nf(v, casas).replace(/,00$/, '')}%`);537 538// ------------------------------------------------------------- sql utils --539 540/** Fator aplicado ao fluxo de cada fundo para neutralizar dupla contagem. */541function fator() {542 switch (estado.modo) {543 case 'prorata': return '(1 - coalesce(d.w_int, 0))';544 case 'master': return '(case when coalesce(d.w_int,0) > 0.5 then 0 else 1 end)';545 default: return '1';546 }547}548 549const filtroUniverso = () => (estado.incluirExclusivos ? '' : 'and not d.exclusivo');550 551/** Coluna de PL da série da gestora que corresponde aos toggles de agora.552 *553 * A série é PRÉ-CALCULADA (o índice de cota encadeado não sai na hora, e o PL554 * não pode vir do `flow_diario`, que só guarda linha com movimento). Então o555 * mart traz o mesmo PL sob os três modos, separado por universo, e aqui a tela556 * escolhe — em vez de desenhar sempre a soma bruta, como fazia.557 *558 * O sintoma era feio: na Absolute a pílula dizia "PL 50,41 bi" e o gráfico559 * logo abaixo, 95,52 bi, os dois rotulados PL, e trocar de modo não mexia no560 * gráfico. Em 787 gestoras, 231 mostravam mais que o dobro. */561function colunaPl(pre = 's.') {562 const m = { prorata: 'pro', master: 'mst' }[estado.modo] || 'bruto';563 const ne = `${pre}pl_ne_${m}`;564 return estado.incluirExclusivos ? `(${ne} + ${pre}pl_ex_${m})` : ne;565}566 567// ---------------------------------------------------------- janela de taxa --568 569/** Quantos MESES de taxa entram em cada janela do site (`null` = do começo do ano).570 *571 * A taxa é mensal e o balancete sai com atraso, então a janela de taxa não572 * coincide com a de fluxo por construção: ela tem o mesmo COMPRIMENTO, mas573 * termina no último mês fechado do balancete, não no último dia do informe.574 * Alinhar as datas exatas seria mentir — o dado simplesmente não existe para o575 * mês corrente. A tela sempre imprime o intervalo real ao lado do número.576 *577 * "5 dias" cai em 1 mês porque é o mínimo que a fonte oferece; abaixo de um mês578 * não há taxa nenhuma para mostrar. */579// `null` = do começo do ano; `0` = a série inteira. As duas janelas novas580// precisam de tratamento próprio: sem isso `_mesIdx(fim) - (undefined - 1)`581// devolve NaN e a janela de taxa sai vazia sem reclamar.582const MESES_TAXA = { '5d': 1, '1m': 1, '3m': 3, '6m': 6, '12m': 12, ytd: null, tudo: 0 };583 584const _mesIdx = s => { const [a, m] = s.split('-').map(Number); return a * 12 + (m - 1); };585const _mesStr = i => `${String(Math.floor(i / 12)).padStart(4, '0')}-${String((i % 12) + 1).padStart(2, '0')}-01`;586 587/** Intervalo de meses da janela corrente, ou null quando não há série de taxa. */588function janelaTaxa(j = estado.janela) {589 const fim = estado.meta?.taxa_dt_fim;590 if (!fim) return null;591 const piso = estado.meta.taxa_dt_ini;592 // intervalo livre: usa as datas do usuário, recortadas pela série de taxa —593 // ela começa em 2015 e termina antes do informe, então o pedido raramente594 // cabe inteiro e recortar é mais honesto que devolver vazio595 if (j === 'custom') {596 if (!estado.de || estado.ate < piso || estado.de > fim) return null;597 const ini = estado.de < piso ? piso : `${estado.de.slice(0, 7)}-01`;598 const f = estado.ate > fim ? fim : `${estado.ate.slice(0, 7)}-01`;599 return ini > f ? null : { ini, fim: f };600 }601 const n = MESES_TAXA[j];602 const ini = n == null ? `${fim.slice(0, 4)}-01-01` // ytd603 : n === 0 ? piso // tudo604 : _mesStr(_mesIdx(fim) - (n - 1));605 return { ini: piso && ini < piso ? piso : ini, fim };606}607 608/** "jul/2025 a jun/2026", ou "jun/2026" quando a janela é de um mês só. */609function rotuloJanelaTaxa(jt = janelaTaxa()) {610 if (!jt) return '';611 return jt.ini === jt.fim ? mesAnoBR(jt.fim) : `${mesAnoBR(jt.ini)} a ${mesAnoBR(jt.fim)}`;612}613 614/** Soma de taxa por fundo na janela, já com o fator de dupla contagem, mais o615 * PL MÉDIO dos mesmos meses.616 *617 * O PL médio do período existe aqui por um motivo concreto: a taxa efetiva618 * calculada sobre o PL de hoje mente para todo fundo que encolheu. O "Bradesco619 * Feeder TNA" aparecia com 26,91% de taxa efetiva — R$ 322,6 mi de taxa contra620 * R$ 1,20 bi de PL atual — porque a taxa foi cobrada quando o fundo tinha621 * dezenas de bilhões, não depois de esvaziado. Dividir pelo patrimônio que de622 * fato pagou a taxa é a única leitura defensável.623 *624 * E a taxa efetiva sai ANUALIZADA, para que o número signifique a mesma coisa625 * em qualquer janela e possa ser comparado com a alíquota do regulamento. */626/* `taxa_mensal` traz `pl_fim`? Preenchido no boot, uma vez.627 *628 * A coluna passou a viajar com a taxa no ETL para tirar da aba a dependencia do629 * `flow_mensal`. MEDIDO no site publicado: o primeiro toque no flow_mensal custa630 * 21,2s -- ele tem 49,3 MB, fica acima do TETO_BUFFER e e lido por pedacos. O631 * join em si custava 154 ms; o preco era abrir o arquivo.632 *633 * Subir o teto para 52 MB nao serve, e isso ja foi medido: o flow_mensal entrava634 * e a pagina de Rankings passou a baixar o arquivo inteiro antes da primeira635 * linha. */636let _taxaTemPl = null;637 638async function taxaTemPl() {639 if (_taxaTemPl === null) {640 _taxaTemPl = (await colunas('taxa_mensal')).has('pl_fim');641 }642 return _taxaTemPl;643}644 645function cteTaxa(escopo = '', j = estado.janela, nome = 'tx', janela = null,646 temPl = true) {647 const jt = janela || janelaTaxa(j);648 if (!jt) return null;649 // Sem a coluna -- publicacao anterior a esta mudanca -- volta ao join, que650 // continua correto, so lento. Consultar `t.pl_fim` quando ela nao existe nao651 // devolve nulo: levanta erro e derruba a aba.652 const pl = temPl ? 'avg(t.pl_fim) pl_medio,'653 : 'avg(m.pl_fim) pl_medio,';654 const jm = temPl ? ''655 : "left join flow_mensal m on m.cnpj = t.cnpj and m.mes = t.mes and m.pl_fim > 0";656 return `${nome} as (657 select t.cnpj,658 sum(t.adm * f.fx) adm,659 sum(t.perfm * f.fx) perfm,660 ${pl}661 count(*) meses662 from taxa_mensal t663 join f on f.cnpj = t.cnpj664 ${jm}665 where t.mes >= date '${jt.ini}' and t.mes <= date '${jt.fim}' ${escopo}666 group by t.cnpj667 )`;668}669 670/** Acima disto não é taxa, é artefato contábil.671 *672 * O balancete traz lançamentos retroativos: o "Bradesco Feeder TNA" tem saldo673 * acumulado de R$ 1,12 mi em mar/2026 e R$ 323,40 mi em abr/2026, voltando a674 * andar R$ 0,15 mi por mês depois — não é a taxa de abril, é uma regularização675 * de períodos anteriores caindo num mês só.676 *677 * Medido: 3.513 fundo-mês em 2,32 milhões (0,15%) passam de 20% a.a., e somam678 * 0,62% do total. O AGREGADO não é tocado — o dinheiro saiu do fundo e removê-lo679 * erraria para o outro lado. O que se suprime é a TAXA EFETIVA desses casos, que680 * seria lida como preço e não é. 20% a.a. é o mesmo teto usado para a alíquota681 * declarada, por coerência. */682const TETO_EFETIVA = 0.20;683 684/** Taxa efetiva anualizada: o que o cotista pagou, comparável com a alíquota.685 * Devolve null quando o número não é plausível como preço. */686function efetivaAA(valor, plMedio, meses) {687 if (valor == null || !(plMedio > 0) || !(meses > 0)) return null;688 const aa = (valor / plMedio) * (12 / meses);689 return aa >= 0 && aa <= TETO_EFETIVA ? aa : null;690}691 692/** CTE `jf`: os agregados da janela corrente, no mesmo formato de `janelas_fundo`.693 *694 * Existe para que o resto do site não precise saber se a janela é uma das695 * pré-calculadas ou um intervalo que o usuário digitou. Pré-calcular todo696 * intervalo possível seria impossível; calcular sempre no navegador seria lento697 * para o caso comum. Então: janela conhecida lê o mart, intervalo livre agrega698 * na hora.699 *700 * O intervalo livre sai de `flow_diario` quando cabe nos 2 anos da série701 * diária, e de `flow_mensal` quando é mais antigo. A troca é automática porque702 * a alternativa — recusar datas antigas — seria pior que a perda de precisão de703 * usar o mês fechado.704 */705function cteJanela() {706 if (estado.janela !== 'custom') {707 return `jf as (select * from janelas_fundo where janela = '${estado.janela}')`;708 }709 const de = estado.de, ate = estado.ate;710 const diario = de >= estado.meta.dt_inicio_diario;711 const fonte = diario ? 'flow_diario' : 'flow_mensal';712 const col = diario ? 'dt' : 'mes';713 // no mensal a coluna é o primeiro dia do mês, então o filtro tem de pegar o714 // mês inteiro em que a data final cai715 const ateSql = diario ? `date '${ate}'` : `date_trunc('month', date '${ate}')`;716 // A cota vem SEMPRE do mart mensal, mesmo quando o fluxo vem do diário: o717 // `flow_diario` não carrega cota, e sem isto o período livre seria a única718 // janela sem retorno. A ponta inicial é a cota do mês ANTERIOR ao intervalo —719 // mesma regra das janelas fixas —, então o número tem resolução de mês, não720 // de dia. Intervalo dentro de um único mês fica sem retorno, e é o certo:721 // não há duas pontas para comparar.722 const mesDe = `date_trunc('month', date '${de}')`;723 return `jf as (724 with fl as (725 select cnpj,726 sum(captacao) captacao,727 sum(resgate) resgate,728 sum(captacao) - sum(resgate) net,729 count(*)::int dias730 from ${fonte}731 where ${col} >= date '${de}' and ${col} <= ${ateSql}732 group by cnpj733 ),734 c0 as (735 select cnpj, first(cota_fim order by mes desc) cota_ini736 from flow_mensal where mes < ${mesDe} and cota_fim > 0 group by cnpj737 ),738 c1 as (739 select cnpj, first(cota_fim order by mes desc) cota_fim740 from flow_mensal741 where mes >= ${mesDe} and mes <= ${ateSql} and cota_fim > 0 group by cnpj742 )743 select fl.*, null::double pl_ini, null::double pl_fim,744 c0.cota_ini, c1.cota_fim745 from fl left join c0 using (cnpj) left join c1 using (cnpj)746 )`;747}748 749/** CTE reutilizada: cada fundo com seu fator e ja filtrado pelo universo. */750function cteFundos() {751 return `f as (752 select d.cnpj, d.grupo_id, d.nome, d.pl_atual, d.w_int, d.w_fic, d.exclusivo, d.fic,753 d.situacao,754 ${fator()} as fx755 from dim_fundo d756 where 1=1 ${filtroUniverso()}757 )`;758}759 760/* ---------------------------------------------- views sob demanda --------761 *762 * O boot criava as 88 views antes de mostrar a tela, e isso era 80% do tempo de763 * abertura. MEDIDO no site publicado, com cache vazio:764 *765 * 1,2s pagina e bibliotecas766 * +1,2s instanciar o DuckDB WASM767 * +24,8s registrar arquivos e criar as views <-- aqui768 * +3,8s materializar as dimensoes769 * =31,1s770 *771 * Cada `create view ... parquet_scan(...)` precisa do ESQUEMA, e para isso le o772 * rodape do Parquet por HTTP. Sao 88 arquivos (17 marts + 71 particoes) a ~280ms773 * de ida e volta. E nao adianta paralelizar: o DuckDB-WASM le arquivo remoto com774 * XHR SINCRONO dentro do worker, entao as leituras nao se sobrepoem -- varias775 * conexoes so enfileiram no mesmo thread.776 *777 * O desperdicio nao e a lentidao de cada leitura, e o numero delas: a home usa778 * meia duzia de marts, nao 88. Entao a view passa a nascer na primeira consulta779 * que a menciona, e cada tela paga so pelo que usa. Nao ha aquecimento em780 * segundo plano de proposito: ele voltaria a ocupar o worker com as 88 leituras781 * e a primeira navegacao do visitante ficaria na fila atras delas. */782const _views = new Map();783let _nomesMart = [];784let _ver = ''; // ?v=<hash>: preenchido no boot785 786/* Mart pequeno vai INTEIRO, numa requisicao. Mart grande fica em range request.787 *788 * O DuckDB-WASM le Parquet remoto em pedacos, e cada pedaco e uma requisicao789 * HTTP sincrona no worker. No Dataset do Hugging Face cada uma ainda paga um790 * 307 para o CDN, entao sao DUAS viagens por pedaco. MEDIDO no site publicado,791 * primeiro toque de cada mart:792 *793 * taxa_mensal 74,4s (13,8 MB em 12 particoes)794 * carteira_ativos 37,3s (32,8 MB)795 * ativos_fundo 8,0s (43,5 MB)796 *797 * O custo nao acompanha o TAMANHO -- `ativos_fundo` e tres vezes maior que798 * `taxa_mensal` e leva um decimo do tempo. Ele acompanha o numero de idas e799 * voltas, e por isso ate um `count(*)`, que deveria ler so metadado, custava os800 * 74s. Baixar o arquivo inteiro de uma vez troca centenas de viagens por uma.801 *802 * O teto foi de 36 para 45 MB depois de medir: `flow_diario` (40,8 MB) custava803 * 13,6s e `flow_mensal` (49,3 MB) 16,2s no primeiro toque, e sao eles que o804 * grafico de abertura da pagina da gestora precisa -- 23,4s no Itau. A poda por805 * data que eu esperava nao salva: o filtro e por `grupo_id`, e o arquivo nao e806 * ordenado por ele, entao a varredura le quase tudo de qualquer jeito. Sendo os807 * mesmos bytes, baixar de uma vez ganha das centenas de idas e voltas.808 *809 * 45 e nao 52 por medicao, nao por gosto: em 52 o `flow_mensal` (49,3 MB)810 * entrava junto, e a pagina de Rankings -- que le ele com filtro de data --811 * passou a baixar o arquivo inteiro antes de desenhar a primeira linha, e812 * estourou o tempo do teste. Ajudar uma tela e atrapalhar outra nao e ganho.813 * Em 45 entra so o `flow_diario`, que e o que o grafico da gestora precisa.814 *815 * O teto continua existindo para a memoria nao virar o proximo problema:816 * `carteira_ativos` tem 130 MB e fica de fora -- a pagina do FUNDO consulta ele817 * por CNPJ, que poda bem (0,2s), e a da GESTORA agora le818 * `carteira_ativos_gestora`, que ja vem somado. */819const TETO_BUFFER = 45e6;820 821async function bufferizar(arquivos) {822 await Promise.all(arquivos.map(async arq => {823 const url = new URL(`${DATA_BASE}/${arq}${_ver}`, location.href).href;824 const r = await fetch(url);825 if (!r.ok) throw new Error(`${arq}: HTTP ${r.status}`);826 await db.registerFileBuffer(arq, new Uint8Array(await r.arrayBuffer()));827 }));828}829 830/** Bytes que este mart ocupa, segundo o meta.json. */831function bytesDoMart(nome) {832 const a = estado.meta?.arquivos ?? {};833 return a[nome]?.bytes ?? a[`${nome}.parquet`]?.bytes ?? Infinity;834}835 836function criarView(nome) {837 if (_views.has(nome)) return _views.get(nome);838 const p = (async () => {839 const partes = (estado.meta?.particoes ?? {})[nome];840 // Troca a leitura por pedaços por um download único, quando cabe.841 if (bytesDoMart(nome) <= TETO_BUFFER) {842 try {843 await bufferizar(partes ?? [`${nome}.parquet`]);844 } catch (e) {845 // Falhou o atalho: segue pelo caminho normal, que continua correto.846 console.warn(`buffer de ${nome} falhou, usando range request:`, e.message);847 }848 }849 if (partes) {850 const lista = partes.map(x => `'${x}'`).join(', ');851 await conn.query(`create or replace view ${nome} as select * from parquet_scan([${lista}])`);852 return;853 }854 const arq = `${nome}.parquet`;855 try {856 await conn.query(`create or replace view ${nome} as select * from parquet_scan('${arq}')`);857 } catch (e) {858 // Mart OPCIONAL ausente. Cartas, pessoas e ativos nascem de coletas que859 // rodam fora do run_all.py e cujas fontes nao existem no runner da nuvem;860 // sem esta saida, uma publicacao sem eles derrubaria a tela que os usa.861 // A view vazia com o mesmo formato faz a contagem dar zero, e dai a aba862 // some sozinha -- que e o comportamento ja definido para gestora sem carta.863 if (!VAZIOS[nome]) throw e;864 console.warn(`mart opcional ausente: ${arq}, a funcionalidade fica oculta`);865 await conn.query(`create or replace view ${nome} as ${VAZIOS[nome]} where false`);866 }867 })();868 _views.set(nome, p);869 return p;870}871 872/** Cria as views que ESTE SQL menciona, e espera so por elas. */873function garantirViews(sql) {874 const usados = _nomesMart.filter(n => new RegExp(`\\b${n}\\b`).test(sql));875 return usados.length ? Promise.all(usados.map(criarView)) : Promise.resolve();876}877 878async function q(sql) {879 await garantirViews(sql);880 const tbl = await conn.query(sql);881 return tbl.toArray().map(r => {882 const o = typeof r.toJSON === 'function' ? r.toJSON() : { ...r };883 for (const k in o) {884 if (typeof o[k] === 'bigint') o[k] = Number(o[k]);885 else if (o[k] && typeof o[k] === 'object' && o[k].constructor?.name === 'Vector') {886 o[k] = Array.from(o[k]);887 }888 }889 return o;890 });891}892 893const q1 = async sql => (await q(sql))[0] ?? null;894 895/** Contador de roteamentos. Ver o comentario em `rotear`. */896let _epoca = 0;897/** A renderizacao que chamou ainda e a da vez? */898const epocaAtual = e => e === _epoca;899 900/** As colunas que a view tem de fato, em cache.901 *902 * Existe por causa da janela de publicacao: a casca sobe antes dos dados, e por903 * alguns minutos o JS novo le o Parquet antigo. Consultar uma coluna que ainda904 * nao existe nao devolve nulo -- levanta erro e derruba a tela. Entao quem905 * depende de coluna recem-criada pergunta antes. */906const _cols = new Map();907async function colunas(view) {908 if (_cols.has(view)) return _cols.get(view);909 let set = new Set();910 try {911 await garantirViews(view);912 const tbl = await conn.query(`select * from ${view} limit 0`);913 set = new Set(tbl.schema.fields.map(f => f.name));914 } catch (e) {915 console.warn(`nao consegui ler o esquema de ${view}`, e);916 }917 _cols.set(view, set);918 return set;919}920 921// Gancho para o teste de ponta a ponta (tools/testar_site.mjs). Ele precisa922// escolher casos pelos DADOS -- "um fundo que cobra performance com benchmark" --923// em vez de fixar um CNPJ que um dia sai da base. Expor não abre nada: o924// DuckDB já roda inteiro no navegador do visitante, sobre Parquet público.925globalThis.__q = q;926 927// ------------------------------------------------------------------ boot --928 929/** Frases da tela de abertura. Trocam sozinhas enquanto o WASM e os Parquet930 * carregam — o que leva alguns segundos e ficaria sem graça com uma barra só. */931// A ABERTURA MOSTRA QUANTO FALTA, e nao uma frase.932//933// A versao anterior girava catorze frases do setor a cada 2,3s. Elas eram boas934// e a ideia era honesta -- distrair de uma espera que nao da para encurtar --,935// mas espera com frase e espera sem numero: quem abre o site nao sabe se faltam936// dois segundos ou vinte, e a frase que muda parece progresso sem ser.937//938// A PORCENTAGEM E MEDIDA, e nao encenada. A maior fatia da abertura e download939// de arquivo, e download tem `Content-Length`: da para contar byte que chegou940// contra byte prometido. As fases que nao tem como medir por dentro -- compilar941// o WASM do DuckDB -- avancam por tempo DENTRO da propria faixa e nunca a942// ultrapassam, entao a barra nunca volta atras nem mente sobre ter acabado.943 944// Cada fase ocupa uma faixa da barra. Os pesos saem de medicao no site945// publicado, com cache frio: motor 1,2s, indice 0,8s, dimensoes 3,7s, tela946// 1,2s. Faixa proporcional ao que a fase de fato custa e o unico jeito de a947// barra andar em ritmo constante.948const BOOT_FASES = {949 motor: [0, 18],950 indice: [18, 26],951 dimensoes: [26, 88],952 tela: [88, 100],953};954 955let _bootPct = 0;956let _bootCreep = null;957 958function bootMostra(pct) {959 // NUNCA VOLTA ATRAS. Uma barra que recua diz ao leitor que o trabalho foi960 // desfeito, e nunca e isso que aconteceu.961 _bootPct = Math.max(_bootPct, Math.min(100, pct));962 const b = document.getElementById('bootBar');963 const t = document.getElementById('bootPct');964 if (b) b.style.width = `${_bootPct}%`;965 if (t) t.textContent = `${Math.round(_bootPct)}%`;966}967 968/** Entra numa fase. `medida` diz se ela sabe reportar o proprio avanco; quando969 * nao sabe, a barra caminha sozinha ate 85% da faixa e espera ali. */970function bootFase(nome, medida = false) {971 const [de, ate] = BOOT_FASES[nome];972 clearInterval(_bootCreep);973 bootMostra(de);974 if (medida) return (frac) => bootMostra(de + (ate - de) * Math.min(1, frac));975 const t0 = performance.now();976 _bootCreep = setInterval(() => {977 // Assintotica: aproxima do teto da faixa sem alcancar. 1.400ms e a978 // constante que faz a fase de 1,2s chegar perto do fim quando ela acaba.979 const f = 1 - Math.exp(-(performance.now() - t0) / 1400);980 bootMostra(de + (ate - de) * 0.85 * f);981 }, 90);982 return null;983}984 985function bootFim(nome) {986 clearInterval(_bootCreep);987 _bootCreep = null;988 bootMostra(BOOT_FASES[nome][1]);989}990 991/** Baixa os arquivos da abertura EM PARALELO, contando os bytes.992 *993 * POR QUE NAO DEIXAR O DUCKDB BAIXAR. O worker dele le arquivo remoto com XHR994 * SINCRONO -- este arquivo ja registra isso noutro lugar --, entao quatro995 * leituras viram quatro esperas em fila. Medido na abertura publicada:996 * dim_fundo terminava em 3,0s, dim_gestora em 5,1s, ativos_dim em 5,6s e997 * cartas em 5,8s, uma depois da outra. Buscados aqui, na thread principal,998 * eles saem juntos e o custo vira o do maior.999 *1000 * `allSettled` porque `cartas` e mart PRIVADO: sem conta ele responde 404, e1001 * isso nao e falha de abertura -- e o estado normal de quem nao entrou. O que1002 * falhar continua registrado por URL e se vira sozinho depois.1003 */1004async function bootBaixa(arquivos, avanca) {1005 const total = {};1006 const lidos = {};1007 const soma = o => Object.values(o).reduce((a, b) => a + b, 0);1008 const um = async arq => {1009 const url = new URL(`${DATA_BASE}/${arq}${_ver}`, location.href).href;1010 const r = await fetch(url);1011 if (!r.ok) throw new Error(`${arq}: HTTP ${r.status}`);1012 const n = Number(r.headers.get('content-length') || 0);1013 total[arq] = n || 0;1014 lidos[arq] = 0;1015 // Sem `Content-Length` (ou sem corpo legivel) nao da para contar: le de uma1016 // vez e credita tudo no fim. A barra fica mais dura, e continua verdadeira.1017 if (!n || !r.body) {1018 const b = new Uint8Array(await r.arrayBuffer());1019 lidos[arq] = total[arq] = b.length;1020 if (avanca) avanca(soma(lidos) / Math.max(1, soma(total)));1021 return [arq, b];1022 }1023 const leitor = r.body.getReader();1024 const pedacos = [];1025 for (;;) {1026 const { done, value } = await leitor.read();1027 if (done) break;1028 pedacos.push(value);1029 lidos[arq] += value.length;1030 if (avanca) avanca(soma(lidos) / Math.max(1, soma(total)));1031 }1032 const b = new Uint8Array(lidos[arq]);1033 let off = 0;1034 for (const pe of pedacos) { b.set(pe, off); off += pe.length; }1035 return [arq, b];1036 };1037 const rs = await Promise.allSettled(arquivos.map(um));1038 const ok = [];1039 for (const r of rs) {1040 if (r.status !== 'fulfilled') { console.warn('abertura:', r.reason?.message); continue; }1041 const [arq, bytes] = r.value;1042 await db.registerFileBuffer(arq, bytes);1043 ok.push(arq);1044 }1045 if (avanca) avanca(1);1046 return ok;1047}1048 1049async function iniciar() {1050 try {1051 bootFase('motor');1052 const bundles = duckdb.getJsDelivrBundles();1053 const bundle = await duckdb.selectBundle(bundles);1054 const workerUrl = URL.createObjectURL(1055 new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' })1056 );1057 const worker = new Worker(workerUrl);1058 db = new duckdb.AsyncDuckDB(new duckdb.VoidLogger(), worker);1059 await db.instantiate(bundle.mainModule, bundle.pthreadWorker);1060 URL.revokeObjectURL(workerUrl);1061 conn = await db.connect();1062 1063 // A EXTENSAO PARQUET COMECA A VIR AGORA, e nao na primeira consulta.1064 //1065 // O DuckDB-WASM carrega `parquet.duckdb_extension.wasm` (0,67 MB) sob1066 // demanda, no primeiro `parquet_scan` -- que acontece la no fim, quando as1067 // dimensoes viram tabela. Medido na abertura publicada: o pedido comecava1068 // aos 3,9s e levava 848ms, sozinho, com a rede ociosa e a barra parada em1069 // 98%. Disparado aqui, ele viaja JUNTO com os 2,1 MB de dimensoes -- tempo1070 // que ja estava sendo gasto de qualquer jeito.1071 //1072 // Sem `await`: a promessa fica guardada e so e cobrada onde o parquet e1073 // realmente preciso. Se falhar, nao derruba nada -- o `parquet_scan`1074 // seguinte faz o carregamento do jeito antigo, que e o comportamento de1075 // hoje.1076 _parquetPronto = conn.query('load parquet').catch(e => {1077 console.warn('extensao parquet adiantada falhou; segue pelo caminho normal:',1078 e.message);1079 });1080 1081 bootFim('motor');1082 bootFase('indice');1083 // meta.json sempre da rede: e ele que carrega a versao dos dados, entao uma1084 // copia velha em cache faria o resto vir velho junto1085 estado.meta = await (await fetch(`${DATA_BASE}/meta.json?t=${Date.now()}`,1086 { cache: 'no-store' })).json();1087 1088 // As tres pequenas viram TABELA (ficam em memoria, usadas em toda consulta);1089 // as grandes ficam como VIEW sobre o Parquet remoto, lidas por range request1090 // conforme a necessidade. Um nome nao pode ser view e tabela ao mesmo tempo,1091 // entao a decisao acontece aqui, uma vez so.1092 // Materializar = baixar o arquivo inteiro na abertura. Vale para as duas1093 // dimensoes pequenas, que toda consulta usa. `janelas_fundo` saiu daqui: eram1094 // 4,4 MB baixados no boot para usar UMA janela por vez, e agora ela vem1095 // particionada por janela (~600 KB cada, sob demanda).1096 const MATERIALIZAR = new Set(['dim_fundo', 'dim_gestora']);1097 1098 // Os Parquet trocam de conteúdo mantendo o mesmo nome a cada atualização.1099 // Sem a versão na URL, o navegador serve um dim_fundo em cache junto de um1100 // flow_diario recém-baixado; os grupo_id de um não batem com os do outro e a1101 // gestora aparece sem nenhum fundo. A versão é hash do conteúdo, então só1102 // muda quando os dados mudam de verdade.1103 const v = estado.meta.versao ? `?v=${encodeURIComponent(estado.meta.versao)}` : '';1104 _ver = v; // `criarView` precisa dele para baixar o mart inteiro1105 const registrar = async arq => {1106 const url = new URL(`${DATA_BASE}/${arq}${v}`, location.href).href;1107 await db.registerFileURL(arq, url, duckdb.DuckDBDataProtocol.HTTP, false);1108 };1109 1110 // `registerFileURL` só guarda o de-para nome→URL: não toca a rede. Por isso1111 // os 88 arquivos podem ir todos juntos, e isso custa milissegundos. O que1112 // custava eram as views, que agora nascem sob demanda (ver `criarView`).1113 const particoes = estado.meta.particoes ?? {};1114 const todos = [...ARQUIVOS, ...Object.values(particoes).flat()];1115 await Promise.all(todos.map(registrar));1116 1117 // A lista de nomes que `garantirViews` procura dentro de cada SQL.1118 _nomesMart = [1119 ...ARQUIVOS.map(a => a.replace('.parquet', '')).filter(n => !MATERIALIZAR.has(n)),1120 ...Object.keys(particoes),1121 ];1122 1123 bootFim('indice');1124 1125 // TUDO QUE A ABERTURA PRECISA, DE UMA VEZ. As duas dimensoes pequenas viram1126 // TABELA porque toda consulta as usa; `ativos_dim` e `cartas` entram na1127 // mesma leva porque a contagem que decide se a aba existe vem logo abaixo,1128 // e deixa-las para o worker custava duas esperas em fila no fim do boot.1129 const avanca = bootFase('dimensoes', true);1130 await bootBaixa([...[...MATERIALIZAR].map(n => `${n}.parquet`),1131 'ativos_dim.parquet', 'cartas.parquet'], avanca);1132 bootFim('dimensoes');1133 1134 // A FASE `tela` AGORA MEDE. Antes ela era "nao medida", e uma fase nao1135 // medida caminha sozinha ate 85% da propria faixa e ESPERA ali -- com a1136 // faixa de 88 a 100, isso e exatamente 98,2%. A barra chegava a 98% e1137 // sentava por 1,3s, que e a travada que se ve. O trabalho daqui tem quatro1138 // passos conhecidos; contar quatro passos e melhor que fingir progresso.1139 const passo = bootFase('tela', true);1140 let feito = 0;1141 const PASSOS = 4;1142 const anda = () => passo(++feito / PASSOS);1143 1144 // A extensao parquet e cobrada AQUI, na primeira vez que ela e mesmo1145 // necessaria -- a esta altura ela costuma ja ter chegado, porque saiu junto1146 // com as dimensoes.1147 await _parquetPronto;1148 anda();1149 1150 for (const nome of MATERIALIZAR) {1151 await conn.query(`create or replace table ${nome} as select * from parquet_scan('${nome}.parquet')`);1152 }1153 anda();1154 1155 // Aba de navegação some quando o mart dela não veio: um item de menu que1156 // leva a uma página sempre vazia é pior que item nenhum. Vai por `q()` para1157 // a view nascer sozinha — e são só estes dois arquivos a mais no boot.1158 // Com conta, os 5 marts privados sao reapontados para o Worker ANTES da1159 // contagem abaixo -- senao ela leria as views vazias e esconderia a aba de1160 // quem tem acesso.1161 _sessao = _leSessao();1162 if (temConta()) await abrirPrivados();1163 anda();1164 // `_privOk` separa "nao tem conta" de "tem conta e o dado nao veio".1165 pintaChipConta();1166 1167 // A aba some quando o mart nao veio; mas quando ele existe e so exige conta,1168 // ela FICA, com cadeado. Sao coisas diferentes: ausencia de dado e ausencia1169 // de credencial, e so a segunda o leitor pode resolver. Esconder aqui1170 // apagaria justamente o convite -- ninguem se cadastra para ver uma pagina1171 // que nao sabe que existe.1172 // O cadeado responde a MESMA pergunta que o portao do `rotear`: ha sessao?1173 // Amarra-lo a disponibilidade do dado parecia equivalente e nao e -- em1174 // desenvolvimento os Parquet privados vem de ./data, entao a contagem dava1175 // diferente de zero, o cadeado sumia, e a aba ficava sem marca enquanto a1176 // pagina estava bloqueada. Dois estados para a mesma coisa e um deles mente.1177 const marcar = (rota, tem) => {1178 const a = document.querySelector(`.tabs a[data-route="${rota}"]`);1179 if (!a) return;1180 if (cfgPriv && !temConta()) { a.classList.add('privada'); a.title = 'exige conta'; return; }1181 // Com sessao e sem dado, a aba FICA -- e em claret, com o motivo no1182 // title. Some-la aqui era o pior desfecho possivel: o leitor pagou o1183 // cadastro e viu o menu ENCOLHER, o que parece punicao por ter entrado,1184 // e nao sobra nada na tela para ele reportar.1185 if (cfgPriv && temConta() && !_privOk) {1186 a.classList.add('privada', 'falhou');1187 a.title = `entrou, mas os dados nao abriram: ${motivoPrivado()}`;1188 return;1189 }1190 a.classList.remove('privada', 'falhou');1191 if (!tem) a.remove(); // mart mesmo ausente: some, como antes1192 };1193 // AS DUAS CONTAGENS SAEM JUNTAS. Uma depois da outra eram duas idas ao1194 // worker em fila no ultimo passo do boot, e a segunda so comecava quando a1195 // primeira voltava -- ~250ms jogados fora com a rede parada. Elas nao1196 // dependem uma da outra.1197 const [nAtivos, nCartas] = await Promise.all([1198 q('select count(*) n from ativos_dim').then(r => Number(r[0]?.n ?? 0)).catch(() => 0),1199 q('select count(*) n from cartas').then(r => Number(r[0]?.n ?? 0)).catch(() => 0),1200 ]);