pyield 0.49.4__tar.gz → 0.49.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {pyield-0.49.4 → pyield-0.49.5}/PKG-INFO +2 -1
  2. {pyield-0.49.4 → pyield-0.49.5}/README.md +1 -0
  3. {pyield-0.49.4 → pyield-0.49.5}/pyield/b3/di_over.py +29 -26
  4. pyield-0.49.5/pyield/tpf/rmd/__init__.py +81 -0
  5. pyield-0.49.5/pyield/tpf/rmd/_aba_1_3.py +107 -0
  6. pyield-0.49.5/pyield/tpf/rmd/_aba_2_1.py +97 -0
  7. pyield-0.49.5/pyield/tpf/rmd/_common.py +41 -0
  8. pyield-0.49.5/pyield/tpf/rmd/_download.py +55 -0
  9. {pyield-0.49.4 → pyield-0.49.5}/pyproject.toml +1 -1
  10. pyield-0.49.4/pyield/tpf/rmd.py +0 -262
  11. {pyield-0.49.4 → pyield-0.49.5}/LICENSE +0 -0
  12. {pyield-0.49.4 → pyield-0.49.5}/pyield/__init__.py +0 -0
  13. {pyield-0.49.4 → pyield-0.49.5}/pyield/_internal/__init__.py +0 -0
  14. {pyield-0.49.4 → pyield-0.49.5}/pyield/_internal/br_numbers.py +0 -0
  15. {pyield-0.49.4 → pyield-0.49.5}/pyield/_internal/cache.py +0 -0
  16. {pyield-0.49.4 → pyield-0.49.5}/pyield/_internal/converters.py +0 -0
  17. {pyield-0.49.4 → pyield-0.49.5}/pyield/_internal/data_cache.py +0 -0
  18. {pyield-0.49.4 → pyield-0.49.5}/pyield/_internal/retry.py +0 -0
  19. {pyield-0.49.4 → pyield-0.49.5}/pyield/_internal/types.py +0 -0
  20. {pyield-0.49.4 → pyield-0.49.5}/pyield/anbima/__init__.py +0 -0
  21. {pyield-0.49.4 → pyield-0.49.5}/pyield/anbima/imaq.py +0 -0
  22. {pyield-0.49.4 → pyield-0.49.5}/pyield/anbima/mercado_secundario.py +0 -0
  23. {pyield-0.49.4 → pyield-0.49.5}/pyield/b3/__init__.py +0 -0
  24. {pyield-0.49.4 → pyield-0.49.5}/pyield/b3/_contratos.py +0 -0
  25. {pyield-0.49.4 → pyield-0.49.5}/pyield/b3/_validar_pregao.py +0 -0
  26. {pyield-0.49.4 → pyield-0.49.5}/pyield/b3/boletim.py +0 -0
  27. {pyield-0.49.4 → pyield-0.49.5}/pyield/b3/derivativos_intradia.py +0 -0
  28. {pyield-0.49.4 → pyield-0.49.5}/pyield/bc/__init__.py +0 -0
  29. {pyield-0.49.4 → pyield-0.49.5}/pyield/bc/_olinda.py +0 -0
  30. {pyield-0.49.4 → pyield-0.49.5}/pyield/bc/leiloes.py +0 -0
  31. {pyield-0.49.4 → pyield-0.49.5}/pyield/bc/sgs.py +0 -0
  32. {pyield-0.49.4 → pyield-0.49.5}/pyield/bc/tpf_intradia.py +0 -0
  33. {pyield-0.49.4 → pyield-0.49.5}/pyield/bc/tpf_mensal.py +0 -0
  34. {pyield-0.49.4 → pyield-0.49.5}/pyield/bc/vna.py +0 -0
  35. {pyield-0.49.4 → pyield-0.49.5}/pyield/di1.py +0 -0
  36. {pyield-0.49.4 → pyield-0.49.5}/pyield/du/__init__.py +0 -0
  37. {pyield-0.49.4 → pyield-0.49.5}/pyield/du/core.py +0 -0
  38. {pyield-0.49.4 → pyield-0.49.5}/pyield/du/feriados/__init__.py +0 -0
  39. {pyield-0.49.4 → pyield-0.49.5}/pyield/du/feriados/feriados_antigos_br.txt +0 -0
  40. {pyield-0.49.4 → pyield-0.49.5}/pyield/du/feriados/feriados_br.py +0 -0
  41. {pyield-0.49.4 → pyield-0.49.5}/pyield/du/feriados/feriados_novos_br.txt +0 -0
  42. {pyield-0.49.4 → pyield-0.49.5}/pyield/futuro/__init__.py +0 -0
  43. {pyield-0.49.4 → pyield-0.49.5}/pyield/futuro/contratos.py +0 -0
  44. {pyield-0.49.4 → pyield-0.49.5}/pyield/futuro/historico.py +0 -0
  45. {pyield-0.49.4 → pyield-0.49.5}/pyield/futuro/intradia.py +0 -0
  46. {pyield-0.49.4 → pyield-0.49.5}/pyield/fwd.py +0 -0
  47. {pyield-0.49.4 → pyield-0.49.5}/pyield/interpolador.py +0 -0
  48. {pyield-0.49.4 → pyield-0.49.5}/pyield/ipca/__init__.py +0 -0
  49. {pyield-0.49.4 → pyield-0.49.5}/pyield/ipca/historico.py +0 -0
  50. {pyield-0.49.4 → pyield-0.49.5}/pyield/ipca/projetado.py +0 -0
  51. {pyield-0.49.4 → pyield-0.49.5}/pyield/lft.py +0 -0
  52. {pyield-0.49.4 → pyield-0.49.5}/pyield/ltn.py +0 -0
  53. {pyield-0.49.4 → pyield-0.49.5}/pyield/ntnb.py +0 -0
  54. {pyield-0.49.4 → pyield-0.49.5}/pyield/ntnb1.py +0 -0
  55. {pyield-0.49.4 → pyield-0.49.5}/pyield/ntnbprinc.py +0 -0
  56. {pyield-0.49.4 → pyield-0.49.5}/pyield/ntnc.py +0 -0
  57. {pyield-0.49.4 → pyield-0.49.5}/pyield/ntnf.py +0 -0
  58. {pyield-0.49.4 → pyield-0.49.5}/pyield/py.typed +0 -0
  59. {pyield-0.49.4 → pyield-0.49.5}/pyield/relogio.py +0 -0
  60. {pyield-0.49.4 → pyield-0.49.5}/pyield/selic/__init__.py +0 -0
  61. {pyield-0.49.4 → pyield-0.49.5}/pyield/selic/compromissada.py +0 -0
  62. {pyield-0.49.4 → pyield-0.49.5}/pyield/selic/copom.py +0 -0
  63. {pyield-0.49.4 → pyield-0.49.5}/pyield/selic/cpm.py +0 -0
  64. {pyield-0.49.4 → pyield-0.49.5}/pyield/selic/probabilities.py +0 -0
  65. {pyield-0.49.4 → pyield-0.49.5}/pyield/tpf/__init__.py +0 -0
  66. {pyield-0.49.4 → pyield-0.49.5}/pyield/tpf/benchmark.py +0 -0
  67. {pyield-0.49.4 → pyield-0.49.5}/pyield/tpf/leiloes.py +0 -0
  68. {pyield-0.49.4 → pyield-0.49.5}/pyield/tpf/pre.py +0 -0
  69. {pyield-0.49.4 → pyield-0.49.5}/pyield/tpf/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyield
3
- Version: 0.49.4
3
+ Version: 0.49.5
4
4
  Summary: A Python library for analysis of fixed income instruments in Brazil
5
5
  Keywords: fixed-income,brazil,finance,analysis,bonds
6
6
  Author: Carlos Carvalho
@@ -46,6 +46,7 @@ Description-Content-Type: text/markdown
46
46
  [![Made with Python](https://img.shields.io/badge/Python->=3.12-blue?logo=python&logoColor=white)](https://python.org "Go to Python homepage")
47
47
  [![License](https://img.shields.io/badge/License-MIT-blue)](https://github.com/crdcj/PYield/blob/main/LICENSE)
48
48
  [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue?logo=readthedocs&logoColor=white)](https://crdcj.github.io/PYield/)
49
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/crdcj/PYield/blob/main/examples/pyield_quickstart.ipynb)
49
50
 
50
51
  # PYield: Toolkit de Renda Fixa Brasileira
51
52
 
@@ -2,6 +2,7 @@
2
2
  [![Made with Python](https://img.shields.io/badge/Python->=3.12-blue?logo=python&logoColor=white)](https://python.org "Go to Python homepage")
3
3
  [![License](https://img.shields.io/badge/License-MIT-blue)](https://github.com/crdcj/PYield/blob/main/LICENSE)
4
4
  [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue?logo=readthedocs&logoColor=white)](https://crdcj.github.io/PYield/)
5
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/crdcj/PYield/blob/main/examples/pyield_quickstart.ipynb)
5
6
 
6
7
  # PYield: Toolkit de Renda Fixa Brasileira
7
8
 
@@ -13,9 +13,12 @@ Notas de implementação:
13
13
  """
14
14
 
15
15
  import datetime as dt
16
- import ftplib
17
16
  import logging
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
18
20
 
21
+ from pyield import du
19
22
  from pyield._internal.cache import ttl_cache
20
23
  from pyield._internal.converters import converter_datas
21
24
  from pyield._internal.types import DateLike, any_is_empty
@@ -28,36 +31,36 @@ DATA_INICIO = dt.date(2012, 8, 20)
28
31
  # 4 casas decimais na taxa = 2 casas decimais em percentual
29
32
  CASAS_DECIMAIS_DI_OVER = 4
30
33
 
34
+ _URL_BASE = "ftp://ftp.cetip.com.br/MediaCDI/"
35
+ _MAX_TENTATIVAS = 3
36
+ _ESPERA = 2.0 # segundos entre tentativas (erro 421 é transitório)
37
+
31
38
 
32
39
  @ttl_cache()
33
40
  def _buscar_taxa(nome_arquivo: str) -> float:
34
41
  """Busca a taxa DI no FTP da CETIP para o arquivo informado."""
35
- try:
36
- with ftplib.FTP("ftp.cetip.com.br", timeout=10) as ftp:
37
- ftp.login()
38
- ftp.cwd("/MediaCDI")
39
-
40
- linhas = []
41
- try:
42
- ftp.retrlines(f"RETR {nome_arquivo}", linhas.append)
43
- except ftplib.error_perm as e:
44
- # Código 550 = arquivo não encontrado (feriado/fim de semana)
45
- if str(e).startswith("550"):
46
- return float("nan")
47
- raise
48
-
49
- if not linhas:
50
- registro.error("Arquivo %s está vazio.", nome_arquivo)
51
- return float("nan")
52
-
53
- # Formato usual: "00001315" -> 13.15% -> 0.1315
54
- taxa_bruta = linhas[0].strip()
55
- taxa = int(taxa_bruta) / 10**CASAS_DECIMAIS_DI_OVER
42
+ for tentativa in range(1, _MAX_TENTATIVAS + 1):
43
+ try:
44
+ with urllib.request.urlopen(_URL_BASE + nome_arquivo, timeout=10) as r:
45
+ conteudo = r.read().decode().strip()
46
+ taxa = int(conteudo) / 10**CASAS_DECIMAIS_DI_OVER
56
47
  return round(taxa, CASAS_DECIMAIS_DI_OVER)
48
+ except urllib.error.URLError as e:
49
+ motivo = str(e.reason)
50
+ # Código 550 = arquivo não encontrado (feriado/fim de semana)
51
+ if "550" in motivo:
52
+ return float("nan")
53
+ # Código 421 = muitas conexões simultâneas; erro transitório
54
+ if "421" in motivo and tentativa < _MAX_TENTATIVAS:
55
+ registro.warning(
56
+ "Erro FTP transitório (tentativa %s): %s", tentativa, e.reason
57
+ )
58
+ time.sleep(_ESPERA)
59
+ continue
60
+ raise ConnectionError(f"Falha ao buscar taxa DI via FTP: {e.reason}") from e
57
61
 
58
- except ftplib.all_errors as e:
59
- registro.error("Erro de conexão ou transferência FTP: %s", e)
60
- raise ConnectionError(f"Falha ao buscar taxa DI via FTP: {e}") from e
62
+ msg = "Fluxo de retry inválido."
63
+ raise RuntimeError(msg)
61
64
 
62
65
 
63
66
  def di_over(data: DateLike) -> float:
@@ -85,7 +88,7 @@ def di_over(data: DateLike) -> float:
85
88
  return float("nan")
86
89
 
87
90
  data_ref = converter_datas(data)
88
- if data_ref < DATA_INICIO:
91
+ if data_ref < DATA_INICIO or not du.eh_dia_util(data_ref):
89
92
  return float("nan")
90
93
 
91
94
  return _buscar_taxa(data_ref.strftime("%Y%m%d.txt"))
@@ -0,0 +1,81 @@
1
+ """Relatório Mensal da Dívida (RMD) do Tesouro Nacional."""
2
+
3
+ import logging
4
+
5
+ import polars as pl
6
+
7
+ from . import _aba_1_3, _aba_2_1
8
+ from ._download import baixar_planilha_rmd as _carregar_planilha_rmd
9
+
10
+ registro = logging.getLogger(__name__)
11
+
12
+ _IMPLEMENTACOES = {
13
+ "1.3": _aba_1_3.estruturar_dados,
14
+ "2.1": _aba_2_1.estruturar_dados,
15
+ }
16
+
17
+
18
+ def rmd(aba: str) -> pl.DataFrame:
19
+ """Retorna dados do Relatório Mensal da Dívida (RMD) do Tesouro Nacional.
20
+
21
+ Baixa e processa a planilha do RMD, extraindo dados da aba solicitada. A
22
+ publicação mais recente é descoberta automaticamente via parse HTML da página
23
+ oficial do Tesouro Transparente.
24
+
25
+ Args:
26
+ aba: Número da aba a processar. Abas implementadas: ``"1.3"`` e ``"2.1"``.
27
+
28
+ Returns:
29
+ DataFrame Polars no schema específico da aba solicitada. Em caso de erro,
30
+ retorna DataFrame vazio e registra o erro em log.
31
+
32
+ Output Columns:
33
+ Aba ``"1.3"``:
34
+ * periodo (Date): primeiro dia do mês de referência.
35
+ * grupo (String): seção principal — ``"Emissões"`` ou ``"Resgates"``.
36
+ * subgrupo (String): categoria dentro do grupo.
37
+ * titulo (String): tipo de título ou ``null`` para subgrupos sem
38
+ detalhamento por título.
39
+ * valor (Float64): valor em R$.
40
+ Aba ``"2.1"``:
41
+ * periodo (Date): primeiro dia do mês de referência.
42
+ * detentor (String): quem detém o estoque — ``"Público"`` ou
43
+ ``"Banco Central"``.
44
+ * tipo (String): classificação da dívida — ``"DPMFi"`` (interna) ou
45
+ ``"DPFe"`` (externa).
46
+ * categoria (String): subdivisão dentro do tipo, quando houver —
47
+ ``"Tesouro Nacional"``, ``"Banco Central"`` (emitente dentro da
48
+ DPMFi pública), ``"Mobiliária"``, ``"Contratual"``; ``null``
49
+ quando não há subdivisão (ex.: DPMFi em poder do Banco Central).
50
+ * titulo (String): título ou instrumento de dívida.
51
+ * valor (Float64): valor em R$. Somente registros folha; subtotais
52
+ devem ser calculados pelo usuário via agregação.
53
+
54
+ Raises:
55
+ ValueError: Se ``aba`` não estiver entre as abas implementadas.
56
+
57
+ Notes:
58
+ - A publicação mais recente é descoberta automaticamente via parse HTML
59
+ do Tesouro Transparente.
60
+ - A aba ``"1.3"`` traz emissões e resgates da DPMFi.
61
+ - A aba ``"2.1"`` traz a série histórica de estoque da DPF.
62
+
63
+ Examples:
64
+ >>> df = yd.tpf.rmd(aba="1.3") # doctest: +SKIP
65
+ >>> df = yd.tpf.rmd(aba="2.1") # doctest: +SKIP
66
+ """
67
+ if aba not in _IMPLEMENTACOES:
68
+ disponiveis = ", ".join(f'"{t}"' for t in sorted(_IMPLEMENTACOES))
69
+ raise ValueError(
70
+ f"Aba '{aba}' não disponível. Abas implementadas: {disponiveis}."
71
+ )
72
+
73
+ try:
74
+ conteudo_excel = _carregar_planilha_rmd()
75
+ df = _IMPLEMENTACOES[aba](conteudo_excel)
76
+ except Exception as e:
77
+ registro.exception(f"Erro ao coletar dados do RMD (aba {aba!r}): {e}")
78
+ return pl.DataFrame()
79
+
80
+ registro.info(f"Dados do RMD (aba {aba!r}) processados. Shape: {df.shape}.")
81
+ return df
@@ -0,0 +1,107 @@
1
+ """Parser da aba 1.3 do RMD."""
2
+
3
+ import datetime as dt
4
+
5
+ import polars as pl
6
+
7
+ from ._common import parsear_periodo
8
+
9
+ _LINHA_PERIODOS = 2
10
+ _LINHA_INICIO_DADOS = _LINHA_PERIODOS + 1
11
+
12
+ _TITULOS = ("LFT", "LTN", "NTN-B", "NTN-B1", "NTN-F", "NTN-C", "NTN-D", "Demais")
13
+ _SECOES = {"I - EMISSÕES": "Emissões", "II - RESGATES": "Resgates"}
14
+ _SUBGRUPOS = {"Vendas", "Trocas", "Vencimentos", "Compras"}
15
+ _SUBGRUPO_TD = "Tesouro Direto"
16
+ _SUBGRUPOS_DIRETOS = (
17
+ "Transferência de Carteira",
18
+ "Emissão Direta com Financeiro",
19
+ "Emissão Direta sem Financeiro",
20
+ "Pagamento de Dividendos",
21
+ "Cancelamentos",
22
+ )
23
+ _PREFIXOS_IGNORAR = ("IMPACTO", "OPERAÇÕES", "III -", "RESGATE")
24
+
25
+
26
+ def _classificar_categorias(
27
+ categorias: list[str],
28
+ ) -> list[tuple[int, str, str, str | None]]:
29
+ """Percorre rótulos de categoria e classifica linhas de dados."""
30
+ grupo = ""
31
+ subgrupo = ""
32
+ eventos: list[tuple[int, str, str, str | None]] = []
33
+
34
+ for i, cat in enumerate(categorias):
35
+ c = cat.strip()
36
+ if c in _SECOES:
37
+ grupo, subgrupo = _SECOES[c], ""
38
+ elif any(c.startswith(p) for p in _PREFIXOS_IGNORAR):
39
+ grupo = ""
40
+ elif grupo:
41
+ if c in _SUBGRUPOS:
42
+ subgrupo = c
43
+ elif c.startswith(_SUBGRUPO_TD):
44
+ subgrupo = _SUBGRUPO_TD
45
+ elif c in _TITULOS:
46
+ eventos.append((i, grupo, subgrupo, c))
47
+ else:
48
+ prefixo = next((p for p in _SUBGRUPOS_DIRETOS if c.startswith(p)), None)
49
+ if prefixo:
50
+ eventos.append((i, grupo, prefixo, None))
51
+
52
+ return eventos
53
+
54
+
55
+ def _montar_registros(
56
+ eventos: list[tuple[int, str, str, str | None]],
57
+ datas_mensais: list[dt.date],
58
+ matriz: pl.DataFrame,
59
+ ) -> pl.DataFrame:
60
+ """Monta DataFrame longo com todos os registros de emissões e resgates."""
61
+ linhas = [
62
+ (data, grupo, subgrupo, titulo, valor)
63
+ for idx, grupo, subgrupo, titulo in eventos
64
+ for data, valor in zip(datas_mensais, matriz.row(idx))
65
+ ]
66
+ return pl.DataFrame(
67
+ linhas,
68
+ schema={
69
+ "periodo": pl.Date,
70
+ "grupo": pl.String,
71
+ "subgrupo": pl.String,
72
+ "titulo": pl.String,
73
+ "valor": pl.Float64,
74
+ },
75
+ orient="row",
76
+ )
77
+
78
+
79
+ def estruturar_dados(conteudo_excel: bytes) -> pl.DataFrame:
80
+ """Lê a aba ``1.3`` do Excel e retorna DataFrame longo."""
81
+ df_bruto = pl.read_excel(
82
+ conteudo_excel,
83
+ sheet_name="1.3",
84
+ has_header=False,
85
+ )
86
+
87
+ periodos_raw = [str(p) for p in df_bruto.row(_LINHA_PERIODOS)[1:] if p is not None]
88
+
89
+ datas_e_indices = [
90
+ (i, data)
91
+ for i, periodo in enumerate(periodos_raw)
92
+ if (data := parsear_periodo(periodo)) is not None
93
+ ]
94
+ indices_mensais = [i for i, _ in datas_e_indices]
95
+ datas_mensais = [data for _, data in datas_e_indices]
96
+
97
+ df_dados = df_bruto[_LINHA_INICIO_DADOS:]
98
+ df_dados = df_dados.filter(df_dados[:, 0].is_not_null())
99
+
100
+ eventos = _classificar_categorias([str(c) for c in df_dados[:, 0].to_list()])
101
+ matriz = df_dados[:, 1:].cast(pl.Float64, strict=False)[:, indices_mensais]
102
+
103
+ return (
104
+ _montar_registros(eventos, datas_mensais, matriz)
105
+ .with_columns(valor=pl.col("valor").mul(1_000_000).round(2))
106
+ .filter(pl.col("valor").is_not_null() & (pl.col("valor") != 0))
107
+ )
@@ -0,0 +1,97 @@
1
+ """Parser da aba 2.1 do RMD."""
2
+
3
+ from datetime import date
4
+
5
+ import polars as pl
6
+
7
+ from ._common import limpar_rotulo, parsear_periodo
8
+
9
+ _LINHA_PERIODOS = 2
10
+ _LINHA_INICIO_DADOS = _LINHA_PERIODOS + 1
11
+
12
+ # Rótulos (uppercase) que definem transições de estado hierárquico.
13
+ # Valor: (detentor, tipo, categoria, pode_emitir)
14
+ # pode_emitir=False indica estado intermediário; linhas folha são ignoradas até
15
+ # a próxima transição com pode_emitir=True.
16
+ _TRANSICOES: dict[str, tuple[str | None, str | None, str | None, bool]] = {
17
+ "DPF EM PODER DO PÚBLICO": (None, None, None, False),
18
+ "DPMFI": ("Público", "DPMFi", None, False),
19
+ "TESOURO NACIONAL": ("Público", "DPMFi", "Tesouro Nacional", True),
20
+ "BANCO CENTRAL": ("Público", "DPMFi", "Banco Central", True),
21
+ "DPFE": ("Público", "DPFe", None, False),
22
+ "DÍVIDA MOBILIÁRIA": ("Público", "DPFe", "Mobiliária", True),
23
+ "DÍVIDA CONTRATUAL": ("Público", "DPFe", "Contratual", True),
24
+ "DPMFI EM PODER DO BANCO CENTRAL": ("Banco Central", "DPMFi", None, True),
25
+ }
26
+
27
+
28
+ def _obter_periodos_mensais(
29
+ df_bruto: pl.DataFrame,
30
+ ) -> list[tuple[int, date]]:
31
+ """Extrai os pares (índice_coluna, data) dos períodos mensais válidos."""
32
+ periodos_raw = [str(p) for p in df_bruto.row(_LINHA_PERIODOS)[1:] if p is not None]
33
+ return [
34
+ (i, data)
35
+ for i, periodo in enumerate(periodos_raw)
36
+ if (data := parsear_periodo(periodo)) is not None
37
+ ]
38
+
39
+
40
+ def _montar_registros(df_bruto: pl.DataFrame) -> list[tuple[object, ...]]:
41
+ """Converte o bloco hierárquico da aba em registros longos (somente folhas)."""
42
+ periodos = _obter_periodos_mensais(df_bruto)
43
+ linhas = df_bruto[_LINHA_INICIO_DADOS:]
44
+ detentor: str | None = None
45
+ tipo: str | None = None
46
+ categoria: str | None = None
47
+ pode_emitir: bool = False
48
+ registros: list[tuple[object, ...]] = []
49
+
50
+ for linha in linhas.iter_rows():
51
+ bruto = linha[0]
52
+ if bruto is None:
53
+ continue
54
+
55
+ rotulo = limpar_rotulo(bruto)
56
+ if not rotulo:
57
+ continue
58
+
59
+ transicao = _TRANSICOES.get(rotulo.upper())
60
+ if transicao is not None:
61
+ detentor, tipo, categoria, pode_emitir = transicao
62
+ continue
63
+
64
+ if not pode_emitir:
65
+ continue
66
+
67
+ valores = linha[1:]
68
+ for indice, data in periodos:
69
+ registros.append((data, detentor, tipo, categoria, rotulo, valores[indice]))
70
+
71
+ return registros
72
+
73
+
74
+ def estruturar_dados(conteudo_excel: bytes) -> pl.DataFrame:
75
+ """Lê a aba ``2.1`` do Excel e retorna DataFrame longo."""
76
+ df_bruto = pl.read_excel(
77
+ conteudo_excel,
78
+ sheet_name="2.1",
79
+ has_header=False,
80
+ )
81
+
82
+ return (
83
+ pl.DataFrame(
84
+ _montar_registros(df_bruto),
85
+ schema={
86
+ "periodo": pl.Date,
87
+ "detentor": pl.String,
88
+ "tipo": pl.String,
89
+ "categoria": pl.String,
90
+ "titulo": pl.String,
91
+ "valor": pl.Float64,
92
+ },
93
+ orient="row",
94
+ )
95
+ .with_columns(valor=pl.col("valor").mul(1_000_000_000).round(2))
96
+ .filter(pl.col("valor").is_not_null())
97
+ )
@@ -0,0 +1,41 @@
1
+ """Helpers compartilhados pelos parsers do RMD."""
2
+
3
+ import datetime as dt
4
+ import re
5
+
6
+ _MESES_PT = {
7
+ "Jan": 1,
8
+ "Fev": 2,
9
+ "Mar": 3,
10
+ "Abr": 4,
11
+ "Mai": 5,
12
+ "Jun": 6,
13
+ "Jul": 7,
14
+ "Ago": 8,
15
+ "Set": 9,
16
+ "Out": 10,
17
+ "Nov": 11,
18
+ "Dez": 12,
19
+ }
20
+
21
+ _PADRAO_ESPACOS = re.compile(r"\s+")
22
+
23
+
24
+ def parsear_periodo(periodo: str) -> dt.date | None:
25
+ """Converte string de período para ``datetime.date`` ou ``None``."""
26
+ try:
27
+ mes_str, ano_str = periodo.split("/")
28
+ except ValueError:
29
+ return None
30
+
31
+ mes = _MESES_PT.get(mes_str)
32
+ if mes is None:
33
+ return None
34
+
35
+ return dt.date(2000 + int(ano_str), mes, 1)
36
+
37
+
38
+ def limpar_rotulo(valor: object) -> str:
39
+ """Remove espaços e notas de rodapé do rótulo lido do Excel."""
40
+ texto = str(valor).replace("¹", "").replace("²", "").strip()
41
+ return _PADRAO_ESPACOS.sub(" ", texto)
@@ -0,0 +1,55 @@
1
+ """Download e extração da planilha do RMD."""
2
+
3
+ import io
4
+ import zipfile as zf
5
+
6
+ import requests
7
+ from lxml import html
8
+
9
+ from pyield._internal.cache import ttl_cache
10
+ from pyield._internal.retry import retry_padrao
11
+
12
+ URL_BASE = (
13
+ "https://www.tesourotransparente.gov.br/publicacoes/relatorio-mensal-da-divida-rmd"
14
+ )
15
+ _TIMEOUT_SEGUNDOS = 60
16
+ _TTL_UM_DIA = 86_400 # segundos
17
+
18
+
19
+ @retry_padrao
20
+ def _buscar_conteudo(url: str) -> bytes:
21
+ """Busca o conteúdo de uma URL, seguindo redirects, com retry."""
22
+ resposta = requests.get(url, timeout=_TIMEOUT_SEGUNDOS)
23
+ resposta.raise_for_status()
24
+ return resposta.content
25
+
26
+
27
+ def _buscar_url_anexo() -> str:
28
+ """Encontra a URL do arquivo ZIP do anexo mais recente do RMD."""
29
+ conteudo_pagina = _buscar_conteudo(URL_BASE)
30
+ arvore = html.fromstring(conteudo_pagina)
31
+ resultado = arvore.xpath("//a[contains(@href, 'publicacao-anexo')]/@href")
32
+ if not isinstance(resultado, list) or not resultado:
33
+ raise ValueError("Link do anexo ZIP não encontrado na página do RMD.")
34
+ return str(resultado[0])
35
+
36
+
37
+ def _extrair_excel(conteudo_zip: bytes) -> bytes:
38
+ """Extrai o arquivo Excel do ZIP."""
39
+ with zf.ZipFile(io.BytesIO(conteudo_zip), "r") as arquivo_zip:
40
+ nomes_excel = [
41
+ nome
42
+ for nome in arquivo_zip.namelist()
43
+ if nome.lower().endswith((".xlsx", ".xls"))
44
+ ]
45
+ if not nomes_excel:
46
+ raise ValueError("Nenhum arquivo Excel encontrado no ZIP do RMD.")
47
+ return arquivo_zip.read(nomes_excel[0])
48
+
49
+
50
+ @ttl_cache(ttl=_TTL_UM_DIA)
51
+ def baixar_planilha_rmd() -> bytes:
52
+ """Baixa e extrai a planilha Excel do anexo mais recente do RMD."""
53
+ url_anexo = _buscar_url_anexo()
54
+ conteudo_zip = _buscar_conteudo(url_anexo)
55
+ return _extrair_excel(conteudo_zip)
@@ -19,7 +19,7 @@ dependencies = [
19
19
  "fastexcel>=0.19.0",
20
20
  "tzdata>=2024.1; platform_system == 'Windows'",
21
21
  ]
22
- version = "0.49.4"
22
+ version = "0.49.5"
23
23
 
24
24
  [project.urls]
25
25
  Homepage = "https://github.com/crdcj/PYield"
@@ -1,262 +0,0 @@
1
- """Módulo para buscar dados do Relatório Mensal da Dívida (RMD) do Tesouro Nacional."""
2
-
3
- import datetime
4
- import io
5
- import logging
6
- import zipfile as zf
7
-
8
- import polars as pl
9
- import requests
10
- from lxml import html
11
-
12
- from pyield._internal.retry import retry_padrao
13
-
14
- registro = logging.getLogger(__name__)
15
-
16
- URL_BASE = (
17
- "https://www.tesourotransparente.gov.br/publicacoes/relatorio-mensal-da-divida-rmd"
18
- )
19
- _ABAS_DISPONIVEIS = ("1.3",)
20
- _TIMEOUT_SEGUNDOS = 60
21
-
22
- # Índices de linha (0-based) na planilha após leitura com fastexcel (sem cabeçalho)
23
- # O fastexcel compacta linhas totalmente vazias, resultando em 81 linhas ao invés das
24
- # 101 do Excel bruto. Os índices abaixo refletem o layout observado no arquivo atual.
25
- _LINHA_PERIODOS = 2 # Rótulos de período: "Nov/06", "Dez/06", ..., "2025"
26
- _LINHA_INICIO_DADOS = 3 # Primeira linha de dados: "I - EMISSÕES"
27
- _LINHA_FIM_DADOS = 67 # Exclusivo: notas de rodapé a partir desta linha
28
-
29
- # Tipos de título que viram colunas (em ordem)
30
- _TITULOS = ("LFT", "LTN", "NTN-B", "NTN-B1", "NTN-F", "NTN-C", "NTN-D", "Demais")
31
-
32
- # Mapeamento de rótulo de seção → nome limpo
33
- _SECOES = {"I - EMISSÕES": "Emissões", "II - RESGATES": "Resgates"}
34
-
35
- # Rótulos de subgrupo conhecidos e prefixo do Tesouro Direto
36
- _SUBGRUPOS = {"Vendas", "Trocas", "Vencimentos", "Compras"}
37
- _SUBGRUPO_TD = "Tesouro Direto"
38
-
39
- # Subgrupos sem detalhamento por tipo de título (valor direto na linha)
40
- # Tuple para ordem determinística; correspondência por prefixo (ignora notas de rodapé)
41
- _SUBGRUPOS_DIRETOS = (
42
- "Transferência de Carteira",
43
- "Emissão Direta com Financeiro",
44
- "Emissão Direta sem Financeiro",
45
- "Pagamento de Dividendos",
46
- "Cancelamentos",
47
- )
48
-
49
- # Prefixos que sinalizam fim da área de interesse (seções a ignorar)
50
- _PREFIXOS_IGNORAR = ("IMPACTO", "OPERAÇÕES", "III -", "RESGATE")
51
-
52
- _MESES_PT = {
53
- "Jan": 1,
54
- "Fev": 2,
55
- "Mar": 3,
56
- "Abr": 4,
57
- "Mai": 5,
58
- "Jun": 6,
59
- "Jul": 7,
60
- "Ago": 8,
61
- "Set": 9,
62
- "Out": 10,
63
- "Nov": 11,
64
- "Dez": 12,
65
- }
66
-
67
-
68
- def _parsear_periodo(periodo: str) -> datetime.date | None:
69
- """Converte string de período para datetime.date ou None para totais anuais."""
70
- try:
71
- mes_str, ano_str = periodo.split("/")
72
- except ValueError:
73
- return None # ex: "2025" (total anual) → descartado
74
- mes = _MESES_PT.get(mes_str)
75
- if mes is None:
76
- return None
77
- ano = 2000 + int(ano_str)
78
- return datetime.date(ano, mes, 1)
79
-
80
-
81
- @retry_padrao
82
- def _buscar_conteudo(url: str) -> bytes:
83
- """Busca o conteúdo de uma URL, seguindo redirects, com retry."""
84
- resposta = requests.get(url, timeout=_TIMEOUT_SEGUNDOS)
85
- resposta.raise_for_status()
86
- return resposta.content
87
-
88
-
89
- def _buscar_url_anexo() -> str:
90
- """Encontra a URL do arquivo ZIP do anexo mais recente do RMD.
91
-
92
- A URL base redireciona automaticamente para a página do mês atual.
93
- O lxml localiza o link do anexo ZIP nessa página.
94
- """
95
- conteudo_pagina = _buscar_conteudo(URL_BASE)
96
- arvore = html.fromstring(conteudo_pagina)
97
- resultado = arvore.xpath("//a[contains(@href, 'publicacao-anexo')]/@href")
98
- if not isinstance(resultado, list) or not resultado:
99
- raise ValueError("Link do anexo ZIP não encontrado na página do RMD.")
100
- return str(resultado[0])
101
-
102
-
103
- def _extrair_excel(conteudo_zip: bytes) -> bytes:
104
- """Extrai o arquivo Excel do ZIP."""
105
- with zf.ZipFile(io.BytesIO(conteudo_zip), "r") as arquivo_zip:
106
- nomes_excel = [
107
- n for n in arquivo_zip.namelist() if n.lower().endswith((".xlsx", ".xls"))
108
- ]
109
- if not nomes_excel:
110
- raise ValueError("Nenhum arquivo Excel encontrado no ZIP do RMD.")
111
- return arquivo_zip.read(nomes_excel[0])
112
-
113
-
114
- def _classificar_categorias(
115
- categorias: list[str],
116
- ) -> list[tuple[int, str, str, str | None]]:
117
- """Percorre rótulos de categoria e classifica linhas de dados.
118
-
119
- Máquina de estados que rastreia grupo (Emissões/Resgates) e subgrupo.
120
- Retorna lista de eventos (idx, grupo, subgrupo, titulo). Para subgrupos
121
- sem detalhamento por título, titulo é None.
122
-
123
- Args:
124
- categorias: Lista de rótulos de categoria lidos da coluna 0 do Excel.
125
-
126
- Returns:
127
- Lista de eventos (idx, grupo, subgrupo, titulo) detectados.
128
- """
129
- grupo = ""
130
- subgrupo = ""
131
- eventos: list[tuple[int, str, str, str | None]] = []
132
- for i, cat in enumerate(categorias):
133
- c = cat.strip()
134
- if c in _SECOES:
135
- grupo, subgrupo = _SECOES[c], ""
136
- elif any(c.startswith(p) for p in _PREFIXOS_IGNORAR):
137
- grupo = ""
138
- elif grupo:
139
- if c in _SUBGRUPOS:
140
- subgrupo = c
141
- elif c.startswith(_SUBGRUPO_TD):
142
- subgrupo = _SUBGRUPO_TD
143
- elif c in _TITULOS:
144
- eventos.append((i, grupo, subgrupo, c))
145
- else:
146
- prefixo = next((p for p in _SUBGRUPOS_DIRETOS if c.startswith(p)), None)
147
- if prefixo:
148
- eventos.append((i, grupo, prefixo, None))
149
- return eventos
150
-
151
-
152
- def _montar_registros(
153
- eventos: list[tuple[int, str, str, str | None]],
154
- datas_mensais: list[datetime.date],
155
- matriz: pl.DataFrame,
156
- ) -> pl.DataFrame:
157
- """Monta DataFrame longo com todos os registros de emissões e resgates."""
158
- linhas = [
159
- (data, grupo, subgrupo, titulo, val)
160
- for idx, grupo, subgrupo, titulo in eventos
161
- for data, val in zip(datas_mensais, matriz.row(idx))
162
- ]
163
- return pl.DataFrame(
164
- linhas,
165
- schema={
166
- "periodo": pl.Date,
167
- "grupo": pl.String,
168
- "subgrupo": pl.String,
169
- "titulo": pl.String,
170
- "valor": pl.Float64,
171
- },
172
- orient="row",
173
- )
174
-
175
-
176
- def _estruturar_dados(conteudo_excel: bytes) -> pl.DataFrame:
177
- """Lê a aba '1.3' do Excel e retorna DataFrame longo com emissões e resgates."""
178
- df_bruto = pl.read_excel(
179
- conteudo_excel,
180
- sheet_name="1.3",
181
- has_header=False,
182
- )
183
-
184
- periodos_raw = [str(p) for p in df_bruto.row(_LINHA_PERIODOS)[1:] if p is not None]
185
-
186
- datas_e_indices = [
187
- (i, d)
188
- for i, periodo in enumerate(periodos_raw)
189
- if (d := _parsear_periodo(periodo)) is not None
190
- ]
191
- indices_mensais = [i for i, _ in datas_e_indices]
192
- datas_mensais = [d for _, d in datas_e_indices]
193
-
194
- df_dados = df_bruto[_LINHA_INICIO_DADOS:_LINHA_FIM_DADOS]
195
- df_dados = df_dados.filter(df_dados[:, 0].is_not_null())
196
-
197
- eventos = _classificar_categorias([str(c) for c in df_dados[:, 0].to_list()])
198
-
199
- matriz = df_dados[:, 1:].cast(pl.Float64, strict=False)[:, indices_mensais]
200
-
201
- return (
202
- _montar_registros(eventos, datas_mensais, matriz)
203
- .with_columns(valor=pl.col("valor").mul(1_000_000).round(2))
204
- .filter(pl.col("valor").is_not_null() & (pl.col("valor") != 0))
205
- )
206
-
207
-
208
- def rmd(aba: str) -> pl.DataFrame:
209
- """Retorna dados do Relatório Mensal da Dívida (RMD) do Tesouro Nacional.
210
-
211
- Baixa e processa a planilha do RMD, extraindo dados de emissões e resgates
212
- de Títulos Públicos Federais da Dívida Pública Mobiliária Federal interna
213
- (DPMFi). A publicação mais recente é descoberta automaticamente via parse
214
- HTML da página oficial.
215
-
216
- Args:
217
- aba: Número da aba a processar (ex: ``"1.3"``). Abas implementadas: ``"1.3"``.
218
-
219
- Returns:
220
- DataFrame longo com dados de emissões e resgates por período, seção,
221
- subgrupo e tipo de título. Registros com valor nulo ou zero são excluídos.
222
- Em caso de erro, retorna DataFrame vazio e registra log da excessão.
223
-
224
- Output Columns:
225
- * periodo (Date): primeiro dia do mês de referência.
226
- * grupo (String): seção principal — ``"Emissões"`` ou ``"Resgates"``.
227
- * subgrupo (String): categoria dentro do grupo.
228
- * titulo (String): tipo de título (``"LFT"``, ``"LTN"``, ``"NTN-B"``,
229
- ``"NTN-B1"``, ``"NTN-F"``, ``"NTN-C"``, ``"NTN-D"``, ``"Demais"``,
230
- ou ``null`` para subgrupos sem detalhamento por título).
231
- * valor (Float64): valor em R$.
232
-
233
- Raises:
234
- ValueError: Se ``aba`` não estiver entre as abas implementadas.
235
-
236
- Notes:
237
- - A função sempre busca a publicação mais recente disponível.
238
- - Totais anuais são excluídos; podem ser recalculados via group_by.
239
- - Totais de referência para 2025:
240
- Emissões = R$ 1.840.946.621.648,18
241
- Resgates = R$ 1.395.109.062.272,45.
242
-
243
- Examples:
244
- >>> df = yd.tpf.rmd(aba="1.3") # doctest: +SKIP
245
- """
246
- if aba not in _ABAS_DISPONIVEIS:
247
- disponiveis = ", ".join(f'"{t}"' for t in sorted(_ABAS_DISPONIVEIS))
248
- raise ValueError(
249
- f"Aba '{aba}' não disponível. Abas implementadas: {disponiveis}."
250
- )
251
- try:
252
- url_anexo = _buscar_url_anexo()
253
- registro.debug(f"URL do anexo RMD: {url_anexo}")
254
- conteudo_zip = _buscar_conteudo(url_anexo)
255
- conteudo_excel = _extrair_excel(conteudo_zip)
256
- df = _estruturar_dados(conteudo_excel)
257
- except Exception as e:
258
- registro.exception(f"Erro ao coletar dados do RMD (aba {aba!r}): {e}")
259
- return pl.DataFrame()
260
-
261
- registro.info(f"Dados do RMD (aba {aba!r}) processados. Shape: {df.shape}.")
262
- return df
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes