diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml new file mode 100644 index 0000000..eaef844 --- /dev/null +++ b/.github/pr-labeler.yml @@ -0,0 +1,3 @@ +feature: feature/* +bugfix: bugfix/* +chore: chore/* diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml new file mode 100644 index 0000000..a83606f --- /dev/null +++ b/.github/workflows/build-publish.yml @@ -0,0 +1,56 @@ +name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI +on: + push: + release: + types: [published] +jobs: + build-and-publish: + name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@master + with: + fetch-depth: 0 + - uses: actions/setup-python@v1 + with: + python-version: 3.7 + - name: Install flake8 + run: >- + pip install + flake8 + - name: Lint + run: >- + flake8 + - name: Test + run: >- + python setup.py test + - name: Install pep517 + run: >- + python -m + pip install + pep517 + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + pep517.build + --source + --binary + --out-dir dist/ + . + - name: Publish distribution 📦 to Test PyPI + if: startsWith(github.event.ref, 'refs/heads/master') + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.test_pypi_password }} + TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ + run: >- + pip install twine && twine upload dist/* + - name: Publish distribution 📦 to PyPI + if: startsWith(github.event.ref, 'refs/tags') + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.pypi_password }} + TWINE_REPOSITORY_URL: https://upload.pypi.org/legacy/ + run: >- + pip install twine && twine upload dist/* diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..cc5b765 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,13 @@ +name: PR Labeler +on: + pull_request: + types: [opened] + +jobs: + pr-labeler: + runs-on: ubuntu-latest + steps: + - uses: TimonVS/pr-labeler-action@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..a0e9481 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,15 @@ +name: Release Drafter + +on: + push: + branches: + - master + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5.3.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2be14f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Temporary and binary files +*~ +*.py[cod] +*.so +*.cfg +!.isort.cfg +!setup.cfg +*.orig +*.log +*.pot +__pycache__/* +.cache/* +.*.swp +*/.ipynb_checkpoints/* +.DS_Store + +# Project files +.ropeproject +.project +.pydevproject +.settings +.idea +tags + +# Package files +*.egg +*.eggs/ +.installed.cfg +*.egg-info + +# Unittest and coverage +htmlcov/* +.coverage +.tox +junit.xml +coverage.xml +.pytest_cache/ + +# Build and docs folder/files +build/* +dist/* +sdist/* +docs/api/* +docs/_rst/* +docs/_build/* +cover/* +MANIFEST + +# Per-project virtualenvs +.venv*/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d5b658 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Patrick Ruckstuhl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a06fcad --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +prune .github +exclude .gitignore \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..5a939b6 --- /dev/null +++ b/README.rst @@ -0,0 +1,13 @@ +.. image:: https://img.shields.io/pypi/l/xirr.svg + :target: https://pypi.python.org/pypi/xirr +.. image:: https://img.shields.io/pypi/v/xirr.svg + :target: https://pypi.python.org/pypi/xirr + +xirr +============== + +Irregular internal rate of return (xirr) and net present value (npv) calculations. +xirr and xnpv calculation + +Based on https://stackoverflow.com/questions/8919718/financial-python-library-that-has-xirr-and-xnpv-function +with some handling for special cases from https://github.com/RayDeCampo/java-xirr/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9787c3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6d8e9aa --- /dev/null +++ b/setup.cfg @@ -0,0 +1,121 @@ +# This file is used to configure your project. +# Read more about the various options under: +# http://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files + +[metadata] +name = xirr +description = xirr and xnpv calculators +author = Patrick Ruckstuhl +author-email = patrick@ch.tario.org +license = MIT +long-description = file: README.rst +long-description-content-type = text/x-rst; charset=UTF-8 +url = https://github.com/tarioch/xirr/ +# Change if running only on Windows, Mac or Linux (comma-separated) +platforms = any +# Add here all kinds of additional classifiers as defined under +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers = + Development Status :: 3 - Alpha + Programming Language :: Python + Intended Audience :: Developers + Topic :: Office/Business :: Financial + Topic :: Office/Business :: Financial :: Accounting + Topic :: Office/Business :: Financial :: Investment + License :: OSI Approved :: MIT License +[options] +zip_safe = False +packages = find: +include_package_data = True +package_dir = + =src +# DON'T CHANGE THE FOLLOWING LINE! IT WILL BE UPDATED BY PYSCAFFOLD! +setup_requires = pyscaffold>=3.2a0,<3.3a0 +# Add here dependencies of your project (semicolon/line-separated), e.g. +# install_requires = numpy; scipy +install_requires = + scipy +# The usage of test_requires is discouraged, see `Dependency Management` docs +# tests_require = pytest; pytest-cov +# Require a specific Python version, e.g. Python 2.7 or >= 3.4 +# python_requires = >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.* + +[options.packages.find] +where = src +exclude = + tests + +[options.extras_require] +# Add here additional requirements for extra features, to install with: +# `pip install xirr[PDF]` like: +# PDF = ReportLab; RXP +# Add here test requirements (semicolon/line-separated) +testing = + pytest + pytest-cov + flake8 + +[options.entry_points] +# Add here console scripts like: +# console_scripts = +# script_name = xirr.module:function +# For example: +# console_scripts = +# fibonacci = xirr.skeleton:run +# And any other entry points, for example: +# pyscaffold.cli = +# awesome = pyscaffoldext.awesome.extension:AwesomeExtension + +[test] +# py.test options when running `python setup.py test` +# addopts = --verbose +extras = True + +[tool:pytest] +# Options for py.test: +# Specify command line options as you would do when invoking py.test directly. +# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml +# in order to write a coverage file that can be read by Jenkins. +addopts = + --cov xirr --cov-report term-missing + --verbose +norecursedirs = + dist + build + .tox +testpaths = tests + +[aliases] +dists = bdist_wheel + +[bdist_wheel] +# Use this option if your package is pure-python +universal = 1 + +[build_sphinx] +source_dir = docs +build_dir = build/sphinx + +[devpi:upload] +# Options for the devpi: PyPI server and packaging tool +# VCS export must be deactivated since we are using setuptools-scm +no-vcs = 1 +formats = bdist_wheel + +[flake8] +# Some sane defaults for the code style checker flake8 +ignore = E501 +exclude = + .tox + build + dist + .eggs + docs/conf.py + +[pyscaffold] +# PyScaffold's parameters when the project was created. +# This will be used when updating. Do not change! +version = 3.2.3 +package = xirr +extensions = + pyproject diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..99e0060 --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +""" + Setup file for beancounttools. + Use setup.cfg to configure your project. + + This file was generated with PyScaffold 3.2.3. + PyScaffold helps you to put up the scaffold of your new Python project. + Learn more under: https://pyscaffold.org/ +""" +import sys + +from pkg_resources import VersionConflict, require +from setuptools import setup + +try: + require('setuptools>=38.3') +except VersionConflict: + print("Error: version of setuptools is too old (<38.3)!") + sys.exit(1) + + +def local_scheme(version): + return '' + + +if __name__ == "__main__": + setup(use_pyscaffold={"local_scheme": local_scheme}) diff --git a/src/tests/test_math.py b/src/tests/test_math.py new file mode 100644 index 0000000..96197fb --- /dev/null +++ b/src/tests/test_math.py @@ -0,0 +1,9 @@ +import pytest +from datetime import datetime +from xirr.math import xirr + + +@pytest.mark.parametrize("valuesPerDateString,expected", [({'2019-12-31': -80005.8, '2020-03-12': 65209.6}, -0.6453638827)]) +def test_xirr(valuesPerDateString, expected): + valuesPerDate = {datetime.fromisoformat(k).date: v for k, v in valuesPerDateString.items()} + assert xirr(valuesPerDate) == expected diff --git a/src/xirr/__init__.py b/src/xirr/__init__.py new file mode 100644 index 0000000..c99a72a --- /dev/null +++ b/src/xirr/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from pkg_resources import get_distribution, DistributionNotFound + +try: + # Change here if project is renamed and does not equal the package name + dist_name = __name__ + __version__ = get_distribution(dist_name).version +except DistributionNotFound: + __version__ = 'unknown' +finally: + del get_distribution, DistributionNotFound diff --git a/src/xirr/math.py b/src/xirr/math.py new file mode 100644 index 0000000..58754d6 --- /dev/null +++ b/src/xirr/math.py @@ -0,0 +1,67 @@ +import scipy.optimize + +DAYS_PER_YEAR = 365.0 + +# +# based on https://stackoverflow.com/questions/8919718/financial-python-library-that-has-xirr-and-xnpv-function +# with some handling for special cases from +# https://github.com/RayDeCampo/java-xirr/blob/master/src/main/java/org/decampo/xirr/Xirr.java +# + + +def xnpv(valuesPerDate, rate): + '''Calculate the irregular net present value. + + >>> from datetime import date + >>> valuesPerDate = {date(2019, 12, 31): -100, date(2020, 12, 31): 110)} + >>> xnpv(valuesPerDate, -0.10) + 22.2575 + ''' + + if rate == -1.0: + return float('inf') + + t0 = min(valuesPerDate.keys()) + + if rate <= -1.0: + return sum([-abs(vi) / (-1.0 - rate)**((ti - t0).days / DAYS_PER_YEAR) for ti, vi in valuesPerDate.items()]) + + return sum([vi / (1.0 + rate)**((ti - t0).days / DAYS_PER_YEAR) for ti, vi in valuesPerDate.items()]) + + +def xirr(valuesPerDate): + '''Calculate the irregular internal rate of return. + + >>> from datetime import date + >>> valuesPerDate = {date(2019, 12, 31): -80005.8, date(2020, 03, 12): 65209.6)} + >>> xirr(valuesPerDate) + -0.6454 + ''' + if not valuesPerDate: + return None + + result = None + try: + result = scipy.optimize.newton(lambda r: xnpv(valuesPerDate, r), 0) + except (RuntimeError, OverflowError): # Failed to converge? + result = scipy.optimize.brentq(lambda r: xnpv(valuesPerDate, r), -1.0, 1e10) + + if not isinstance(result, complex): + return result + else: + return None + + +def cleanXirr(valuesPerDate): + '''A "cleaned" version of the xirr which avoids returning a xirr for some extreme cases and ignores amounts which are almost 0. + ''' + valuesPerDateCleaned = {} + for date, amount in valuesPerDate.items(): + if round(amount, 2) != 0: + valuesPerDateCleaned[date] = amount + + result = xirr(valuesPerDateCleaned) + if result is not None and (abs(result) >= 100 or round(result, 4) == 0): + return None + else: + return result diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c99a72a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from pkg_resources import get_distribution, DistributionNotFound + +try: + # Change here if project is renamed and does not equal the package name + dist_name = __name__ + __version__ = get_distribution(dist_name).version +except DistributionNotFound: + __version__ = 'unknown' +finally: + del get_distribution, DistributionNotFound diff --git a/tests/test_math.py b/tests/test_math.py new file mode 100644 index 0000000..c03b71e --- /dev/null +++ b/tests/test_math.py @@ -0,0 +1,54 @@ +import pytest +from pytest import approx + +from datetime import datetime +from xirr.math import xirr, cleanXirr, xnpv + + +@pytest.mark.parametrize("valuesPerDateString,expected", [ + ({'2019-12-31': -80005.8, '2020-03-12': 65209.6}, -0.6454), + ({'2020-03-12': 65209.6, '2019-12-31': -80005.8}, -0.6454), + ({'2019-12-31': -100082.76, '2020-03-05': 82671.24}, -0.6581), + ({}, None), + ({'2019-12-31': -0.00001, '2020-03-05': 0.00001}, 0.0), + ({'2019-12-31': -100, '2020-03-05': 100}, 0.0), + ({'2019-12-31': -100, '2020-03-05': 1000}, 412461.6383), +]) +def test_xirr(valuesPerDateString, expected): + valuesPerDate = {datetime.fromisoformat(k).date(): v for k, v in valuesPerDateString.items()} + actual = xirr(valuesPerDate) + if expected: + assert round(actual, 4) == expected + else: + assert actual == expected + + +@pytest.mark.parametrize("valuesPerDateString,expected", [ + ({'2019-12-31': -80005.8, '2020-03-12': 65209.6}, -0.6454), + ({'2020-03-12': 65209.6, '2019-12-31': -80005.8}, -0.6454), + ({'2019-12-31': -100082.76, '2020-03-05': 82671.24}, -0.6581), + ({}, None), + ({'2019-12-31': -0.00001, '2020-03-05': 0.00001}, None), + ({'2019-12-31': -100, '2020-03-05': 100}, None), + ({'2019-12-31': -100, '2020-03-05': 1000}, None), +]) +def test_cleanXirr(valuesPerDateString, expected): + valuesPerDate = {datetime.fromisoformat(k).date(): v for k, v in valuesPerDateString.items()} + actual = cleanXirr(valuesPerDate) + if expected: + assert round(actual, 4) == expected + else: + assert actual == expected + + +@pytest.mark.parametrize("valuesPerDateString,rate,expected", [ + ({'2019-12-31': -100, '2020-12-31': 110}, -1.0, float('inf')), + ({'2019-12-31': -100, '2020-12-31': 110}, -0.10, 22.2575), +]) +def test_xnpv(valuesPerDateString, rate, expected): + valuesPerDate = {datetime.fromisoformat(k).date(): v for k, v in valuesPerDateString.items()} + actual = xnpv(valuesPerDate, rate) + if expected: + assert actual == approx(expected, 0.0001) + else: + assert actual == expected