diff --git a/README.md b/README.md index c848301..5f1a356 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,45 @@ Notes: case, the IRR is for the underlying period. - `verbose_pme`: Calculate PME for evenly spaced cashflows and return vebose information. +- `investpy_pme` and `investpy_verbose_pme`: Use price information from Investing.com. + See below. + +## Investpy examples -- using investpy to retrieve PME prices online + +Use `investpy_pme` and `investpy_verbose_pme` to use a ticker from Investing.com and +compare with those prices. Like so: + +```python +from datetime import date +from pypme import investpy_pme + +common_args = { + "dates": [date(2012, 1, 1), date(2013, 1, 1)], + "cashflows": [-100], + "prices": [1, 1], +} +print(investpy_pme(pme_ticker="Global X Lithium", pme_type="etf", **common_args)) +print(investpy_pme(pme_ticker="bitcoin", pme_type="crypto", **common_args)) +print(investpy_pme(pme_ticker="SRENH", pme_type="stock", pme_country="switzerland", **common_args)) +``` + +Produces: + +``` +-0.02834024870462727 +1.5031336254547634 +0.3402634808264912 +``` + +The investpy functions take the following parameters: +- `pme_type`: One of `stock`, `etf`, `fund`, `crypto`, `bond`, `index`, `certificate`. + Defaults to `stock`. +- `pme_ticker`: The ticker symbol/name. +- `pme_country`: The ticker's country of residence. Defaults to `united states`. + +Check out [the Investpy project](https://github.com/alvarobartt/investpy) for more +details. + ## Garbage in, garbage out diff --git a/pypme/__init__.py b/pypme/__init__.py index 7024738..65cc1ea 100644 --- a/pypme/__init__.py +++ b/pypme/__init__.py @@ -1,2 +1,3 @@ -__version__ = '0.1.3' +__version__ = '0.2.0' from .pme import verbose_pme, pme, verbose_xpme, xpme +from .mod_investpy_pme import investpy_verbose_pme, investpy_pme \ No newline at end of file diff --git a/pypme/mod_investpy_pme.py b/pypme/mod_investpy_pme.py new file mode 100644 index 0000000..9c896fc --- /dev/null +++ b/pypme/mod_investpy_pme.py @@ -0,0 +1,72 @@ +"""Calculate PME and get the prices from Investing.com via the `investpy` module. + +Important: The Investing API has rate limiting measures in place and will block you if +you hit the API too often. You will notice by getting 429 errors (or maybe +also/alternatively 503). Wait roughly 2 seconds between each consecutive call to the API +via the functions in this module. + +Args: +- pme_type: One of "stock", "etf", "fund", "crypto", "bond", "index", "certificate". + Defaults to "stock". +- pme_ticker: The ticker symbol/name. +- pme_country: The ticker's country of residence. Defaults to "united states". + +Refer to the `pme` module to understand other arguments and what the functions return. +""" + +from typing import List, Tuple +from datetime import date +import pandas as pd +import investpy +from .pme import verbose_xpme + + +def get_historical_data(ticker: str, type: str, **kwargs) -> pd.DataFrame: + """Small wrapper to make the investpy interface accessible in a more unified fashion.""" + kwargs[type] = ticker + if type == "crypto" and "country" in kwargs: + del kwargs["country"] + return getattr(investpy, "get_" + type + "_historical_data")(**kwargs) + + +def investpy_verbose_pme( + dates: List[date], + cashflows: List[float], + prices: List[float], + pme_ticker: str, + pme_type: str = "stock", + pme_country: str = "united states", +) -> Tuple[float, float, pd.DataFrame]: + """Calculate PME return vebose information, retrieving PME price information from + Investing.com in real time. + """ + dates_as_str = [x.strftime("%d/%m/%Y") for x in sorted(dates)] + pmedf = get_historical_data( + pme_ticker, + pme_type, + country=pme_country, + from_date=dates_as_str[0], + to_date=dates_as_str[-1], + ) + # Pick the nearest price if there is no price for an exact date: + pme_prices = [ + pmedf.iloc[pmedf.index.get_indexer([x], method="nearest")[0]]["Close"] + for x in dates_as_str + ] + return verbose_xpme(dates, cashflows, prices, pme_prices) + + +def investpy_pme( + dates: List[date], + cashflows: List[float], + prices: List[float], + pme_ticker: str, + pme_type: str = "stock", + pme_country: str = "united states", +) -> Tuple[float, float, pd.DataFrame]: + """Calculate PME and return the PME IRR only, retrieving PME price information from + Investing.com in real time. + """ + return investpy_verbose_pme( + dates, cashflows, prices, pme_ticker, pme_type, pme_country + )[0] diff --git a/pypme/pme.py b/pypme/pme.py index a6456fe..570554d 100644 --- a/pypme/pme.py +++ b/pypme/pme.py @@ -1,5 +1,7 @@ """Calculate PME (Public Market Equivalent) for both evenly and unevenly spaced -cashflows. Calculation according to +cashflows. + +Calculation according to https://en.wikipedia.org/wiki/Public_Market_Equivalent#Modified_PME Args: @@ -11,7 +13,6 @@ Note: - Both `prices` and `pme_prices` need an additional item at the end for the last interval / point in time, for which the PME is calculated. -- Obviously, all prices must be in the same (implicit) currency. - `cashflows` has one fewer entry than the other lists because the last cashflow is implicitly assumed to be the current NAV at that time. @@ -127,6 +128,8 @@ def verbose_xpme( Requires the points in time as `dates` as an input parameter in addition to the ones required by `pme()`. """ + if dates != sorted(dates): + raise ValueError("Dates must be in order") if len(dates) != len(prices): raise ValueError("Inconsistent input data") df = verbose_pme(cashflows, prices, pme_prices)[2] diff --git a/pyproject.toml b/pyproject.toml index 69c71b5..3daeded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pypme" -version = "0.1.3" +version = "0.2.0" description = "Python library for PME (Public Market Equivalent) calculation" authors = ["ymyke"] license = "MIT" @@ -14,12 +14,14 @@ python = ">=3.8, <3.10" xirr = "^0.1.8" numpy-financial = "^1.0.0" pandas = "^1.4.1" +investpy = "^1.0.8" [tool.poetry.dev-dependencies] pytest = "^5.2" ipykernel = "^6.9.1" black = "^22.1.0" hypothesis = "^6.39.3" +pytest-mock = "^3.7.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/test_investpy_pypme.py b/tests/test_investpy_pypme.py new file mode 100644 index 0000000..ccccfce --- /dev/null +++ b/tests/test_investpy_pypme.py @@ -0,0 +1,72 @@ +import pytest +from datetime import date +import pandas as pd +from pypme.mod_investpy_pme import investpy_pme, investpy_verbose_pme + + +@pytest.mark.parametrize( + "dates, cashflows, prices, pme_timestamps, pme_prices, target_pme_irr, target_asset_irr", + [ + ( + [date(2012, 1, 1), date(2013, 1, 1)], + [-100], + [1, 1], + ["2012-01-01"], + [20], + 0, + # B/c the function will search for the nearest date which is always the same + # one b/c there is only one and therefore produce a PME IRR of 0 + 0, + ), + ( + [date(2012, 1, 1), date(2013, 1, 1)], + [-100], + [1, 1], + ["2012-01-01", "2012-01-02"], + [20, 40], + 99.62, + # In this case, the "nearest" option in `investpy_verbose_pme`'s call to + # `get_indexer` finds the entry at 2012-01-02. Even though it's far away + # from 2013-01-01, it's still the closest. + 0, + ), + ], +) +def test_investpy_pme( + mocker, + dates, + cashflows, + prices, + pme_timestamps, + pme_prices, + target_pme_irr, + target_asset_irr, +): + """Test both the verbose and non-verbose variant at the same to keep things simple. + + Note that this test does _not_ hit the network / investing API since the relevant + function gets mocked. + """ + mocker.patch( + "pypme.mod_investpy_pme.get_historical_data", + return_value=pd.DataFrame( + {"Close": {pd.Timestamp(x): y for x, y in zip(pme_timestamps, pme_prices)}} + ), + ) + pme_irr, asset_irr, df = investpy_verbose_pme( + dates=dates, + cashflows=cashflows, + prices=prices, + pme_ticker="dummy", + ) + assert round(pme_irr * 100.0, 2) == round(target_pme_irr, 2) + assert round(asset_irr * 100.0, 2) == round(target_asset_irr, 2) + assert isinstance(df, pd.DataFrame) + + pme_irr = investpy_pme( + dates=dates, + cashflows=cashflows, + prices=prices, + pme_ticker="dummy", + ) + assert round(pme_irr * 100.0, 2) == round(target_pme_irr, 2) diff --git a/tests/test_pypme.py b/tests/test_pypme.py index 5d40654..78ca23e 100644 --- a/tests/test_pypme.py +++ b/tests/test_pypme.py @@ -7,7 +7,7 @@ def test_version(): - assert __version__ == "0.1.3" + assert __version__ == "0.2.0" @pytest.mark.parametrize( @@ -92,12 +92,18 @@ def test_for_valueerrors(list1, list2, list3, exc_pattern): assert exc_pattern in str(exc) +def test_for_non_sorted_dates(): + with pytest.raises(ValueError) as exc: + xpme([date(2000, 1, 1), date(1900, 1, 1)], [], [], []) + assert "Dates must be in order" in str(exc) + + @st.composite def same_len_lists(draw): n = draw(st.integers(min_value=2, max_value=100)) floatlist = st.lists(st.floats(), min_size=n, max_size=n) datelist = st.lists(st.dates(), min_size=n, max_size=n) - return (draw(datelist), draw(floatlist), draw(floatlist), draw(floatlist)) + return (sorted(draw(datelist)), draw(floatlist), draw(floatlist), draw(floatlist)) @given(same_len_lists()) @@ -109,6 +115,8 @@ def test_xpme_hypothesis_driven(lists): ) except ValueError as exc: assert "least one cashflow" in str(exc) or "All prices" in str(exc) + except OverflowError as exc: + assert "Result too large" in str(exc) else: assert xnpv(df["PME", "CF"], pme_irr) == 0 assert xnpv(df["Asset", "CF"], asset_irr) == 0