├── src └── irpf_investidor │ ├── py.typed │ ├── __init__.py │ ├── prompt.py │ ├── formatting.py │ ├── responses.py │ ├── __main__.py │ ├── report_reader.py │ └── b3.py ├── .gitattributes ├── tests ├── __init__.py ├── conftest.py ├── test_prompt.py ├── test_formatting.py ├── test_responses.py ├── test_b3.py ├── test_main.py └── test_report_reader.py ├── .github ├── workflows │ ├── constraints.txt │ ├── labeler.yml │ ├── release-dev.yml │ ├── release.yml │ └── tests.yml ├── dependabot.yml ├── release-drafter.yml └── labels.yml ├── Template_InfoCEI.xls ├── docs ├── images │ └── winpath.png ├── requirements.txt ├── license.md ├── usage.md ├── index.md └── conf.py ├── codecov.yml ├── .readthedocs.yml ├── .gitignore ├── .cookiecutter.json ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── README.md └── noxfile.py /src/irpf_investidor/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /src/irpf_investidor/__init__.py: -------------------------------------------------------------------------------- 1 | """IRPF Investidor.""" 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for the irpf_investidor package.""" 2 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==25.3 2 | nox==2025.11.12 3 | virtualenv==20.35.4 4 | -------------------------------------------------------------------------------- /Template_InfoCEI.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staticdev/irpf-investidor/HEAD/Template_InfoCEI.xls -------------------------------------------------------------------------------- /docs/images/winpath.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staticdev/irpf-investidor/HEAD/docs/images/winpath.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo==2025.9.25 2 | myst_parser==4.0.1 3 | sphinx==9.0.4 4 | sphinx-click==6.2.0 5 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ```{literalinclude} ../LICENSE 4 | --- 5 | language: none 6 | --- 7 | ``` 8 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Uso 2 | 3 | ```{eval-rst} 4 | .. click:: irpf_investidor.__main__:main 5 | :prog: irpf-investidor 6 | :nested: full 7 | ``` 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | target: "100" 7 | patch: 8 | default: 9 | target: "100" 10 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-20.04 4 | tools: 5 | python: "3.13" 6 | sphinx: 7 | configuration: docs/conf.py 8 | formats: all 9 | python: 10 | install: 11 | - requirements: docs/requirements.txt 12 | - path: . 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | /.coverage 3 | /.coverage.* 4 | /.nox/ 5 | /.python-version 6 | /.pytype/ 7 | /dist/ 8 | /docs/_build/ 9 | /src/*.egg-info/ 10 | __pycache__/ 11 | *.xls 12 | 13 | # Pycharm 14 | .idea/ 15 | 16 | # Visual Studio Code 17 | .vscode/ 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Package-wide test fixtures.""" 2 | 3 | from _pytest.config import Config 4 | 5 | 6 | def pytest_configure(config: Config) -> None: 7 | """Pytest configuration hook.""" 8 | config.addinivalue_line("markers", "e2e: mark as end-to-end test.") 9 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | --- 3 | end-before: 4 | --- 5 | ``` 6 | 7 | [license]: license 8 | [command-line reference]: usage 9 | 10 | ```{toctree} 11 | --- 12 | hidden: 13 | maxdepth: 1 14 | --- 15 | 16 | usage 17 | License 18 | Changelog 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration.""" 2 | 3 | from datetime import datetime 4 | 5 | project = "IRPF Investidor" 6 | author = "staticdev" 7 | copyright = f"{datetime.now().year}, {author}" 8 | extensions = [ 9 | "sphinx.ext.autodoc", 10 | "sphinx.ext.napoleon", 11 | "sphinx_click", 12 | "myst_parser", 13 | ] 14 | autodoc_typehints = "description" 15 | html_theme = "furo" 16 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | labeler: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repository 14 | uses: actions/checkout@v6 15 | 16 | - name: Run Labeler 17 | uses: crazy-max/ghaction-github-labeler@v5 18 | with: 19 | skip-delete: true 20 | -------------------------------------------------------------------------------- /.cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "_template": "gh:cjolowicz/cookiecutter-hypermodern-python", 3 | "author": "staticdev", 4 | "development_status": "Development Status :: 5 - Production/Stable", 5 | "email": "staticdev-support@protonmail.com", 6 | "friendly_name": "IRPF Investidor", 7 | "github_user": "staticdev", 8 | "license": "MIT", 9 | "package_name": "irpf_investidor", 10 | "project_name": "irpf-investidor", 11 | "version": "1.0.0" 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: pip 12 | directory: "/docs" 13 | schedule: 14 | interval: daily 15 | - package-ecosystem: pip 16 | directory: "/" 17 | schedule: 18 | interval: daily 19 | versioning-strategy: lockfile-only 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | # Modifiers 6 | - id: trailing-whitespace 7 | # Static Checkers 8 | - id: check-added-large-files 9 | - id: check-toml 10 | - id: check-yaml 11 | - id: end-of-file-fixer 12 | 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | # Ruff version. 15 | rev: v0.11.10 16 | hooks: 17 | # Run the linter. 18 | - id: ruff 19 | # Run the formatter. 20 | - id: ruff-format 21 | -------------------------------------------------------------------------------- /src/irpf_investidor/prompt.py: -------------------------------------------------------------------------------- 1 | """Prompt module.""" 2 | 3 | import prompt_toolkit.shortcuts as shortcuts 4 | 5 | TITLE = "IRPF Investidor" 6 | 7 | 8 | def select_trades(trades: list[tuple[int, str]]) -> list[int]: 9 | """Checkbox selection of auction trades.""" 10 | text = ( 11 | "Informe as operações realizadas em horário de leilão para cálculo dos " 12 | "emolumentos.\nEssa informação é obtida através de sua corretora." 13 | ) 14 | while True: 15 | operations: list[int] = shortcuts.checkboxlist_dialog( 16 | title=TITLE, 17 | text=text, 18 | values=trades, 19 | ).run() 20 | if not operations or len(operations) == 0: 21 | confirmed = shortcuts.yes_no_dialog( 22 | title=TITLE, 23 | text="Nenhuma operação selecionada.\nIsso está correto?", 24 | ).run() 25 | if confirmed: 26 | return [] 27 | else: 28 | return operations 29 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | template: | 3 | ## Changes 4 | 5 | $CHANGES 6 | 7 | categories: 8 | - title: ":boom: Breaking Changes" 9 | label: "breaking" 10 | - title: ":rocket: Features" 11 | label: "enhancement" 12 | - title: ":fire: Removals and Deprecations" 13 | label: "removal" 14 | - title: ":beetle: Fixes" 15 | label: "bug" 16 | - title: ":raising_hand: Help wanted" 17 | label: "help wanted" 18 | - title: ":racehorse: Performance" 19 | label: "performance" 20 | - title: ":rotating_light: Testing" 21 | label: "testing" 22 | - title: ":construction_worker: Continuous Integration" 23 | label: "ci" 24 | - title: ":books: Documentation" 25 | label: "documentation" 26 | - title: ":hammer: Refactoring" 27 | label: "refactoring" 28 | - title: ":lipstick: Style" 29 | label: "style" 30 | - title: ":package: Dependencies" 31 | labels: 32 | - "dependencies" 33 | - "build" 34 | 35 | exclude-labels: 36 | - "skip-changelog" 37 | -------------------------------------------------------------------------------- /.github/workflows/release-dev.yml: -------------------------------------------------------------------------------- 1 | name: Release Dev 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out the repository 12 | uses: actions/checkout@v6 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v6 18 | with: 19 | python-version: "3.13" 20 | 21 | - name: Install UV 22 | uses: astral-sh/setup-uv@v7 23 | with: 24 | version: ">=0.5.24" 25 | 26 | - name: Install dependencies 27 | run: | 28 | uv sync --all-extras --frozen 29 | 30 | - name: Build package 31 | run: | 32 | uv build 33 | 34 | - name: Publish package on TestPyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | with: 37 | password: ${{ secrets.TEST_PYPI_TOKEN }} 38 | repository-url: https://test.pypi.org/legacy/ 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 by staticdev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repository 14 | uses: actions/checkout@v6 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set Tag env 19 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v6 23 | with: 24 | python-version: "3.13" 25 | 26 | - name: Install UV 27 | uses: astral-sh/setup-uv@v7 28 | with: 29 | version: ">=0.5.24" 30 | 31 | - name: Install dependencies 32 | run: | 33 | uv sync --all-extras --frozen 34 | 35 | - name: Build package 36 | run: | 37 | uv build 38 | 39 | - name: Publish package on PyPI 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | with: 42 | user: __token__ 43 | password: ${{ secrets.PYPI_TOKEN }} 44 | 45 | - name: Publish the release notes 46 | uses: release-drafter/release-drafter@v6 47 | with: 48 | tag: ${{ env.RELEASE_VERSION }} 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /src/irpf_investidor/formatting.py: -------------------------------------------------------------------------------- 1 | """Formatting module.""" 2 | 3 | import locale 4 | import math 5 | from collections.abc import Callable 6 | 7 | import irpf_investidor.responses as res 8 | 9 | 10 | def set_pt_br_locale() -> res.ResponseFailure | res.ResponseSuccess: 11 | """Set pt_BR locale.""" 12 | # Get available locale from shell `locale -a` 13 | supported_locales = ["pt_BR.utf8", "pt_BR.UTF-8"] 14 | for loc in supported_locales: 15 | try: 16 | locale.setlocale(locale.LC_ALL, loc) 17 | return res.ResponseSuccess() 18 | except locale.Error: 19 | pass 20 | return res.ResponseFailure( 21 | res.ResponseTypes.SYSTEM_ERROR, 22 | "locale pt_BR não encontrado, confira a documentação para mais informações.", 23 | ) 24 | 25 | 26 | def get_currency_format() -> Callable[..., str]: 27 | """Return currency function.""" 28 | return locale.currency 29 | 30 | 31 | def fmt_money(amount: float, ndigits: int = 2) -> str: 32 | """Return padded and rounded value.""" 33 | if math.isnan(amount): 34 | return "N/A" 35 | rounded = round(amount, ndigits) 36 | result = str(rounded).replace(".", ",") 37 | rounded_digits = result.split(",")[1] 38 | missing_digits = ndigits - len(rounded_digits) 39 | padded_result = result + "0" * missing_digits 40 | return padded_result 41 | -------------------------------------------------------------------------------- /src/irpf_investidor/responses.py: -------------------------------------------------------------------------------- 1 | """Response objects.""" 2 | 3 | from typing import Any 4 | 5 | 6 | class ResponseTypes: 7 | """Response types class.""" 8 | 9 | PARAMETERS_ERROR = "ParametersError" 10 | RESOURCE_ERROR = "ResourceError" 11 | SYSTEM_ERROR = "SystemError" 12 | SUCCESS = "Success" 13 | 14 | 15 | class ResponseFailure: 16 | """Response failure class.""" 17 | 18 | def __init__(self, type_: str, message: str | Exception | None) -> None: 19 | """Construct.""" 20 | self.type = type_ 21 | self.message = self._format_message(message) 22 | 23 | def _format_message(self, msg: str | Exception | None) -> str | None: 24 | """Format message when it is an exception.""" 25 | if isinstance(msg, Exception): 26 | return f"{msg.__class__.__name__}: {msg}" 27 | return msg 28 | 29 | @property 30 | def value(self) -> dict[str, str | None]: 31 | """Value property.""" 32 | return {"type": self.type, "message": self.message} 33 | 34 | def __bool__(self) -> bool: 35 | """Bool return for success.""" 36 | return False 37 | 38 | 39 | class ResponseSuccess: 40 | """Response success class.""" 41 | 42 | def __init__(self, value: Any = None) -> None: 43 | """Construct.""" 44 | self.type = ResponseTypes.SUCCESS 45 | self.value = value 46 | 47 | def __bool__(self) -> bool: 48 | """Bool return for success.""" 49 | return True 50 | -------------------------------------------------------------------------------- /tests/test_prompt.py: -------------------------------------------------------------------------------- 1 | """Test cases for prompt module.""" 2 | 3 | import pytest 4 | from pytest_mock import MockerFixture 5 | 6 | import irpf_investidor.prompt as prompt 7 | 8 | TRADES = [(0, "trade 1"), (0, "trade 2")] 9 | 10 | 11 | @pytest.fixture 12 | def mock_checkboxlist_dialog(mocker: MockerFixture) -> MockerFixture: 13 | """Fixture for mocking shortcuts.checkboxlist_dialog.""" 14 | return mocker.patch("prompt_toolkit.shortcuts.checkboxlist_dialog") 15 | 16 | 17 | @pytest.fixture 18 | def mock_yes_no_dialog(mocker: MockerFixture) -> MockerFixture: 19 | """Fixture for mocking shortcuts.yes_no_dialog.""" 20 | return mocker.patch("prompt_toolkit.shortcuts.yes_no_dialog") 21 | 22 | 23 | def test_select_trades_empty( 24 | mock_checkboxlist_dialog: MockerFixture, mock_yes_no_dialog: MockerFixture 25 | ) -> None: 26 | """It returns empty list.""" 27 | mock_checkboxlist_dialog.return_value.run.side_effect = [[], []] 28 | mock_yes_no_dialog.return_value.run.side_effect = [False, True] 29 | 30 | result = prompt.select_trades(TRADES) 31 | 32 | assert mock_checkboxlist_dialog.call_count == 2 33 | assert mock_yes_no_dialog.call_count == 2 34 | assert result == [] 35 | 36 | 37 | def test_select_trades_some_selected( 38 | mock_checkboxlist_dialog: MockerFixture, 39 | ) -> None: 40 | """It returns list with id 1.""" 41 | mock_checkboxlist_dialog.return_value.run.return_value = [1] 42 | 43 | result = prompt.select_trades(TRADES) 44 | 45 | assert result == [1] 46 | -------------------------------------------------------------------------------- /src/irpf_investidor/__main__.py: -------------------------------------------------------------------------------- 1 | """Command-line interface.""" 2 | 3 | import click 4 | 5 | import irpf_investidor.formatting 6 | import irpf_investidor.prompt as prompt 7 | import irpf_investidor.report_reader 8 | 9 | 10 | @click.version_option() 11 | @click.command() 12 | def main() -> None: 13 | """Sequecence of operations for trades.""" 14 | response = irpf_investidor.formatting.set_pt_br_locale() 15 | if not response: 16 | click.secho( 17 | f"Erro: {response.value['message']}", 18 | fg="red", 19 | err=True, 20 | ) 21 | # Raises SystemExit 22 | raise click.ClickException("") 23 | filename = irpf_investidor.report_reader.get_xls_filename() 24 | click.secho(f"Nome do arquivo: {filename}", fg="blue") 25 | 26 | ref_year, institution = irpf_investidor.report_reader.validate_header( 27 | filename 28 | ) 29 | source_df = irpf_investidor.report_reader.read_xls(filename) 30 | source_df = irpf_investidor.report_reader.clean_table_cols(source_df) 31 | source_df = irpf_investidor.report_reader.group_trades(source_df) 32 | trades = irpf_investidor.report_reader.get_trades(source_df) 33 | auction_trades = prompt.select_trades(trades) 34 | tax_df = irpf_investidor.report_reader.calculate_taxes( 35 | source_df, auction_trades 36 | ) 37 | irpf_investidor.report_reader.output_taxes(tax_df) 38 | result_df = irpf_investidor.report_reader.goods_and_rights(tax_df) 39 | irpf_investidor.report_reader.output_goods_and_rights( 40 | result_df, ref_year, institution 41 | ) 42 | 43 | 44 | if __name__ == "__main__": 45 | main() 46 | -------------------------------------------------------------------------------- /tests/test_formatting.py: -------------------------------------------------------------------------------- 1 | """Test cases for formatting module.""" 2 | 3 | import locale 4 | 5 | from pytest_mock import MockerFixture 6 | 7 | from irpf_investidor import formatting 8 | 9 | 10 | def test_set_pt_br_locale_success(mocker: MockerFixture) -> None: 11 | """Return success.""" 12 | mocker.patch("locale.setlocale") 13 | assert bool(formatting.set_pt_br_locale()) is True 14 | 15 | 16 | def test_set_pt_br_locale_error(mocker: MockerFixture) -> None: 17 | """Return pt_BR locale.""" 18 | mocker.patch("locale.setlocale", side_effect=locale.Error()) 19 | response = formatting.set_pt_br_locale() 20 | 21 | assert bool(response) is False 22 | assert ( 23 | response.value["message"] 24 | == "locale pt_BR não encontrado, confira a documentação para mais informações." 25 | ) 26 | 27 | 28 | def test_get_currency_format(mocker: MockerFixture) -> None: 29 | """Give no error.""" 30 | formatting.get_currency_format() 31 | 32 | 33 | def test_fmt_money_no_padding() -> None: 34 | """Return rounded value.""" 35 | num = 1581.12357 36 | digits = 3 37 | expected = "1581,124" 38 | 39 | assert formatting.fmt_money(num, digits) == expected 40 | 41 | 42 | def test_fmt_money_with_padding() -> None: 43 | """Return rounded and padded value.""" 44 | num = 1581.1 45 | digits = 3 46 | expected = "1581,100" 47 | 48 | assert formatting.fmt_money(num, digits) == expected 49 | 50 | 51 | def test_fmt_money_is_nan() -> None: 52 | """Return N/A.""" 53 | num = float("nan") 54 | digits = 2 55 | expected = "N/A" 56 | 57 | assert formatting.fmt_money(num, digits) == expected 58 | -------------------------------------------------------------------------------- /tests/test_responses.py: -------------------------------------------------------------------------------- 1 | """Test cases for response objects.""" 2 | 3 | import irpf_investidor.responses as res 4 | 5 | SUCCESS_VALUE = {"key": ["value1", "value2"]} 6 | GENERIC_RESPONSE_TYPE = "Response" 7 | GENERIC_RESPONSE_MESSAGE = "This is a response" 8 | 9 | 10 | def test_response_success_is_true() -> None: 11 | """It has bool value of True.""" 12 | response = res.ResponseSuccess(SUCCESS_VALUE) 13 | 14 | assert bool(response) is True 15 | 16 | 17 | def test_response_success_has_type_and_value() -> None: 18 | """It has success type and value.""" 19 | response = res.ResponseSuccess(SUCCESS_VALUE) 20 | 21 | assert response.type == res.ResponseTypes.SUCCESS 22 | assert response.value == SUCCESS_VALUE 23 | 24 | 25 | def test_response_failure_is_false() -> None: 26 | """It has bool value of False.""" 27 | response = res.ResponseFailure( 28 | GENERIC_RESPONSE_TYPE, GENERIC_RESPONSE_MESSAGE 29 | ) 30 | 31 | assert bool(response) is False 32 | 33 | 34 | def test_response_failure_has_type_and_message() -> None: 35 | """It has failure type and message.""" 36 | response = res.ResponseFailure( 37 | GENERIC_RESPONSE_TYPE, GENERIC_RESPONSE_MESSAGE 38 | ) 39 | 40 | assert response.type == GENERIC_RESPONSE_TYPE 41 | assert response.message == GENERIC_RESPONSE_MESSAGE 42 | assert response.value == { 43 | "type": GENERIC_RESPONSE_TYPE, 44 | "message": GENERIC_RESPONSE_MESSAGE, 45 | } 46 | 47 | 48 | def test_response_failure_initialisation_with_exception() -> None: 49 | """It builds a ResponseFailure from exception.""" 50 | response = res.ResponseFailure( 51 | GENERIC_RESPONSE_TYPE, Exception("Just an error message") 52 | ) 53 | 54 | assert bool(response) is False 55 | assert response.type == GENERIC_RESPONSE_TYPE 56 | assert response.message == "Exception: Just an error message" 57 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: "bfd4f2" 10 | - name: bug 11 | description: Something isn't working 12 | color: "d73a4a" 13 | - name: build 14 | description: Build System and Dependencies 15 | color: "bfdadc" 16 | - name: ci 17 | description: Continuous Integration 18 | color: "4a97d6" 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: "0366d6" 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: "0075ca" 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: "cfd3d7" 28 | - name: enhancement 29 | description: New feature or request 30 | color: "a2eeef" 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: "7057ff" 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: "008672" 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: "e4e669" 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: "2b67c6" 49 | - name: question 50 | description: Further information is requested 51 | color: "d876e3" 52 | - name: refactoring 53 | description: Refactoring 54 | color: "ef67c4" 55 | - name: removal 56 | description: Removals and deprecations 57 | color: "9ae7ea" 58 | - name: style 59 | description: Style 60 | color: "c120e5" 61 | - name: testing 62 | description: Testing 63 | color: "b1fc6f" 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: "ffffff" 67 | - name: skip-changelog 68 | description: This will not be added to release notes 69 | color: "dddddd" 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "irpf-investidor" 3 | dynamic = ["version"] 4 | description = "IRPF Investidor" 5 | authors = [ 6 | { name = "staticdev", email = "staticdev-support@proton.me"} 7 | ] 8 | license = "MIT" 9 | readme = "README.md" 10 | homepage = "https://github.com/staticdev/irpf-investidor" 11 | repository = "https://github.com/staticdev/irpf-investidor" 12 | documentation = "https://irpf-investidor.readthedocs.io" 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | ] 16 | requires-python = ">=3.11" 17 | dependencies = [ 18 | "click>=8.1.7", 19 | "pandas>=2.2.3", 20 | "prompt-toolkit>=3.0.43", 21 | "xlrd>=2.0.1", 22 | ] 23 | 24 | [project.scripts] 25 | irpf-investidor = "irpf_investidor.__main__:main" 26 | 27 | [tool.hatch.version] 28 | source = "vcs" 29 | 30 | [tool.hatch.version.raw-options] 31 | local_scheme = "no-local-version" 32 | 33 | [project.urls] 34 | Changelog = "https://github.com/staticdev/irpf-investidor/releases" 35 | 36 | [dependency-groups] 37 | dev = [ 38 | "coverage[toml]>=7.2.7", 39 | "furo>=2023.9.10", 40 | "mypy>=1.7.1", 41 | "hatch>=1.14.0", 42 | "nox>=2024.10.9", 43 | "pre-commit>=3.5.0", 44 | "pre-commit-hooks>=4.5.0", 45 | "pyfakefs>=5.8.0", 46 | "Pygments>=2.11.2", 47 | "pytest>=7.4.2", 48 | "pytest-mock>=3.11.1", 49 | "sphinx>=7.2.6", 50 | "sphinx-autobuild>=2021.3.14", 51 | "sphinx-click>=3.0.2", 52 | "typeguard>=4.1.5", 53 | "xdoctest[colors]>=0.15.10", 54 | ] 55 | 56 | [tool.coverage.paths] 57 | source = ["src", "*/site-packages"] 58 | tests = ["tests", "*/tests"] 59 | 60 | [tool.coverage.run] 61 | branch = true 62 | source = ["irpf_investidor", "tests"] 63 | 64 | [tool.coverage.report] 65 | show_missing = true 66 | fail_under = 99 67 | 68 | [tool.ruff] 69 | line-length = 80 70 | 71 | [tool.ruff.lint] 72 | select = ["B", "B9", "C", "D", "E", "F", "N", "W"] 73 | ignore = ["E203", "E501", "B905"] 74 | per-file-ignores = { "times.py" = ["N806"] } 75 | 76 | [tool.ruff.lint.mccabe] 77 | max-complexity = 10 78 | 79 | [tool.ruff.lint.isort] 80 | force-single-line = true 81 | lines-after-imports = 2 82 | 83 | [tool.mypy] 84 | strict = true 85 | warn_unreachable = true 86 | pretty = true 87 | show_column_numbers = true 88 | show_error_codes = true 89 | show_error_context = true 90 | 91 | [[tool.mypy.overrides]] 92 | module = ["pandas", "pytest_mock", "xlrd"] 93 | ignore_missing_imports = true 94 | 95 | [build-system] 96 | requires = ["hatchling", "hatch-vcs"] 97 | build-backend = "hatchling.build" 98 | -------------------------------------------------------------------------------- /tests/test_b3.py: -------------------------------------------------------------------------------- 1 | """Test cases for the B3 module.""" 2 | 3 | import datetime 4 | 5 | import pytest 6 | 7 | from irpf_investidor import b3 8 | 9 | 10 | def test_get_asset_info_etf() -> None: 11 | """Return ETF.""" 12 | asset_info = b3.get_asset_info("BOVA11") 13 | assert asset_info.category == "ETF" 14 | assert asset_info.cnpj == "10.406.511/0001-61" 15 | 16 | 17 | def test_get_asset_info_fii() -> None: 18 | """Return FII.""" 19 | asset_info = b3.get_asset_info("DOVL11B") 20 | assert asset_info.category == "FII" 21 | assert asset_info.cnpj == "10.522.648/0001-81" 22 | 23 | 24 | def test_get_asset_info_stock() -> None: 25 | """Return STOCKS.""" 26 | asset_info = b3.get_asset_info("PETR4") 27 | assert asset_info.category == "STOCKS" 28 | assert asset_info.cnpj == "33.000.167/0001-01" 29 | 30 | 31 | def test_get_asset_info_stock_fractionary() -> None: 32 | """Return STOCKS.""" 33 | asset_info = b3.get_asset_info("PETR4F") 34 | assert asset_info.category == "STOCKS" 35 | assert asset_info.cnpj == "33.000.167/0001-01" 36 | 37 | 38 | def test_get_asset_info_not_found() -> None: 39 | """Return NOT_FOUND.""" 40 | asset_info = b3.get_asset_info("OMG3M3") 41 | assert asset_info.category == "NOT_FOUND" 42 | 43 | 44 | def test_get_liquidacao_rates_error() -> None: 45 | """Raise `SystemExit` when date is not found.""" 46 | series = [datetime.datetime(1930, 2, 20)] 47 | with pytest.raises(SystemExit): 48 | assert b3.get_liquidacao_rates(series) 49 | 50 | 51 | def test_get_liquidacao_rates_success() -> None: 52 | """Return date rates.""" 53 | series = [ 54 | datetime.datetime(2019, 2, 20), 55 | datetime.datetime(2021, 12, 31), 56 | ] 57 | expected = [0.000275, 0.00025] 58 | result = b3.get_liquidacao_rates(series) 59 | assert result == expected 60 | 61 | 62 | def test_get_emolumentos_rates_error() -> None: 63 | """Raise `SystemExit` when date is not found.""" 64 | series = [datetime.datetime(1930, 2, 20)] 65 | with pytest.raises(SystemExit): 66 | assert b3.get_emolumentos_rates(series, []) 67 | 68 | 69 | def test_get_emolumentos_rates_sucess_no_auction() -> None: 70 | """Return date rates.""" 71 | series = [ 72 | datetime.datetime(2019, 2, 20), 73 | datetime.datetime(2019, 3, 6), 74 | datetime.datetime(2019, 5, 14), 75 | datetime.datetime(2019, 12, 31), 76 | ] 77 | expected = [0.00004032, 0.00004157, 0.00004408, 0.00003802] 78 | result = b3.get_emolumentos_rates(series, []) 79 | assert result == expected 80 | 81 | 82 | def test_get_emolumentos_rates_sucess_with_auction() -> None: 83 | """Return date rates and auction rates.""" 84 | series = [ 85 | datetime.datetime(2019, 2, 20), 86 | datetime.datetime(2019, 3, 6), 87 | datetime.datetime(2019, 5, 14), 88 | datetime.datetime(2019, 12, 31), 89 | ] 90 | expected = [0.00004032, 0.00007, 0.00007, 0.00003802] 91 | result = b3.get_emolumentos_rates(series, [1, 2]) 92 | assert result == expected 93 | 94 | 95 | def test_get_cnpj_institution_found() -> None: 96 | """Return a known CNPJ.""" 97 | institution = "90 - EASYNVEST - TITULO CV S.A." 98 | result = b3.get_cnpj_institution(institution) 99 | assert result == "62.169.875/0001-79" 100 | 101 | 102 | def test_get_cnpj_institution_not_found() -> None: 103 | """Return a known CNPJ.""" 104 | institution = "9999 - UNKNOWN S.A." 105 | result = b3.get_cnpj_institution(institution) 106 | assert result == "não encontrado" 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IRPF Investidor 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/irpf-investidor.svg)][pypi status] 4 | [![Status](https://img.shields.io/pypi/status/irpf-investidor.svg)][pypi status] 5 | [![Python Version](https://img.shields.io/pypi/pyversions/irpf-investidor)][pypi status] 6 | [![License](https://img.shields.io/pypi/l/irpf-investidor)][license] 7 | 8 | [![Read the documentation at https://irpf-investidor.readthedocs.io/](https://img.shields.io/readthedocs/irpf-investidor/latest.svg?label=Read%20the%20Docs)][read the docs] 9 | [![Tests](https://github.com/staticdev/irpf-investidor/workflows/Tests/badge.svg)][tests] 10 | [![Codecov](https://codecov.io/gh/staticdev/irpf-investidor/branch/main/graph/badge.svg)][codecov] 11 | 12 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit] 13 | [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black] 14 | 15 | [pypi status]: https://pypi.org/project/irpf-investidor/ 16 | [read the docs]: https://irpf-investidor.readthedocs.io/ 17 | [tests]: https://github.com/staticdev/irpf-investidor/actions?workflow=Tests 18 | [codecov]: https://app.codecov.io/gh/staticdev/irpf-investidor 19 | [pre-commit]: https://github.com/pre-commit/pre-commit 20 | [black]: https://github.com/psf/black 21 | 22 | Programa auxiliar para calcular custos de ações, ETFs e FIIs. Este programa foi feito para calcular emolumentos, taxa de liquidação e custo total para a declaração de Bens e Direitos do Imposto de Renda Pessoa Física. 23 | 24 | **Essa aplicação foi testada e configurada para calcular tarifas referentes aos anos de 2019 a 2022 (IRPF 2020/2023) e não faz cálculos para compra e venda no mesmo dia (Day Trade), contratos futuros e Índice Brasil 50.** 25 | 26 | ## Requisitos 27 | 28 | 1. Python 29 | 30 | Instale na sua máquina o Python 3.11.0 ou superior (versão 3.11 recomendada) para o seu sistema operacional em [python.org]. 31 | 32 | 1. Suporte a língua Português (Brasil) no seu sistema operacional. 33 | 34 | Pode ser instalado no Linux (Debian/Ubuntu) pelo comando: 35 | 36 | ```sh 37 | $ apt-get install language-pack-pt-base 38 | ``` 39 | 40 | ## Instalação 41 | 42 | You can install _IRPF Investidor_ via [pip] from [PyPI]: 43 | 44 | ```sh 45 | $ pip install irpf-investidor 46 | ``` 47 | 48 | ## Uso 49 | 50 | 1. Entre na [Área do Investidor] da B3, faça login e entre no menu Extratos e Informativos → Negociação de Ativos → Escolha uma corretora e as datas 1 de Janeiro e 31 de Dezembro do ano em que deseja declarar. Em seguida clique no botão “Exportar para EXCEL”. Ele irá baixar o arquivo “InfoCEI.xls”. 51 | 52 | **Ainda não é possível rodar o programa usando os novos arquivos XLSX, gerar no formato antigo.** Baixe e altere o [Template_InfoCEI.xls](Template_InfoCEI.xls). 53 | 54 | Você pode combinar lançamentos de anos diferentes em um mesmo documento colando as linhas de um relatório em outro, mas mantenha a ordem cronológica. 55 | 56 | 2. Execute o programa através do comando: 57 | 58 | ```sh 59 | $ irpf-investidor 60 | ``` 61 | 62 | O programa irá procurar o arquivo "InfoCEI.xls" na pasta atual (digite `pwd` no terminal para sabe qual é) ou na pasta downloads e exibirá na tela os resultados. 63 | 64 | Ao executar, o programa pede para selecionar operações realizadas em leilão. Essa informação não pode ser obtida nos relatórios da `Área do Investidor` da B3 e precisam ser buscadas diretamente com a sua corretora de valores. Isso afeta o cálculo dos emolumentos e do custo médio. 65 | 66 | ## Aviso legal (disclaimer) 67 | 68 | Esta é uma ferramenta com código aberto e gratuita, com licença MIT. Você pode alterar o código e distribuir, usar comercialmente como bem entender. Contribuições são muito bem vindas. Toda a responsabilidade de conferência dos valores e do envio dessas informações à Receita Federal é do usuário. Os desenvolvedores e colaboradores desse programa não se responsabilizam por quaisquer incorreções nos cálculos e lançamentos gerados. 69 | 70 | ## Créditos 71 | 72 | Esse projeto foi gerado pelo template [@cjolowicz]'s [Hypermodern Python Cookiecutter]. 73 | 74 | 75 | 76 | [license]: https://github.com/staticdev/irpf-investidor/blob/main/LICENSE 77 | [@cjolowicz]: https://github.com/cjolowicz 78 | [hypermodern python cookiecutter]: https://github.com/cjolowicz/cookiecutter-hypermodern-python 79 | [pip]: https://pip.pypa.io/ 80 | [pypi]: https://pypi.org/ 81 | [python.org]: https://www.python.org/downloads/ 82 | [uso]: https://irpf-investidor.readthedocs.io/en/latest/usage.html 83 | [área do investidor]: https://www.investidor.b3.com.br/ 84 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | name: ${{ matrix.session }} ${{ matrix.python }} / ${{ matrix.os }} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - { python: "3.13", os: "ubuntu-latest", session: "pre-commit" } 16 | - { python: "3.13", os: "ubuntu-latest", session: "mypy" } 17 | - { python: "3.12", os: "ubuntu-latest", session: "mypy" } 18 | - { python: "3.11", os: "ubuntu-latest", session: "mypy" } 19 | - { python: "3.13", os: "ubuntu-latest", session: "tests" } 20 | - { python: "3.12", os: "ubuntu-latest", session: "tests" } 21 | - { python: "3.11", os: "ubuntu-latest", session: "tests" } 22 | - { python: "3.13", os: "macos-latest", session: "tests" } 23 | - { python: "3.13", os: "ubuntu-latest", session: "typeguard" } 24 | - { python: "3.13", os: "ubuntu-latest", session: "xdoctest" } 25 | - { python: "3.13", os: "ubuntu-latest", session: "docs-build" } 26 | 27 | env: 28 | NOXSESSION: ${{ matrix.session }} 29 | FORCE_COLOR: "1" 30 | PRE_COMMIT_COLOR: "always" 31 | 32 | steps: 33 | - name: Check out the repository 34 | uses: actions/checkout@v6 35 | 36 | - name: Set up Python ${{ matrix.python }} 37 | uses: actions/setup-python@v6 38 | with: 39 | python-version: ${{ matrix.python }} 40 | 41 | - name: Upgrade pip 42 | run: | 43 | pip install --constraint=.github/workflows/constraints.txt pip 44 | pip --version 45 | 46 | - name: Upgrade pip in virtual environments 47 | shell: python 48 | run: | 49 | import os 50 | import pip 51 | 52 | with open(os.environ["GITHUB_ENV"], mode="a") as io: 53 | print(f"VIRTUALENV_PIP={pip.__version__}", file=io) 54 | 55 | - name: Install UV 56 | uses: astral-sh/setup-uv@v7 57 | 58 | - name: Install Nox 59 | run: | 60 | pipx install --pip-args=--constraint=.github/workflows/constraints.txt nox 61 | nox --version 62 | 63 | - name: Compute pre-commit cache key 64 | if: matrix.session == 'pre-commit' 65 | id: pre-commit-cache 66 | shell: python 67 | run: | 68 | import hashlib 69 | import sys 70 | 71 | python = "py{}.{}".format(*sys.version_info[:2]) 72 | payload = sys.version.encode() + sys.executable.encode() 73 | digest = hashlib.sha256(payload).hexdigest() 74 | result = "${{ runner.os }}-{}-{}-pre-commit".format(python, digest[:8]) 75 | 76 | print("::set-output name=result::{}".format(result)) 77 | 78 | - name: Restore pre-commit cache 79 | uses: actions/cache@v5 80 | if: matrix.session == 'pre-commit' 81 | with: 82 | path: ~/.cache/pre-commit 83 | key: ${{ steps.pre-commit-cache.outputs.result }}-${{ hashFiles('.pre-commit-config.yaml') }} 84 | restore-keys: | 85 | ${{ steps.pre-commit-cache.outputs.result }}- 86 | 87 | - name: Run Nox 88 | run: | 89 | nox --force-color --python=${{ matrix.python }} 90 | 91 | - name: Upload coverage data 92 | if: always() && matrix.session == 'tests' && matrix.os == 'ubuntu-latest' 93 | uses: "actions/upload-artifact@v6" 94 | with: 95 | name: coverage-data-${{ matrix.python }}-${{ matrix.os }} 96 | path: .coverage.* 97 | include-hidden-files: true 98 | 99 | - name: Upload documentation 100 | if: matrix.session == 'docs-build' 101 | uses: actions/upload-artifact@v6 102 | with: 103 | name: docs 104 | path: docs/_build 105 | 106 | coverage: 107 | runs-on: ubuntu-latest 108 | needs: tests 109 | steps: 110 | - name: Check out the repository 111 | uses: actions/checkout@v6 112 | 113 | - name: Set up Python 114 | uses: actions/setup-python@v6 115 | with: 116 | python-version: "3.13" 117 | 118 | - name: Upgrade pip 119 | run: | 120 | pip install --constraint=.github/workflows/constraints.txt pip 121 | pip --version 122 | 123 | - name: Install UV 124 | uses: astral-sh/setup-uv@v7 125 | 126 | - name: Install dependencies 127 | run: | 128 | uv sync --all-extras --frozen 129 | 130 | - name: Install Nox 131 | run: | 132 | pipx install --pip-args=--constraint=.github/workflows/constraints.txt nox 133 | nox --version 134 | 135 | - name: Download coverage data 136 | uses: actions/download-artifact@v7 137 | 138 | - name: Combine coverage data and display human readable report 139 | run: | 140 | shopt -s dotglob 141 | mv coverage-data-3.13-ubuntu-latest/* . 142 | mv coverage-data-3.12-ubuntu-latest/* . 143 | mv coverage-data-3.11-ubuntu-latest/* . 144 | nox --force-color --session=coverage 145 | 146 | - name: Create coverage report 147 | run: | 148 | nox --force-color --session=coverage -- xml 149 | 150 | - name: Upload coverage report 151 | uses: codecov/codecov-action@v5 152 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """Test cases for the __main__ module.""" 2 | 3 | import click.testing 4 | import pytest 5 | from pytest_mock import MockerFixture 6 | 7 | from irpf_investidor import __main__ 8 | from irpf_investidor import responses as res 9 | 10 | 11 | @pytest.fixture 12 | def runner() -> click.testing.CliRunner: 13 | """Fixture for invoking command-line interfaces.""" 14 | return click.testing.CliRunner() 15 | 16 | 17 | @pytest.fixture 18 | def mock_cei_get_xls_filename(mocker: MockerFixture) -> MockerFixture: 19 | """Fixture for mocking report_reader.get_xls_filename.""" 20 | return mocker.patch("irpf_investidor.report_reader.get_xls_filename") 21 | 22 | 23 | @pytest.fixture 24 | def mock_cei_validate_header(mocker: MockerFixture) -> MockerFixture: 25 | """Fixture for mocking report_reader.validate.""" 26 | mock = mocker.patch("irpf_investidor.report_reader.validate_header") 27 | mock.return_value = 2019, "ABC" 28 | return mock 29 | 30 | 31 | @pytest.fixture 32 | def mock_cei_read_xls(mocker: MockerFixture) -> MockerFixture: 33 | """Fixture for mocking report_reader.read_xls.""" 34 | return mocker.patch("irpf_investidor.report_reader.read_xls") 35 | 36 | 37 | @pytest.fixture 38 | def mock_cei_clean_table_cols(mocker: MockerFixture) -> MockerFixture: 39 | """Fixture for mocking report_reader.clean_table_cols.""" 40 | return mocker.patch("irpf_investidor.report_reader.clean_table_cols") 41 | 42 | 43 | @pytest.fixture 44 | def mock_cei_group_trades(mocker: MockerFixture) -> MockerFixture: 45 | """Fixture for mocking report_reader.group_trades.""" 46 | return mocker.patch("irpf_investidor.report_reader.group_trades") 47 | 48 | 49 | @pytest.fixture 50 | def mock_select_trades(mocker: MockerFixture) -> MockerFixture: 51 | """Fixture for mocking prompt.select_trades.""" 52 | return mocker.patch("irpf_investidor.prompt.select_trades") 53 | 54 | 55 | @pytest.fixture 56 | def mock_cei_get_trades(mocker: MockerFixture) -> MockerFixture: 57 | """Fixture for mocking report_reader.get_trades.""" 58 | return mocker.patch("irpf_investidor.report_reader.get_trades") 59 | 60 | 61 | @pytest.fixture 62 | def mock_cei_calculate_taxes(mocker: MockerFixture) -> MockerFixture: 63 | """Fixture for mocking report_reader.calculate_taxes.""" 64 | return mocker.patch("irpf_investidor.report_reader.calculate_taxes") 65 | 66 | 67 | @pytest.fixture 68 | def mock_cei_output_taxes(mocker: MockerFixture) -> MockerFixture: 69 | """Fixture for mocking report_reader.output_taxes.""" 70 | return mocker.patch("irpf_investidor.report_reader.output_taxes") 71 | 72 | 73 | @pytest.fixture 74 | def mock_cei_goods_and_rights(mocker: MockerFixture) -> MockerFixture: 75 | """Fixture for mocking report_reader.goods_and_rights.""" 76 | return mocker.patch("irpf_investidor.report_reader.goods_and_rights") 77 | 78 | 79 | @pytest.fixture 80 | def mock_cei_output_goods_and_rights(mocker: MockerFixture) -> MockerFixture: 81 | """Fixture for mocking report_reader.output_goods_and_rights.""" 82 | return mocker.patch("irpf_investidor.report_reader.output_goods_and_rights") 83 | 84 | 85 | def test_main_succeeds( 86 | mocker: MockerFixture, 87 | runner: click.testing.CliRunner, 88 | mock_cei_get_xls_filename: MockerFixture, 89 | mock_cei_validate_header: MockerFixture, 90 | mock_cei_read_xls: MockerFixture, 91 | mock_cei_clean_table_cols: MockerFixture, 92 | mock_cei_group_trades: MockerFixture, 93 | mock_select_trades: MockerFixture, 94 | mock_cei_get_trades: MockerFixture, 95 | mock_cei_calculate_taxes: MockerFixture, 96 | mock_cei_output_taxes: MockerFixture, 97 | mock_cei_goods_and_rights: MockerFixture, 98 | mock_cei_output_goods_and_rights: MockerFixture, 99 | ) -> None: 100 | """Exit with a status code of zero.""" 101 | mocker.patch( 102 | "irpf_investidor.formatting.set_pt_br_locale", 103 | return_value=res.ResponseSuccess(), 104 | ) 105 | result = runner.invoke(__main__.main) 106 | assert result.output.startswith("Nome do arquivo: ") 107 | mock_cei_calculate_taxes.assert_called_once() 108 | mock_cei_output_taxes.assert_called_once() 109 | mock_cei_goods_and_rights.assert_called_once() 110 | mock_cei_output_goods_and_rights.assert_called_once() 111 | assert result.exit_code == 0 112 | 113 | 114 | def test_main_locale_fail( 115 | mocker: MockerFixture, 116 | runner: click.testing.CliRunner, 117 | mock_cei_get_xls_filename: MockerFixture, 118 | mock_cei_validate_header: MockerFixture, 119 | mock_cei_read_xls: MockerFixture, 120 | mock_cei_clean_table_cols: MockerFixture, 121 | mock_cei_group_trades: MockerFixture, 122 | mock_select_trades: MockerFixture, 123 | mock_cei_get_trades: MockerFixture, 124 | mock_cei_calculate_taxes: MockerFixture, 125 | mock_cei_output_taxes: MockerFixture, 126 | mock_cei_goods_and_rights: MockerFixture, 127 | mock_cei_output_goods_and_rights: MockerFixture, 128 | ) -> None: 129 | """Exit with `SystemExit` when locale not found.""" 130 | locale_fail = res.ResponseFailure( 131 | res.ResponseTypes.SYSTEM_ERROR, "locale xyz não encontrado." 132 | ) 133 | mocker.patch( 134 | "irpf_investidor.formatting.set_pt_br_locale", return_value=locale_fail 135 | ) 136 | result = runner.invoke(__main__.main) 137 | 138 | assert result.output.startswith("Erro: locale xyz não encontrado.") 139 | assert isinstance(result.exception, SystemExit) 140 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Nox sessions.""" 2 | 3 | import os 4 | import shutil 5 | import sys 6 | from pathlib import Path 7 | from textwrap import dedent 8 | 9 | import nox 10 | 11 | package = "irpf_investidor" 12 | python_versions = ["3.13", "3.12", "3.11"] 13 | nox.options.default_venv_backend = "uv" 14 | nox.options.sessions = ( 15 | "pre-commit", 16 | "mypy", 17 | "tests", 18 | "typeguard", 19 | "xdoctest", 20 | "docs-build", 21 | ) 22 | 23 | 24 | def activate_virtualenv_in_precommit_hooks(session: nox.Session) -> None: 25 | """Activate virtualenv in hooks installed by pre-commit. 26 | 27 | This function patches git hooks installed by pre-commit to activate the 28 | session's virtual environment. This allows pre-commit to locate hooks in 29 | that environment when invoked from git. 30 | """ 31 | assert session.bin is not None # nosec 32 | 33 | virtualenv = session.env.get("VIRTUAL_ENV") 34 | if virtualenv is None: 35 | return 36 | 37 | hookdir = Path(".git") / "hooks" 38 | if not hookdir.is_dir(): 39 | return 40 | 41 | for hook in hookdir.iterdir(): 42 | if hook.name.endswith(".sample") or not hook.is_file(): 43 | continue 44 | 45 | text = hook.read_text() 46 | bindir = repr(session.bin)[1:-1] # strip quotes 47 | if not ( 48 | Path("A") == Path("a") 49 | and bindir.lower() in text.lower() 50 | or bindir in text 51 | ): 52 | continue 53 | 54 | lines = text.splitlines() 55 | if not (lines[0].startswith("#!") and "python" in lines[0].lower()): 56 | continue 57 | 58 | header = dedent( 59 | f"""\ 60 | import os 61 | os.environ["VIRTUAL_ENV"] = {virtualenv!r} 62 | os.environ["PATH"] = os.pathsep.join(( 63 | {session.bin!r}, 64 | os.environ.get("PATH", ""), 65 | )) 66 | """ 67 | ) 68 | 69 | lines.insert(1, header) 70 | hook.write_text("\n".join(lines)) 71 | 72 | 73 | @nox.session(name="pre-commit", python=python_versions[0]) 74 | def precommit(session: nox.Session) -> None: 75 | """Lint using pre-commit.""" 76 | args = session.posargs or ["run", "--all-files", "--show-diff-on-failure"] 77 | session.install( 78 | "pre-commit", 79 | "pre-commit-hooks", 80 | ) 81 | session.run("pre-commit", *args) 82 | if args and args[0] == "install": 83 | activate_virtualenv_in_precommit_hooks(session) 84 | 85 | 86 | @nox.session(python=python_versions) 87 | def mypy(session: nox.Session) -> None: 88 | """Type-check using mypy.""" 89 | args = session.posargs or ["src", "tests", "docs/conf.py"] 90 | session.install(".") 91 | session.install("mypy", "pytest") 92 | session.run("mypy", *args) 93 | if not session.posargs: 94 | session.run( 95 | "mypy", f"--python-executable={sys.executable}", "noxfile.py" 96 | ) 97 | 98 | 99 | @nox.session(python=python_versions) 100 | def tests(session: nox.Session) -> None: 101 | """Run the test suite.""" 102 | session.install(".") 103 | session.install( 104 | "coverage[toml]", "pyfakefs", "pytest", "pytest-mock", "pygments" 105 | ) 106 | try: 107 | session.run( 108 | "coverage", "run", "--parallel", "-m", "pytest", *session.posargs 109 | ) 110 | finally: 111 | if session.interactive: 112 | session.notify("coverage", posargs=[]) 113 | 114 | 115 | @nox.session 116 | def coverage(session: nox.Session) -> None: 117 | """Produce the coverage report.""" 118 | args = session.posargs or ["report"] 119 | 120 | session.install("coverage[toml]") 121 | 122 | if not session.posargs and any(Path().glob(".coverage.*")): 123 | session.run("coverage", "combine") 124 | 125 | session.run("coverage", *args) 126 | 127 | 128 | @nox.session(python=python_versions[0]) 129 | def typeguard(session: nox.Session) -> None: 130 | """Runtime type checking using Typeguard.""" 131 | session.install(".") 132 | session.install( 133 | "pyfakefs", "pytest", "pytest-mock", "typeguard", "pygments" 134 | ) 135 | session.run("pytest", f"--typeguard-packages={package}", *session.posargs) 136 | 137 | 138 | @nox.session(python=python_versions) 139 | def xdoctest(session: nox.Session) -> None: 140 | """Run examples with xdoctest.""" 141 | if session.posargs: 142 | args = [package, *session.posargs] 143 | else: 144 | args = [f"--modname={package}", "--command=all"] 145 | if "FORCE_COLOR" in os.environ: 146 | args.append("--colored=1") 147 | 148 | session.install(".") 149 | session.install("xdoctest[colors]") 150 | session.run("python", "-m", "xdoctest", *args) 151 | 152 | 153 | @nox.session(name="docs-build", python=python_versions[0]) 154 | def docs_build(session: nox.Session) -> None: 155 | """Build the documentation.""" 156 | args = session.posargs or ["docs", "docs/_build"] 157 | if not session.posargs and "FORCE_COLOR" in os.environ: 158 | args.insert(0, "--color") 159 | 160 | session.install(".") 161 | session.install("sphinx", "sphinx-click", "furo", "myst-parser") 162 | 163 | build_dir = Path("docs", "_build") 164 | if build_dir.exists(): 165 | shutil.rmtree(build_dir) 166 | 167 | session.run("sphinx-build", *args) 168 | 169 | 170 | @nox.session(python=python_versions[0]) 171 | def docs(session: nox.Session) -> None: 172 | """Build and serve the documentation with live reloading on file changes.""" 173 | args = session.posargs or ["--open-browser", "docs", "docs/_build"] 174 | session.install(".") 175 | session.install( 176 | "sphinx", "sphinx-autobuild", "sphinx-click", "furo", "myst-parser" 177 | ) 178 | 179 | build_dir = Path("docs", "_build") 180 | if build_dir.exists(): 181 | shutil.rmtree(build_dir) 182 | 183 | session.run("sphinx-autobuild", *args) 184 | -------------------------------------------------------------------------------- /src/irpf_investidor/report_reader.py: -------------------------------------------------------------------------------- 1 | """Report reader.""" 2 | 3 | import datetime 4 | import glob 5 | import math 6 | import os 7 | import sys 8 | 9 | import pandas as pd 10 | import xlrd 11 | 12 | import irpf_investidor.b3 13 | import irpf_investidor.formatting 14 | 15 | IRPF_INVESTIMENT_CODES = { 16 | "ETF": "74 (ETF)", 17 | "FII": "73 (FII)", 18 | "STOCKS": "31 (Ações)", 19 | "NOT_FOUND": "Não encontrado", 20 | } 21 | FIRST_IMPLEMENTED_YEAR = 2019 22 | LAST_IMPLEMENTED_YEAR = 2024 23 | 24 | 25 | def get_xls_filename() -> str: 26 | """Return first xls filename in current folder or Downloads folder.""" 27 | filenames = glob.glob("InfoCEI*.xls") 28 | if filenames: 29 | return filenames[0] 30 | home = os.path.expanduser("~") 31 | filenames = glob.glob(os.path.join(home, "Downloads", "InfoCEI*.xls")) 32 | if filenames: 33 | return filenames[0] 34 | return sys.exit( 35 | "Erro: arquivo não encontrado, confira a documentação para mais informações." 36 | ) 37 | 38 | 39 | def date_parse(value: str) -> datetime.datetime: 40 | """Parse dates from CEI report.""" 41 | return datetime.datetime.strptime(value.strip(), "%d/%m/%y") 42 | 43 | 44 | def validate_period(first: str, second: str) -> int: 45 | """Consider the year from the first trade date.""" 46 | first_year = int(first[-4:]) 47 | second_year = int(second[-4:]) 48 | if ( 49 | first_year <= second_year 50 | and first_year >= FIRST_IMPLEMENTED_YEAR 51 | and second_year <= LAST_IMPLEMENTED_YEAR 52 | ): 53 | return second_year 54 | return sys.exit( 55 | f"Erro: o período de {first} a {second} não é válido, favor verificar " 56 | "instruções na documentação." 57 | ) 58 | 59 | 60 | def validate_header(filepath: str) -> tuple[int, str]: 61 | """Validate file header.""" 62 | try: 63 | basic_df = pd.read_excel( 64 | filepath, 65 | usecols="B", 66 | date_parser=date_parse, 67 | skiprows=4, 68 | ) 69 | # exits if empty 70 | except (ValueError, xlrd.XLRDError): 71 | sys.exit( 72 | f"Erro: arquivo {filepath} não se encontra íntegro ou no formato de " 73 | "relatórios do CEI." 74 | ) 75 | 76 | periods = basic_df["Período de"].iloc[0].split(" a ") 77 | ref_year = validate_period(periods[0], periods[1]) 78 | 79 | instutition = basic_df["Período de"].iloc[4] 80 | return ref_year, instutition 81 | 82 | 83 | def read_xls(filename: str) -> pd.DataFrame: 84 | """Read xls.""" 85 | df = pd.read_excel( 86 | filename, 87 | usecols="B:K", 88 | parse_dates=["Data Negócio"], 89 | date_parser=date_parse, 90 | skipfooter=4, 91 | skiprows=10, 92 | ) 93 | return df 94 | 95 | 96 | # Source: https://realpython.com/python-rounding/ 97 | def round_down_money(n: float, decimals: int = 2) -> float: 98 | """Round float on second decimal cases.""" 99 | multiplier = 10**decimals 100 | return math.floor(n * multiplier) / multiplier # type: ignore 101 | 102 | 103 | def clean_table_cols(source_df: pd.DataFrame) -> pd.DataFrame: 104 | """Drop columns without values.""" 105 | return source_df.dropna(axis="columns", how="all") 106 | 107 | 108 | def get_trades(df: pd.DataFrame) -> list[tuple[int, str]]: 109 | """Return trades representations.""" 110 | df["total_cost_rs"] = df["Valor Total (R$)"].apply( 111 | lambda x: "R$ " + str(f"{x:.2f}".replace(".", ",")) 112 | ) 113 | df = df.drop(columns=["Valor Total (R$)"]) 114 | list_of_list = df.astype(str).values.tolist() 115 | df = df.drop(columns=["total_cost_rs"]) 116 | return [(i, " ".join(x)) for i, x in enumerate(list_of_list)] 117 | 118 | 119 | def group_trades(df: pd.DataFrame) -> pd.DataFrame: 120 | """Group trades by day, asset and action.""" 121 | return ( 122 | df.groupby(["Data Negócio", "Código", "C/V"]) 123 | .agg( 124 | { 125 | "Quantidade": "sum", 126 | "Valor Total (R$)": "sum", 127 | "Especificação do Ativo": "first", 128 | } 129 | ) 130 | .reset_index() 131 | ) 132 | 133 | 134 | def calculate_taxes( 135 | df: pd.DataFrame, auction_trades: list[int] 136 | ) -> pd.DataFrame: 137 | """Calculate emolumentos and liquidação taxes based on reference year.""" 138 | df["Liquidação (R$)"] = ( 139 | df["Valor Total (R$)"] 140 | * irpf_investidor.b3.get_liquidacao_rates(df["Data Negócio"].array) 141 | ).apply(round_down_money) 142 | df["Emolumentos (R$)"] = ( 143 | df["Valor Total (R$)"] 144 | * irpf_investidor.b3.get_emolumentos_rates( 145 | df["Data Negócio"].array, auction_trades 146 | ) 147 | ).apply(round_down_money) 148 | return df 149 | 150 | 151 | def buy_sell_columns(df: pd.DataFrame) -> pd.DataFrame: 152 | """Create columns for buys and sells with quantity and total value.""" 153 | df["Quantidade Compra"] = df["Quantidade"].where( 154 | df["C/V"].str.contains("C"), 0 155 | ) 156 | df["Custo Total Compra (R$)"] = ( 157 | df[["Valor Total (R$)", "Liquidação (R$)", "Emolumentos (R$)"]] 158 | .sum(axis="columns") 159 | .where(df["C/V"].str.contains("C"), 0) 160 | ).round(decimals=2) 161 | df["Quantidade Venda"] = df["Quantidade"].where( 162 | df["C/V"].str.contains("V"), 0 163 | ) 164 | df["Custo Total Venda (R$)"] = ( 165 | df[["Valor Total (R$)", "Liquidação (R$)", "Emolumentos (R$)"]] 166 | .sum(axis="columns") 167 | .where(df["C/V"].str.contains("V"), 0) 168 | ).round(decimals=2) 169 | df.drop(["Quantidade", "Valor Total (R$)"], axis="columns", inplace=True) 170 | return df 171 | 172 | 173 | def group_buys_sells(df: pd.DataFrame) -> pd.DataFrame: 174 | """Group buys and sells by asset.""" 175 | return ( 176 | df.groupby(["Código"]) 177 | .agg( 178 | { 179 | "Quantidade Compra": "sum", 180 | "Custo Total Compra (R$)": "sum", 181 | "Quantidade Venda": "sum", 182 | "Custo Total Venda (R$)": "sum", 183 | "Especificação do Ativo": "first", 184 | } 185 | ) 186 | .round(decimals=2) 187 | .reset_index() 188 | ) 189 | 190 | 191 | def average_price(df: pd.DataFrame) -> pd.DataFrame: 192 | """Compute average price.""" 193 | df["Preço Médio (R$)"] = ( 194 | df["Custo Total Compra (R$)"] / df["Quantidade Compra"] 195 | ) 196 | return df 197 | 198 | 199 | def goods_and_rights(source_df: pd.DataFrame) -> pd.DataFrame: 200 | """Call methods for goods and rights.""" 201 | result_df = buy_sell_columns(source_df) 202 | result_df = group_buys_sells(source_df) 203 | result_df = average_price(result_df) 204 | return result_df 205 | 206 | 207 | def output_taxes(tax_df: pd.DataFrame) -> None: 208 | """Print tax DataFrame.""" 209 | with pd.option_context( 210 | "display.max_rows", None, "display.max_columns", None 211 | ): 212 | print( 213 | "Valores calculados de emolumentos, liquidação e custo total:\n", 214 | tax_df, 215 | ) 216 | 217 | 218 | def output_goods_and_rights( 219 | result_df: pd.DataFrame, ref_year: int, institution: str 220 | ) -> None: 221 | """Return a list of assets.""" 222 | pd.set_option( 223 | "float_format", irpf_investidor.formatting.get_currency_format() 224 | ) 225 | print("========= Bens e Direitos =========") 226 | for row in result_df.iterrows(): 227 | idx = row[0] 228 | content = row[1] 229 | desc = content["Especificação do Ativo"] 230 | code = content["Código"] 231 | qtd = content["Quantidade Compra"] - content["Quantidade Venda"] 232 | avg_price = content["Preço Médio (R$)"] 233 | avg_price_str = irpf_investidor.formatting.fmt_money(avg_price, 3) 234 | cnpj = irpf_investidor.b3.get_cnpj_institution(institution) 235 | result = irpf_investidor.formatting.fmt_money(avg_price * qtd, 2) 236 | asset_info = irpf_investidor.b3.get_asset_info(code) 237 | print( 238 | f"============= Ativo {idx + 1} =============\n" 239 | f"Código: {IRPF_INVESTIMENT_CODES[asset_info.category]}\n" 240 | f"CNPJ: {asset_info.cnpj if asset_info.cnpj else 'Não encontrado'}\n" 241 | f"Discriminação (sugerida): {desc}, código: {code}, quantidade: {qtd}, " 242 | f"preço médio de compra: R$ {avg_price_str}, corretora: {institution} -" 243 | f" CNPJ {cnpj}\nSituação em 31/12/{ref_year}: R$ {result}\n" 244 | ) 245 | -------------------------------------------------------------------------------- /tests/test_report_reader.py: -------------------------------------------------------------------------------- 1 | """Test cases for the report reader module.""" 2 | 3 | import datetime 4 | import os 5 | 6 | import pandas as pd 7 | import pytest 8 | from pytest_mock import MockerFixture 9 | 10 | import irpf_investidor.report_reader as report_reader 11 | 12 | B3_REPORT_NAME = "InfoCEI.xls" 13 | 14 | 15 | def test_date_parse() -> None: 16 | """Return datetime.""" 17 | expected = datetime.datetime(day=1, month=2, year=2019) 18 | assert report_reader.date_parse(" 01/02/19 ") == expected 19 | 20 | 21 | @pytest.fixture 22 | def mock_pandas_read_excel(mocker: MockerFixture) -> MockerFixture: 23 | """Fixture for mocking pandas.read_excel.""" 24 | mock = mocker.patch("pandas.read_excel") 25 | header = pd.DataFrame( 26 | { 27 | "Período de": [ 28 | "01/01/2019 a 31/12/2019", 29 | float("nan"), 30 | float("nan"), 31 | float("nan"), 32 | "INSTITUTION", 33 | ] 34 | } 35 | ) 36 | mock.return_value = header 37 | return mock 38 | 39 | 40 | def test_read_xls(mock_pandas_read_excel: MockerFixture) -> None: 41 | """Call read_excel.""" 42 | report_reader.read_xls("my.xls") 43 | mock_pandas_read_excel.assert_called_once() 44 | 45 | 46 | def test_round_down_money_more_than_half() -> None: 47 | """Return rounded down two decimals.""" 48 | assert report_reader.round_down_money(5.999) == 5.99 49 | 50 | 51 | def test_round_down_money_on_half() -> None: 52 | """Return rounded down two decimals second case.""" 53 | assert report_reader.round_down_money(5.555) == 5.55 54 | 55 | 56 | def test_round_down_money_one_digit() -> None: 57 | """Return rounded down two decimals third case.""" 58 | assert report_reader.round_down_money(8.5) == 8.50 59 | 60 | 61 | @pytest.fixture 62 | def cwd(fs: MockerFixture, monkeypatch: MockerFixture) -> None: 63 | """Fixture for pyfakefs fs.""" 64 | fs.cwd = "/path" 65 | monkeypatch.setenv("HOME", "/home") 66 | 67 | 68 | def test_get_xls_filename_not_found( 69 | fs: MockerFixture, cwd: MockerFixture 70 | ) -> None: 71 | """Raise `SystemExit` when file is not found.""" 72 | with pytest.raises(SystemExit): 73 | assert report_reader.get_xls_filename() 74 | 75 | 76 | def test_get_xls_filename_current_folder( 77 | fs: MockerFixture, cwd: MockerFixture 78 | ) -> None: 79 | """Return filename found in current folder.""" 80 | fs.create_file(f"/path/{B3_REPORT_NAME}") 81 | assert report_reader.get_xls_filename() == B3_REPORT_NAME 82 | 83 | 84 | def test_get_xls_filename_download_folder( 85 | mocker: MockerFixture, fs: MockerFixture, cwd: MockerFixture 86 | ) -> None: 87 | """Return filename found in downloads folder.""" 88 | mocker.patch("os.path.expanduser", return_value="/home") 89 | path = os.path.join("/home", "Downloads", B3_REPORT_NAME) 90 | fs.create_file(path) 91 | assert report_reader.get_xls_filename() == path 92 | 93 | 94 | def test_validate_period_success() -> None: 95 | """Return reference year.""" 96 | first_date = "01/01/2019" 97 | second_date = "31/12/2021" 98 | 99 | assert report_reader.validate_period(first_date, second_date) == 2021 100 | 101 | 102 | def test_validate_period_wrong_start_finish() -> None: 103 | """Raise `SystemExit` from wrong start date.""" 104 | first_date = "01/01/2018" 105 | second_date = "31/12/2020" 106 | 107 | with pytest.raises(SystemExit) as ex: 108 | report_reader.validate_period(first_date, second_date) 109 | assert str(ex.value) == ( 110 | f"Erro: o período de {first_date} a {second_date} não é válido, favor " 111 | "verificar instruções na documentação." 112 | ) 113 | 114 | 115 | def test_validate_header_empty_file( 116 | fs: MockerFixture, cwd: MockerFixture 117 | ) -> None: 118 | """Raise `SystemExit` from empty file.""" 119 | path = os.path.join("path", "Inforeport_reader.xls") 120 | fs.create_file(path) 121 | with pytest.raises(SystemExit): 122 | report_reader.validate_header(path) 123 | 124 | 125 | @pytest.fixture 126 | def mock_validate_period(mocker: MockerFixture) -> MockerFixture: 127 | """Fixture for mocking irpf_investidor.report_reader.validate_period.""" 128 | mock = mocker.patch("irpf_investidor.report_reader.validate_period") 129 | mock.return_value = 2019 130 | return mock 131 | 132 | 133 | def test_validate_header( 134 | mock_pandas_read_excel: MockerFixture, mock_validate_period: MockerFixture 135 | ) -> None: 136 | """Return year and institution.""" 137 | assert report_reader.validate_header("/my/path/Inforeport_reader.xls") == ( 138 | 2019, 139 | "INSTITUTION", 140 | ) 141 | 142 | 143 | def test_clean_table_cols() -> None: 144 | """Return cleaned DataFrame.""" 145 | df = pd.DataFrame( 146 | { 147 | "full_valued": [1, 2, 3], 148 | "all_missing1": [None, None, None], 149 | "some_missing": [None, 2, 3], 150 | "all_missing2": [None, None, None], 151 | } 152 | ) 153 | expected_df = pd.DataFrame( 154 | {"full_valued": [1, 2, 3], "some_missing": [None, 2, 3]} 155 | ) 156 | result_df = report_reader.clean_table_cols(df) 157 | pd.testing.assert_frame_equal(result_df, expected_df) 158 | 159 | 160 | def test_get_trades() -> None: 161 | """Return a list of trade tuples.""" 162 | df = pd.DataFrame( 163 | { 164 | "Data": ["10/10/2019", "12/11/2019"], 165 | "Operação": ["B ", "S "], 166 | "Quantidade": [10, 100], 167 | "Valor Total (R$)": [102.0, 3050], 168 | } 169 | ) 170 | expected_result = [ 171 | (0, "10/10/2019 B 10 R$ 102,00"), 172 | (1, "12/11/2019 S 100 R$ 3050,00"), 173 | ] 174 | result = report_reader.get_trades(df) 175 | assert expected_result == result 176 | 177 | 178 | def test_group_trades() -> None: 179 | """Return a DataFrame of grouped trades.""" 180 | df = pd.DataFrame( 181 | { 182 | "Data Negócio": ["1", "1", "2", "2", "2", "2"], 183 | "Código": [ 184 | "BOVA11", 185 | "PETR4", 186 | "PETR4", 187 | "BOVA11", 188 | "BOVA11", 189 | "BOVA11", 190 | ], 191 | "C/V": [" C ", " V ", " V ", " V ", " C ", " C "], 192 | "Quantidade": [20, 30, 50, 80, 130, 210], 193 | "Valor Total (R$)": [10.20, 30.50, 80.13, 210.34, 550.89, 144.233], 194 | "Especificação do Ativo": [ 195 | "ISHARES", 196 | "PETRO", 197 | "PETRO", 198 | "ISHARES", 199 | "ISHARES", 200 | "ISHARES", 201 | ], 202 | } 203 | ) 204 | expected_df = pd.DataFrame( 205 | { 206 | "Data Negócio": ["1", "1", "2", "2", "2"], 207 | "Código": ["BOVA11", "PETR4", "BOVA11", "BOVA11", "PETR4"], 208 | "C/V": [" C ", " V ", " C ", " V ", " V "], 209 | "Quantidade": [20, 30, 340, 80, 50], 210 | "Valor Total (R$)": [10.20, 30.50, 695.123, 210.34, 80.13], 211 | "Especificação do Ativo": [ 212 | "ISHARES", 213 | "PETRO", 214 | "ISHARES", 215 | "ISHARES", 216 | "PETRO", 217 | ], 218 | } 219 | ) 220 | result_df = report_reader.group_trades(df) 221 | pd.testing.assert_frame_equal(result_df, expected_df) 222 | 223 | 224 | def test_calculate_taxes_2019(mocker: MockerFixture) -> None: 225 | """Return calculated taxes.""" 226 | mocker.patch( 227 | "irpf_investidor.b3.get_liquidacao_rates", return_value=0.000275 228 | ) 229 | mocker.patch( 230 | "irpf_investidor.b3.get_emolumentos_rates", 231 | return_value=[0.00004105, 0.00004105, 0.00004105], 232 | ) 233 | df = pd.DataFrame( 234 | { 235 | "Data Negócio": [ 236 | datetime.datetime(2019, 2, 20), 237 | datetime.datetime(2019, 3, 6), 238 | datetime.datetime(2019, 5, 14), 239 | ], 240 | "Valor Total (R$)": [935, 10956, 8870], 241 | } 242 | ) 243 | expected_df = pd.DataFrame( 244 | { 245 | "Data Negócio": [ 246 | datetime.datetime(2019, 2, 20), 247 | datetime.datetime(2019, 3, 6), 248 | datetime.datetime(2019, 5, 14), 249 | ], 250 | "Valor Total (R$)": [935, 10956, 8870], 251 | "Liquidação (R$)": [0.25, 3.01, 2.43], 252 | "Emolumentos (R$)": [0.03, 0.44, 0.36], 253 | } 254 | ) 255 | result_df = report_reader.calculate_taxes(df, []) 256 | pd.testing.assert_frame_equal(result_df, expected_df) 257 | 258 | 259 | def test_buy_sell_columns() -> None: 260 | """Return DataFrame with separated buy/sell columns.""" 261 | df = pd.DataFrame( 262 | { 263 | "Data Negócio": ["1", "1", "2", "2", "2"], 264 | "Código": ["BOVA11", "PETR4", "BOVA11", "BOVA11", "PETR4"], 265 | "C/V": [" C ", " V ", " C ", " V ", " V "], 266 | "Quantidade": [20, 30, 340, 80, 50], 267 | "Valor Total (R$)": [10.20, 30.50, 695.123, 210.34, 80.13], 268 | "Liquidação (R$)": [1, 2, 5, 4, 3], 269 | "Emolumentos (R$)": [0.2, 0.3, 1.3, 0.8, 0.5], 270 | } 271 | ) 272 | expected_df = pd.DataFrame( 273 | { 274 | "Data Negócio": ["1", "1", "2", "2", "2"], 275 | "Código": ["BOVA11", "PETR4", "BOVA11", "BOVA11", "PETR4"], 276 | "C/V": [" C ", " V ", " C ", " V ", " V "], 277 | "Liquidação (R$)": [1, 2, 5, 4, 3], 278 | "Emolumentos (R$)": [0.2, 0.3, 1.3, 0.8, 0.5], 279 | "Quantidade Compra": [20, 0, 340, 0, 0], 280 | "Custo Total Compra (R$)": [11.40, 0, 701.423, 0, 0], 281 | "Quantidade Venda": [0, 30, 0, 80, 50], 282 | "Custo Total Venda (R$)": [0, 32.80, 0, 215.14, 83.63], 283 | } 284 | ) 285 | result_df = report_reader.buy_sell_columns(df) 286 | pd.testing.assert_frame_equal(result_df, expected_df) 287 | 288 | 289 | def test_group_buys_sells() -> None: 290 | """Return a DataFrame with grouped buy/sell trades.""" 291 | df = pd.DataFrame( 292 | { 293 | "Código": ["BOVA11", "PETR4", "BOVA11", "BOVA11", "PETR4"], 294 | "Quantidade Compra": [20, 0, 340, 0, 0], 295 | "Custo Total Compra (R$)": [11.40, 0, 701.423, 0, 0], 296 | "Quantidade Venda": [0, 30, 0, 80, 50], 297 | "Custo Total Venda (R$)": [0, 32.80, 0, 215.14, 83.63], 298 | "Especificação do Ativo": [ 299 | "ISHARES", 300 | "PETRO", 301 | "ISHARES", 302 | "ISHARES", 303 | "PETRO", 304 | ], 305 | } 306 | ) 307 | expected_df = pd.DataFrame( 308 | { 309 | "Código": ["BOVA11", "PETR4"], 310 | "Quantidade Compra": [360, 0], 311 | "Custo Total Compra (R$)": [712.823, 0], 312 | "Quantidade Venda": [80, 80], 313 | "Custo Total Venda (R$)": [215.14, 116.43], 314 | "Especificação do Ativo": ["ISHARES", "PETRO"], 315 | } 316 | ) 317 | result_df = report_reader.group_buys_sells(df) 318 | pd.testing.assert_frame_equal(result_df, expected_df) 319 | 320 | 321 | def test_average_price() -> None: 322 | """Return a DataFrame with average price column.""" 323 | df = pd.DataFrame( 324 | { 325 | "Código": ["BOVA11", "PETR4"], 326 | "Quantidade Compra": [360, 0], 327 | "Custo Total Compra (R$)": [712.823, 0], 328 | } 329 | ) 330 | expected_df = pd.DataFrame( 331 | { 332 | "Código": ["BOVA11", "PETR4"], 333 | "Quantidade Compra": [360, 0], 334 | "Custo Total Compra (R$)": [712.823, 0], 335 | "Preço Médio (R$)": [1.980064, float("nan")], 336 | } 337 | ) 338 | result_df = report_reader.average_price(df) 339 | pd.testing.assert_frame_equal(result_df, expected_df) 340 | 341 | 342 | def test_goods_and_rights( 343 | mocker: MockerFixture, 344 | ) -> None: 345 | """Return a DataFrame.""" 346 | mocker.patch("irpf_investidor.report_reader.buy_sell_columns") 347 | mocker.patch("irpf_investidor.report_reader.group_buys_sells") 348 | mocker.patch( 349 | "irpf_investidor.report_reader.average_price", 350 | return_value=pd.DataFrame(), 351 | ) 352 | 353 | df = report_reader.goods_and_rights(pd.DataFrame()) 354 | assert type(df) is pd.DataFrame 355 | 356 | 357 | def test_output_taxes(mocker: MockerFixture) -> None: 358 | """Print out taxes.""" 359 | mock_print = mocker.patch("builtins.print") 360 | 361 | report_reader.output_taxes(pd.DataFrame()) 362 | mock_print.assert_called_once() 363 | 364 | 365 | def test_output_goods_and_rights(mocker: MockerFixture) -> None: 366 | """Print out goods and rights.""" 367 | mocker.patch("irpf_investidor.formatting.get_currency_format") 368 | mock_print = mocker.patch("builtins.print") 369 | 370 | df = pd.DataFrame( 371 | { 372 | "Código": ["BOVA11", "PETR4"], 373 | "Quantidade Compra": [360, 0], 374 | "Custo Total Compra (R$)": [712.823, 0], 375 | "Quantidade Venda": [80, 80], 376 | "Custo Total Venda (R$)": [215.14, 116.43], 377 | "Preço Médio (R$)": [1.980, float("nan")], 378 | "Especificação do Ativo": ["ISHARES", "PETRO"], 379 | } 380 | ) 381 | report_reader.output_goods_and_rights(df, 2019, "XYZ") 382 | assert mock_print.call_count == 3 383 | -------------------------------------------------------------------------------- /src/irpf_investidor/b3.py: -------------------------------------------------------------------------------- 1 | """B3 module.""" 2 | 3 | import collections 4 | import datetime 5 | import sys 6 | 7 | RatePeriod = collections.namedtuple( 8 | "RatePeriod", ["start_date", "end_date", "rate"] 9 | ) 10 | 11 | # Ref: https://www.b3.com.br/pt_br/produtos-e-servicos/tarifas/listados-a-vista-e-derivativos/renda-variavel/tarifas-de-acoes-e-fundos-de-investimento/a-vista/ 12 | EMOLUMENTOS_PERIODS = [ 13 | RatePeriod( 14 | datetime.datetime(2019, 1, 3), datetime.datetime(2019, 2, 1), 0.00004476 15 | ), 16 | RatePeriod( 17 | datetime.datetime(2019, 2, 4), datetime.datetime(2019, 3, 1), 0.00004032 18 | ), 19 | RatePeriod( 20 | datetime.datetime(2019, 3, 6), datetime.datetime(2019, 4, 1), 0.00004157 21 | ), 22 | RatePeriod( 23 | datetime.datetime(2019, 4, 2), datetime.datetime(2019, 5, 2), 0.0000408 24 | ), 25 | RatePeriod( 26 | datetime.datetime(2019, 5, 3), datetime.datetime(2019, 6, 3), 0.00004408 27 | ), 28 | RatePeriod( 29 | datetime.datetime(2019, 6, 4), datetime.datetime(2019, 7, 1), 0.00004245 30 | ), 31 | RatePeriod( 32 | datetime.datetime(2019, 7, 2), datetime.datetime(2019, 8, 1), 0.00004189 33 | ), 34 | RatePeriod( 35 | datetime.datetime(2019, 8, 2), datetime.datetime(2019, 9, 2), 0.00004115 36 | ), 37 | RatePeriod( 38 | datetime.datetime(2019, 9, 3), 39 | datetime.datetime(2019, 10, 1), 40 | 0.00003756, 41 | ), 42 | RatePeriod( 43 | datetime.datetime(2019, 10, 2), 44 | datetime.datetime(2019, 11, 1), 45 | 0.00004105, 46 | ), 47 | RatePeriod( 48 | datetime.datetime(2019, 11, 4), 49 | datetime.datetime(2019, 12, 2), 50 | 0.0000411, 51 | ), 52 | RatePeriod( 53 | datetime.datetime(2019, 12, 3), 54 | datetime.datetime(2020, 1, 1), 55 | 0.00003802, 56 | ), 57 | RatePeriod( 58 | datetime.datetime(2020, 1, 1), datetime.datetime(2020, 2, 1), 0.0000366 59 | ), 60 | RatePeriod( 61 | datetime.datetime(2020, 2, 1), datetime.datetime(2020, 3, 1), 0.00003462 62 | ), 63 | RatePeriod( 64 | datetime.datetime(2020, 3, 1), datetime.datetime(2020, 4, 1), 0.00003248 65 | ), 66 | RatePeriod( 67 | datetime.datetime(2020, 4, 1), datetime.datetime(2020, 5, 1), 0.00003006 68 | ), 69 | RatePeriod( 70 | datetime.datetime(2020, 5, 1), datetime.datetime(2020, 6, 3), 0.0000334 71 | ), 72 | RatePeriod( 73 | datetime.datetime(2020, 6, 1), datetime.datetime(2020, 7, 1), 0.00003291 74 | ), 75 | RatePeriod( 76 | datetime.datetime(2020, 7, 1), datetime.datetime(2020, 8, 1), 0.00003089 77 | ), 78 | RatePeriod( 79 | datetime.datetime(2020, 8, 1), datetime.datetime(2020, 9, 1), 0.0000318 80 | ), 81 | RatePeriod( 82 | datetime.datetime(2020, 9, 1), 83 | datetime.datetime(2020, 10, 1), 84 | 0.00003125, 85 | ), 86 | RatePeriod( 87 | datetime.datetime(2020, 10, 1), 88 | datetime.datetime(2020, 11, 1), 89 | 0.00003219, 90 | ), 91 | RatePeriod( 92 | datetime.datetime(2020, 11, 1), 93 | datetime.datetime(2021, 2, 2), 94 | 0.00003247, 95 | ), 96 | RatePeriod( 97 | datetime.datetime(2021, 2, 2), datetime.datetime(2025, 5, 15), 0.00005 98 | ), 99 | ] 100 | EMOLUMENTOS_AUCTION_RATE = 0.00007 101 | LIQUIDACAO_PERIODS = [ 102 | RatePeriod( 103 | datetime.datetime(2019, 1, 3), datetime.datetime(2021, 2, 2), 0.000275 104 | ), 105 | RatePeriod( 106 | datetime.datetime(2021, 2, 2), datetime.datetime(2025, 5, 15), 0.00025 107 | ), 108 | ] 109 | 110 | AssetInfo = collections.namedtuple("AssetInfo", ["category", "cnpj"]) 111 | 112 | ETFS = { 113 | "BBSD": "17.817.528/0001-50", 114 | "XBOV": "14.120.533/0001-11", 115 | "BOVB": "32.203.211/0001-18", 116 | "IVVB": "19.909.560/0001-91", 117 | "BOVA": "10.406.511/0001-61", 118 | "BRAX": "11.455.378/0001-04", 119 | "ECOO": "15.562.377/0001-01", 120 | "SMAL": "10.406.600/0001-08", 121 | "BOVV": "21.407.758/0001-19", 122 | "DIVO": "13.416.245/0001-46", 123 | "FIND": "11.961.094/0001-81", 124 | "GOVE": "11.184.136/0001-15", 125 | "MATB": "13.416.228/0001-09", 126 | "ISUS": "12.984.444/0001-98", 127 | "PIBB": "06.323.688/0001-27", 128 | "SMAC": "34.803.814/0001-86", 129 | "SPXI": "17.036.289/0001-00", 130 | } 131 | FIIS = { 132 | "ALZR": "28.737.771/0001-85", 133 | "AQLL": "13.555.918/0001-49", 134 | "BCRI": "22.219.335/0001-38", 135 | "BNFS": "15.570.431/0001-60", 136 | "BBPO": "14.410.722/0001-29", 137 | "BBIM": "20.716.161/0001-93", 138 | "BBRC": "12.681.340/0001-04", 139 | "RDPD": "23.120.027/0001-13", 140 | "RNDP": "15.394.563/0001-89", 141 | "BCIA": "20.216.935/0001-17", 142 | "BZLI": "14.074.706/0001-02", 143 | "CARE": "13.584.584/0001-31", 144 | "BRCO": "20.748.515/0001-81", 145 | "BTLG": "11.839.593/0001-09", 146 | "CRFF": "31.887.401/0001-39", 147 | "CXRI": "17.098.794/0001-70", 148 | "CPFF": "34.081.611/0001-23", 149 | "CBOP": "17.144.039/0001-85", 150 | "GRLV": "17.143.998/0001-86", 151 | "HGFF": "32.784.898/0001-22", 152 | "HGLG": "11.728.688/0001-47", 153 | "HGPO": "11.260.134/0001-68", 154 | "HGRE": "09.072.017/0001-29", 155 | "HGCR": "11.160.521/0001-22", 156 | "HGRU": "29.641.226/0001-53", 157 | "ERPA": "31.469.385/0001-64", 158 | "KINP": "24.070.076/0001-51", 159 | "VRTA": "11.664.201/0001-00", 160 | "BMII": "02.027.437/0001-44", 161 | "BTCR": "29.787.928/0001-40", 162 | "FAED": "11.179.118/0001-45", 163 | "BPRP": "29.800.650/0001-01", 164 | "BRCR": "08.924.783/0001-01", 165 | "FEXC": "09.552.812/0001-14", 166 | "BCFF": "11.026.627/0001-38", 167 | "FCFL": "11.602.654/0001-01", 168 | "CNES": "13.551.286/0001-45", 169 | "CEOC": "15.799.397/0001-09", 170 | "THRA": "13.966.653/0001-71", 171 | "EDGA": "15.333.306/0001-37", 172 | "FLRP": "10.375.382/0001-91", 173 | "HCRI": "04.066.582/0001-60", 174 | "NSLU": "08.014.513/0001-63", 175 | "HTMX": "08.706.065/0001-69", 176 | "MAXR": "11.274.415/0001-70", 177 | "NCHB": "18.085.673/0001-57", 178 | "NVHO": "17.025.970/0001-44", 179 | "PQDP": "10.869.155/0001-12", 180 | "PRSV": "11.281.322/0001-72", 181 | "RBRM": "26.314.437/0001-93", 182 | "RBRR": "29.467.977/0001-03", 183 | "JRDM": "14.879.856/0001-93", 184 | "TBOF": "17.365.105/0001-47", 185 | "ALMI": "07.122.725/0001-00", 186 | "TRNT": "04.722.883/0001-02", 187 | "RECT": "32.274.163/0001-59", 188 | "UBSR": "28.152.272/0001-26", 189 | "VLOL": "15.296.696/0001-12", 190 | "OUFF": "30.791.386/0001-68", 191 | "VVPR": "33.045.581/0001-37", 192 | "LVBI": "30.629.603/0001-18", 193 | "BARI": "29.267.567/0001-00", 194 | "BBVJ": "10.347.985/0001-80", 195 | "BPFF": "17.324.357/0001-28", 196 | "BVAR": "21.126.204/0001-43", 197 | "BPML": "33.046.142/0001-49", 198 | "CXTL": "12.887.506/0001-43", 199 | "CTXT": "00.762.723/0001-28", 200 | "FLMA": "04.141.645/0001-03", 201 | "EURO": "05.437.916/0001-27", 202 | "FIGS": "17.590.518/0001-25", 203 | "ABCP": "01.201.140/0001-90", 204 | "GTWR": "23.740.527/0001-58", 205 | "HBTT": "26.846.202/0001-42", 206 | "HUSC": "28.851.767/0001-43", 207 | "FIIB": "14.217.108/0001-45", 208 | "FINF": "18.369.510/0001-04", 209 | "FMOF": "01.633.741/0001-72", 210 | "MBRF": "13.500.306/0001-59", 211 | "MGFF": "29.216.463/0001-77", 212 | "NPAR": "24.814.916/0001-43", 213 | "PABY": "00.613.094/0001-74", 214 | "FPNG": "17.161.979/0001-82", 215 | "VPSI": "14.721.889/0001-00", 216 | "FPAB": "03.251.720/0001-18", 217 | "RBRY": "30.166.700/0001-11", 218 | "RBRP": "21.408.063/0001-51", 219 | "RCRB": "03.683.056/0001-86", 220 | "RBED": "13.873.457/0001-52", 221 | "RBVA": "15.576.907/0001-70", 222 | "RNGO": "15.006.286/0001-90", 223 | "SFND": "09.350.920/0001-04", 224 | "FISC": "12.804.013/0001-00", 225 | "SCPF": "01.657.856/0001-05", 226 | "SDIL": "16.671.412/0001-93", 227 | "SHPH": "03.507.519/0001-59", 228 | "TGAR": "25.032.881/0001-53", 229 | "ONEF": "12.948.291/0001-23", 230 | "TOUR": "30.578.316/0001-26", 231 | "FVBI": "13.022.993/0001-44", 232 | "VERE": "08.693.497/0001-82", 233 | "FVPQ": "00.332.266/0001-31", 234 | "FIVN": "17.854.016/0001-64", 235 | "VTLT": "27.368.600/0001-63", 236 | "VSHO": "23.740.595/0001-17", 237 | "IBFF": "33.721.517/0001-29", 238 | "PLCR": "32.527.683/0001-26", 239 | "CVBI": "28.729.197/0001-13", 240 | "MCCI": "23.648.935/0001-84", 241 | "ARRI": "32.006.821/0001-21", 242 | "HOSI": "34.081.631/0001-02", 243 | "IRDM": "28.830.325/0001-10", 244 | "KFOF": "30.091.444/0001-40", 245 | "OUCY": "28.516.650/0001-03", 246 | "GSFI": "11.769.604/0001-13", 247 | "GGRC": "26.614.291/0001-00", 248 | "RCFA": "27.771.586/0001-44", 249 | "HABT": "30.578.417/0001-05", 250 | "ATCR": "14.631.148/0001-39", 251 | "HCTR": "30.248.180/0001-96", 252 | "ATSA": "12.809.972/0001-00", 253 | "HGBS": "08.431.747/0001-06", 254 | "HLOG": "27.486.542/0001-72", 255 | "HRDF": "16.929.519/0001-99", 256 | "HPDP": "35.586.415/0001-73", 257 | "HMOC": "14.733.211/0001-48", 258 | "HFOF": "18.307.582/0001-19", 259 | "TFOF": "20.834.884/0001-97", 260 | "HSML": "32.892.018/0001-31", 261 | "BICR": "34.007.109/0001-72", 262 | "RBBV": "16.915.868/0001-51", 263 | "JPPA": "30.982.880/0001-00", 264 | "JPPC": "17.216.625/0001-98", 265 | "JSRE": "13.371.132/0001-71", 266 | "JTPR": "23.876.086/0001-16", 267 | "KNHY": "30.130.708/0001-28", 268 | "KNRE": "14.423.780/0001-97", 269 | "KNIP": "24.960.430/0001-13", 270 | "KNRI": "12.005.956/0001-65", 271 | "KNCR": "16.706.958/0001-32", 272 | "LGCP": "34.598.181/0001-11", 273 | "LUGG": "34.835.191/0001-23", 274 | "DMAC": "30.579.348/0001-46", 275 | "MALL": "26.499.833/0001-32", 276 | "MXRF": "97.521.225/0001-25", 277 | "MFII": "16.915.968/0001-88", 278 | "PRTS": "22.957.521/0001-74", 279 | "SHOP": "22.459.737/0001-00", 280 | "NEWL": "32.527.626/0001-47", 281 | "OUJP": "26.091.656/0001-50", 282 | "ORPD": "19.107.604/0001-60", 283 | "PATC": "30.048.651/0001-12", 284 | "PLRI": "14.080.689/0001-16", 285 | "PORD": "17.156.502/0001-09", 286 | "PBLV": "31.962.875/0001-06", 287 | "QAGR": "32.754.734/0001-52", 288 | "RSPD": "19.249.989/0001-08", 289 | "RBDS": "11.945.604/0001-27", 290 | "RBGS": "13.652.006/0001-95", 291 | "RBCO": "31.894.369/0001-19", 292 | "RBRD": "09.006.914/0001-34", 293 | "RBTS": "29.299.737/0001-39", 294 | "RBRF": "27.529.279/0001-51", 295 | "DOMC": "17.374.696/0001-19", 296 | "RBVO": "15.769.670/0001-44", 297 | "RBFF": "17.329.029/0001-14", 298 | "SAAG": "16.915.840/0001-14", 299 | "SADI": "32.903.521/0001-45", 300 | "SARE": "32.903.702/0001-71", 301 | "FISD": "16.543.270/0001-89", 302 | "WPLZ": "09.326.861/0001-39", 303 | "REIT": "16.841.067/0001-99", 304 | "SPTW": "15.538.445/0001-05", 305 | "SPAF": "18.311.024/0001-27", 306 | "STRX": "11.044.355/0001-07", 307 | "TSNC": "17.007.443/0001-07", 308 | "TCPF": "26.990.011/0001-50", 309 | "XTED": "15.006.267/0001-63", 310 | "TRXF": "28.548.288/0001-52", 311 | "VGIR": "29.852.732/0001-91", 312 | "VLJS": "13.842.683/0001-76", 313 | "VILG": "24.853.044/0001-22", 314 | "VINO": "12.516.185/0001-70", 315 | "VISC": "17.554.274/0001-25", 316 | "VOTS": "17.870.926/0001-30", 317 | "XPCM": "16.802.320/0001-03", 318 | "XPCI": "28.516.301/0001-91", 319 | "XPHT": "18.308.516/0001-63", 320 | "XPIN": "28.516.325/0001-40", 321 | "XPLG": "26.502.794/0001-85", 322 | "XPML": "28.757.546/0001-00", 323 | "XPPR": "30.654.849/0001-40", 324 | "XPSF": "30.983.020/0001-90", 325 | "YCHY": "28.267.696/0001-36", 326 | "ARFI": "14.069.202/0001-02", 327 | "BBFI": "07.000.400/0001-46", 328 | "CPTS": "18.979.895/0001-13", 329 | "DAMT": "26.642.727/0001-66", 330 | "DOVL": "10.522.648/0001-81", 331 | "ANCR": "07.789.135/0001-27", 332 | "BMLC": "14.376.247/0001-11", 333 | "FAMB": "05.562.312/0001-02", 334 | "ELDO": "13.022.994/0001-99", 335 | "SHDP": "07.224.019/0001-60", 336 | "SAIC": "17.311.079/0001-74", 337 | "WTSP": "28.693.595/0001-27", 338 | "BRHT": "15.461.076/0001-91", 339 | "CXCE": "10.991.914/0001-15", 340 | "EDFO": "06.175.262/0001-73", 341 | "GESE": "17.007.528/0001-95", 342 | "OULG": "13.974.819/0001-00", 343 | "LATR": "17.209.378/0001-00", 344 | "LOFT": "19.722.048/0001-31", 345 | "DRIT": "10.456.810/0001-00", 346 | "NVIF": "22.003.469/0001-17", 347 | "FTCE": "01.235.622/0001-61", 348 | "PRSN": "14.056.001/0001-62", 349 | "FIIP": "08.696.175/0001-97", 350 | "RCRI": "26.511.274/0001-39", 351 | "FOFT": "16.875.388/0001-04", 352 | } 353 | STOCKS = { 354 | "AALR3": "42.771.949/0001-35", 355 | "ABCB4": "28.195.667/0001-06", 356 | "ABEV3": "07.526.557/0001-00", 357 | "ADHM3": "10.345.009/0001-98", 358 | "AGRO3": "07.628.528/0001-59", 359 | "ALPA3": "61.079.117/0001-05", 360 | "ALPA4": "61.079.117/0001-05", 361 | "ALSC3": "06.082.980/0001-03", 362 | "ALUP11": "08.364.948/0001-38", 363 | "ALUP3": "08.364.948/0001-38", 364 | "ALUP4": "08.364.948/0001-38", 365 | "AMAR3": "61.189.288/0001-89", 366 | "ANIM3": "09.288.252/0001-32", 367 | "ARZZ3": "16.590.234/0001-76", 368 | "ATOM3": "00.359.742/0001-08", 369 | "AZUL4": "09.305.994/0001-29", 370 | "B3SA3": "09.346.601/0001-25", 371 | "BAUH3": "95.426.862/0001-97", 372 | "BAUH4": "95.426.862/0001-97", 373 | "BBAS3": "00.000.000/0001-91", 374 | "BBDC3": "60.746.948/0001-12", 375 | "BBDC4": "60.746.948/0001-12", 376 | "BBRK3": "08.613.550/0001-98", 377 | "BBSE3": "17.344.597/0001-94", 378 | "BEEF3": "67.620.377/0001-14", 379 | "BIDI4": "18.945.670/0001-46", 380 | "BIDI11": "18.945.670/0001-46", 381 | "BOBR3": "50.564.053/0001-03", 382 | "BOBR4": "50.564.053/0001-03", 383 | "BPAC11": "30.306.294/0001-45", 384 | "BPAC3": "30.306.294/0001-45", 385 | "BPAC5": "30.306.294/0001-45", 386 | "BPAN4": "59.285.411/0001-13", 387 | "BPHA3": "11.395.624/0001-71", 388 | "BRAP3": "03.847.461/0001-92", 389 | "BRAP4": "03.847.461/0001-92", 390 | "BRDT3": "34.274.233/0001-02", 391 | "BRFS3": "01.838.723/0001-27", 392 | "BRIN3": "11.721.921/0001-60", 393 | "BRKM3": "42.150.391/0001-70", 394 | "BRKM5": "42.150.391/0001-70", 395 | "BRKM6": "42.150.391/0001-70", 396 | "BRML3": "06.977.745/0001-91", 397 | "BRPR3": "06.977.751/0001-49", 398 | "BMGB4": "61.186.680/0001-74", 399 | "BRSR3": "92.702.067/0001-96", 400 | "BRSR5": "92.702.067/0001-96", 401 | "BRSR6 ": "92.702.067/0001-96", 402 | "BSEV3": "15.527.906/0001-36", 403 | "BTOW3": "00.776.574/0001-56", 404 | "CAML3": "64.904.295/0001-03", 405 | "CARD3": "01.896.779/0001-38", 406 | "CCRO3": "02.846.056/0001-97", 407 | "CCXC3": "07.950.674/0001-04", 408 | "CEPE3": "10.835.932/0001-08", 409 | "CEPE5": "10.835.932/0001-08", 410 | "CEPE6": "10.835.932/0001-08", 411 | "CESP3": "60.933.603/0001-78", 412 | "CESP5": "60.933.603/0001-78", 413 | "CESP6": "60.933.603/0001-78", 414 | "CGAS3": "61.856.571/0001-17", 415 | "CGAS5": "61.856.571/0001-17", 416 | "CGRA3": "92.012.467/0001-70", 417 | "CGRA4": "92.012.467/0001-70", 418 | "CIEL3": "01.027.058/0001-91", 419 | "CMIG3": "17.155.730/0001-64", 420 | "CMIG4": "17.155.730/0001-64", 421 | "CNTO3": "13.217.485/0001-11", 422 | "COCE3": "07.047.251/0001-70", 423 | "COCE5": "07.047.251/0001-70", 424 | "COCE6": "07.047.251/0001-70", 425 | "CPFE3": "02.429.144/0001-93", 426 | "CREM3": "82.641.325/0001-18", 427 | "CRFB3": "75.315.333/0001-09", 428 | "CSAN3": "50.746.577/0001-15", 429 | "CSMG3": "17.281.106/0001-03", 430 | "CSNA3": "33.042.730/0001-04", 431 | "CEAB3": "45.242.914/0001-05", 432 | "CTKA3": "82.640.558/0001-04", 433 | "CTKA4": "82.640.558/0001-04", 434 | "CTNM3": "22.677.520/0001-76", 435 | "CTNM4": "22.677.520/0001-76", 436 | "CVCB3": "10.760.260/0001-19", 437 | "CYRE3": "73.178.600/0001-18", 438 | "DAGB33": "11.423.623/0001-93", 439 | "DIRR3": "16.614.075/0001-00", 440 | "DMMO3": "08.926.302/0001-05", 441 | "DTEX3": "97.837.181/0001-47", 442 | "ECOR3": "04.149.454/0001-80", 443 | "EGIE3": "02.474.103/0001-19", 444 | "ELEK3": "13.788.120/0001-47", 445 | "ELEK4": "13.788.120/0001-47", 446 | "ELPL3": "61.695.227/0001-93", 447 | "ELET3": "00.001.180/0001-26", 448 | "ELET6": "00.001.180/0001-26", 449 | "EMBR3": "07.689.002/0001-89", 450 | "ENBR3": "03.983.431/0001-03", 451 | "ENEV3": "04.423.567/0001-21", 452 | "ENGI11": "00.864.214/0001-06", 453 | "ENGI3": "00.864.214/0001-06", 454 | "ENGI4": "00.864.214/0001-06", 455 | "EQTL3": "03.220.438/0001-73", 456 | "YDUQ3": "08.807.432/0001-10", 457 | "ESTR3": "61.082.004/0001-50", 458 | "ESTR4": "61.082.004/0001-50", 459 | "ETER3": "61.092.037/0001-81", 460 | "EUCA3": "56.643.018/0001-66", 461 | "EUCA4": "56.643.018/0001-66", 462 | "EVEN3": "43.470.988/0001-65", 463 | "EZTC3": "08.312.229/0001-73", 464 | "FESA3": "15.141.799/0001-03", 465 | "FESA4": "15.141.799/0001-03", 466 | "FHER3": "22.266.175/0001-88", 467 | "TASA3": "92.781.335/0001-02", 468 | "TASA4": "92.781.335/0001-02", 469 | "FJTA3": "92.781.335/0001-02", 470 | "FJTA4": "92.781.335/0001-02", 471 | "FLRY3": "60.840.055/0001-31", 472 | "FRAS3": "88.610.126/0001-29", 473 | "GNDI3": "19.853.511/0001-84", 474 | "HAPV3": "63.554.067/0001-98", 475 | "FRIO3": "04.821.041/0001-08", 476 | "GEPA3": "02.998.301/0001-81", 477 | "GEPA4": "02.998.301/0001-81", 478 | "GFSA3": "01.545.826/0001-07", 479 | "GGBR3": "33.611.500/0001-19", 480 | "GGBR4": "33.611.500/0001-19", 481 | "GOAU3": "92.690.783/0001-09", 482 | "GOAU4": "92.690.783/0001-09", 483 | "GOLL4": "06.164.253/0001-87", 484 | "GRND3": "89.850.341/0001-60", 485 | "GSHP3": "08.764.621/0001-53", 486 | "GUAR3": "08.402.943/0001-52", 487 | "GUAR4": "08.402.943/0001-52", 488 | "HBOR3": "49.263.189/0001-02", 489 | "HGTX3": "78.876.950/0001-71", 490 | "HYPE3": "02.932.074/0001-91", 491 | "HOOT3": "33.200.049/0001-47", 492 | "HOOT4": "33.200.049/0001-47", 493 | "IDNT3": "02.365.069/0001-44", 494 | "IGTA3": "51.218.147/0001-93", 495 | "IRBR3": "33.376.989/0001-91", 496 | "ITSA3": "61.532.644/0001-15", 497 | "ITSA4": "61.532.644/0001-15", 498 | "ITUB3": "60.872.504/0001-23", 499 | "ITUB4": "60.872.504/0001-23", 500 | "JBSS3": "02.916.265/0001-60", 501 | "JHSF3": "08.294.224/0001-65", 502 | "JSLG3": "52.548.435/0001-79", 503 | "KEPL3": "91.983.056/0001-69", 504 | "KLBN11": "89.637.490/0001-45", 505 | "KLBN3": "89.637.490/0001-45", 506 | "KLBN4": "89.637.490/0001-45", 507 | "COGN3": "02.800.026/0001-40", 508 | "KROT3": "02.800.026/0001-40", 509 | "LAME3": "33.014.556/0001-96", 510 | "LAME4": "33.014.556/0001-96", 511 | "LCAM3": "10.215.988/0001-60", 512 | "LEVE3": "60.476.884/0001-87", 513 | "LIGT3": "03.378.521/0001-75", 514 | "LINX3": "06.948.969/0001-75", 515 | "LLIS3": "49.669.856/0001-43", 516 | "LIQO3": "04.032.433/0001-80", 517 | "LOGG3": "09.041.168/0001-10", 518 | "LOGN3": "42.278.291/0001-24", 519 | "LPSB3": "08.078.847/0001-09", 520 | "LREN3": "92.754.738/0001-62", 521 | "LUPA3": "89.463.822/0001-12", 522 | "MAGG3": "08.684.547/0001-65", 523 | "MDIA3": "07.206.816/0001-15", 524 | "MGLU3": "47.960.950/0001-21", 525 | "MILS3": "27.093.558/0001-15", 526 | "MMXM3": "02.762.115/0001-49", 527 | "MNDL3": "88.610.191/0001-54", 528 | "MOVI3": "21.314.559/0001-66", 529 | "MPLU3": "11.094.546/0001-75", 530 | "MRFG3": "03.853.896/0001-40", 531 | "MRVE3": "08.343.492/0001-20", 532 | "MULT3": "07.816.890/0001-53", 533 | "MYPK3": "61.156.113/0001-75", 534 | "NAFG3": "61.067.161/0001-97", 535 | "NAFG4": "61.067.161/0001-97", 536 | "NATU3": "71.673.990/0001-77", 537 | "ODPV3": "58.119.199/0001-51", 538 | "OFSA3": "20.258.278/0001-70", 539 | "OIBR3": "76.535.764/0001-43", 540 | "OIBR4": "76.535.764/0001-43", 541 | "OSXB3": "09.112.685/0001-32", 542 | "PARD3": "19.378.769/0001-76", 543 | "PCAR3": "47.508.411/0001-56", 544 | "PCAR4": "47.508.411/0001-56", 545 | "PDGR3": "02.950.811/0001-89", 546 | "PETR3": "33.000.167/0001-01", 547 | "PETR4": "33.000.167/0001-01", 548 | "PFRM3": "45.453.214/0001-51", 549 | "PINE3": "62.144.175/0001-20", 550 | "PINE4": "62.144.175/0001-20", 551 | "PMAM3": "60.398.369/0004-79", 552 | "POMO3": "88.611.835/0001-29", 553 | "POMO4": "88.611.835/0001-29", 554 | "POSI3": "81.243.735/0001-48", 555 | "PRIO3": "10.629.105/0001-68", 556 | "PRML3": "08.741.499/0001-08", 557 | "PSSA3": "02.149.205/0001-69", 558 | "QGEP3": "11.669.021/0001-10", 559 | "QUAL3": "11.992.680/0001-93", 560 | "RADL3": "61.585.865/0001-51", 561 | "RAIL3": "02.387.241/0001-60", 562 | "RAPT3": "89.086.144/0001-16", 563 | "RAPT4": "89.086.144/0001-16", 564 | "RCSL3": "91.333.666/0001-17", 565 | "RCSL4": "91.333.666/0001-17", 566 | "REDE3": "61.584.140/0001-49", 567 | "RENT3": "16.670.085/0001-55", 568 | "RNEW11": "08.534.605/0001-74", 569 | "RNEW3": "08.534.605/0001-74", 570 | "RNEW4": "08.534.605/0001-74", 571 | "ROMI3": "56.720.428/0001-63", 572 | "RPMG3": "33.412.081/0001-96", 573 | "RSID3": "61.065.751/0001-80", 574 | "SANB11": "90.400.888/0001-42", 575 | "SANB3": "90.400.888/0001-42", 576 | "SANB4": "90.400.888/0001-42", 577 | "SAPR11": "76.484.013/0001-45", 578 | "SAPR3": "76.484.013/0001-45", 579 | "SAPR4": "76.484.013/0001-45", 580 | "SBSP3": "43.776.517/0001-80", 581 | "SCAR3": "29.780.061/0001-09", 582 | "SEDU3": "02.541.982/0001-54", 583 | "SEER3": "04.986.320/0001-13", 584 | "SGPS3": "07.718.269/0001-57", 585 | "SHOW3": "02.860.694/0001-62", 586 | "SHUL3": "84.693.183/0001-68", 587 | "SHUL4": "84.693.183/0001-68", 588 | "SLCE3": "89.096.457/0001-55", 589 | "SLED3": "60.500.139/0001-26", 590 | "SLED4": "60.500.139/0001-26", 591 | "SMLS3": "05.730.375/0001-20", 592 | "SMTO3": "51.466.860/0001-56", 593 | "SQIA3": "04.065.791/0001-99", 594 | "SNSL3": "04.065.791/0001-99", 595 | "SSBR3": "05.878.397/0001-32", 596 | "STBP3": "02.762.121/0001-04", 597 | "SULA11": "29.978.814/0001-87", 598 | "SULA3": "29.978.814/0001-87", 599 | "SULA4": "29.978.814/0001-87", 600 | "SUZB3": "16.404.287/0001-55", 601 | "TAEE11": "07.859.971/0001-30", 602 | "TAEE3": "07.859.971/0001-30", 603 | "TAEE4": "07.859.971/0001-30", 604 | "TCNO3": "33.111.246/0001-90", 605 | "TCNO4": "33.111.246/0001-90", 606 | "TCSA3": "08.065.557/0001-12", 607 | "TECN3": "09.295.063/0001-97", 608 | "TEKA3": "82.636.986/0001-55", 609 | "TEKA4": "82.636.986/0001-55", 610 | "TEND3": "71.476.527/0001-35", 611 | "TGMA3": "02.351.144/0001-18", 612 | "TIET11": "04.128.563/0001-10", 613 | "TIET3": "04.128.563/0001-10", 614 | "TIET4": "04.128.563/0001-10", 615 | "TIMP3": "02.558.115/0001-21", 616 | "TOTS3": "53.113.791/0001-22", 617 | "TOYB3": "22.770.366/0001-82", 618 | "TOYB4": "22.770.366/0001-82", 619 | "TPIS3": "03.014.553/0001-91", 620 | "TRIS3": "08.811.643/0001-27", 621 | "TRPL3": "02.998.611/0001-04", 622 | "TRPL4": "02.998.611/0001-04", 623 | "TRPN3": "05.341.549/0001-63", 624 | "TUPY3": "84.683.374/0001-49", 625 | "UCAS3": "90.441.460/0001-48", 626 | "UGPA3": "33.256.439/0001-39", 627 | "UNIP6": "33.958.695/0001-78", 628 | "USIM3": "60.894.730/0001-05", 629 | "USIM5": "60.894.730/0001-05", 630 | "USIM6": "60.894.730/0001-05", 631 | "VALE3": "33.592.510/0001-54", 632 | "VIVA3": "84.453.844/0342-44", 633 | "VIVR3": "67.571.414/0001-41", 634 | "VIVT3": "02.558.157/0001-62", 635 | "VIVT4": "02.558.157/0001-62", 636 | "VLID3": "33.113.309/0001-47", 637 | "VULC3": "50.926.955/0001-42", 638 | "VVAR11": "33.041.260/0652-90", 639 | "VVAR3": "33.041.260/0652-90", 640 | "VVAR4": "33.041.260/0652-90", 641 | "WEGE3": "84.429.695/0001-11", 642 | "WHRL3": "59.105.999/0001-86", 643 | "WHRL4": "59.105.999/0001-86", 644 | "WIZS3": "42.278.473/0001-03", 645 | "WSON33": "05.721.735/0001-28", 646 | "NEOE3": "01.083.200/0001-18", 647 | "TELB3": "00.336.701/0001-04", 648 | "TELB4": "00.336.701/0001-04", 649 | "BEES3": "28.127.603/0001-78", 650 | "BEES4": "28.127.603/0001-78", 651 | "EALT4": "82.643.537/0001-34", 652 | "MEAL3": "17.314.329/0001-20", 653 | "PTNT4": "88.613.658/0001-10", 654 | "JPSA3": "60.543.816/0001-93", 655 | "ENAT3": "11.669.021/0001-10", 656 | "CRPG5": "15.115.504/0001-24", 657 | "BKBR3": "13.574.594/0001-96", 658 | "GBIO33": "19.688.956/0001-56", 659 | "PTBL3": "83.475.913/0001-91", 660 | "ALSO3": "05.878.397/0001-32", 661 | "BMEB4": "17.184.037/0001-10", 662 | "BTTL3": "42.331.462/0001-31", 663 | "FRTA3": "86.550.951/0001-50", 664 | "TESA3": "05.799.312/0001-20", 665 | "MNPR3": "90.076.886/0001-40", 666 | "AZEV4": "61.351.532/0001-68", 667 | "NTCO3": "32.785.497/0001-97", 668 | } 669 | 670 | CNPJ_INSTITUTIONS = { 671 | "39": "74.014.747/0001-35", 672 | "4": "62.178.421/0001-64", 673 | "226": "17.312.661/0001-55", 674 | "147": "33.775.974/0001-04", 675 | "1982": "30.723.886/0001-62", 676 | "172": "93.026.847/0001-26", 677 | "72": "61.855.045/0001-32", 678 | "120": "05.816.451/0001-15", 679 | "85": "43.815.158/0001-22", 680 | "308": "02.332.886/0011-78", 681 | "88": "02.685.483/0001-30", 682 | "234": "09.512.542/0001-18", 683 | "74": "00.336.036/0001-40", 684 | "45": "42.584.318/0001-07", 685 | "90": "62.169.875/0001-79", 686 | "174": "28.048.783/0001-00", 687 | "131": "63.062.749/0001-83", 688 | "173": "27.652.684/0001-62", 689 | "186": "92.858.380/0001-18", 690 | "15": "65.913.436/0001-17", 691 | "115": "01.788.147/0001-50", 692 | "54": "33.894.445/0001-11", 693 | "735": "09.105.360/0001-22", 694 | "1099": "18.945.670/0001-46", 695 | "114": "61.194.353/0001-64", 696 | "16": "32.588.139/0001-94", 697 | "106": "16.683.062/0001-85", 698 | "13": "02.670.590/0001-95", 699 | "262": "12.392.983/0001-38", 700 | "40": "04.323.351/0001-94", 701 | "23": "52.904.364/0001-08", 702 | "93": "04.257.795/0001-79", 703 | "63": "43.060.029/0001-71", 704 | "129": "00.806.535/0001-54", 705 | "386": "02.332.886/0016-82", 706 | "3762": "42.066.258/0001-30", 707 | "59": "60.783.503/0001-02", 708 | "27": "51.014.223/0001-49", 709 | "187": "17.315.359/0001-50", 710 | "58": "62.285.390/0001-40", 711 | "177": "68.757.681/0001-70", 712 | "10": "61.739.629/0001-42", 713 | "107": "03.751.794/0001-13", 714 | "4090": "29.162.769/0001-98", 715 | "37": "33.968.066/0001-29", 716 | "29": "28.156.214/0001-70", 717 | "21": "03.384.738/0001-98", 718 | "3": "02.332.886/0001-04", 719 | } 720 | 721 | 722 | def get_asset_info(code: str) -> AssetInfo: 723 | """Return asset info.""" 724 | if code in STOCKS: 725 | return AssetInfo("STOCKS", STOCKS[code]) 726 | # STOCKS CAN END IN F 727 | elif code.endswith("F") and code[:-1] in STOCKS: 728 | code = code[:-1] 729 | return AssetInfo("STOCKS", STOCKS[code]) 730 | # ETF and FII code can end in 11 or 11B 731 | if len(code) == 6 and code.endswith("11"): 732 | code = code[:-2] 733 | elif len(code) == 7 and code.endswith("11B"): 734 | code = code[:-3] 735 | if code in FIIS: 736 | return AssetInfo("FII", FIIS[code]) 737 | if code in ETFS: 738 | return AssetInfo("ETF", ETFS[code]) 739 | return AssetInfo("NOT_FOUND", "") 740 | 741 | 742 | def get_liquidacao_rates(dates: list[datetime.datetime]) -> list[float]: 743 | """Get the list of liquidação rates.""" 744 | rates = [] 745 | last_period = 0 746 | for date in dates: 747 | for idx_period, period in enumerate( 748 | LIQUIDACAO_PERIODS[last_period:], start=last_period 749 | ): 750 | if period.start_date <= date <= period.end_date: 751 | last_period = idx_period 752 | rates.append(period.rate) 753 | break 754 | else: 755 | sys.exit( 756 | f"Nenhum período de liquidação encontrado para a data: {date}" 757 | ) 758 | return rates 759 | 760 | 761 | def get_emolumentos_rates( 762 | dates: list[datetime.datetime], auction_trades: list[int] 763 | ) -> list[float]: 764 | """Get the list of emolumentos rates.""" 765 | rates = [] 766 | last_period = 0 767 | for date in dates: 768 | for idx_period, period in enumerate( 769 | EMOLUMENTOS_PERIODS[last_period:], start=last_period 770 | ): 771 | if period.start_date <= date <= period.end_date: 772 | last_period = idx_period 773 | rates.append(period.rate) 774 | break 775 | else: 776 | sys.exit( 777 | f"Nenhum período de emolumentos encontrado para a data: {date}" 778 | ) 779 | for trade in auction_trades: 780 | rates[trade] = EMOLUMENTOS_AUCTION_RATE 781 | return rates 782 | 783 | 784 | def get_cnpj_institution(institution: str) -> str: 785 | """Return CNPJ of institution.""" 786 | b3_code = institution.split(" - ")[0] 787 | if b3_code in CNPJ_INSTITUTIONS: 788 | return CNPJ_INSTITUTIONS[b3_code] 789 | return "não encontrado" 790 | --------------------------------------------------------------------------------