CoolFace
Apppublic

PrumoFS/financial-data

sourceHugging Facemitupdated 7d agoView on Hugging Face
0likes
app.js12212 linesDownload Raw Back to root
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  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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    ]);

Showing the first 1,200 of 12212 lines. Download the file for the rest.