Skip to content

Commit

Permalink
Add variants that retrieve price information in real time
Browse files Browse the repository at this point in the history
- Add `mod_investpy_pme` module with `investpy_pme` and
  `investpy_verbose_pme` using `investpy` module, which uses
  Investing.com as the data source.
- Also, fix a couple of issues with hypothesis testing and making sure
  the input dates are sorted.
  • Loading branch information
ymyke committed Mar 13, 2022
2 parents 1cf75c8 + 0c8cee8 commit 885e6e6
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 6 deletions.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion pypme/__init__.py
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions pypme/mod_investpy_pme.py
Original file line number Diff line number Diff line change
@@ -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]
7 changes: 5 additions & 2 deletions pypme/pme.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"]
Expand Down
72 changes: 72 additions & 0 deletions tests/test_investpy_pypme.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 10 additions & 2 deletions tests/test_pypme.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


def test_version():
assert __version__ == "0.1.3"
assert __version__ == "0.2.0"


@pytest.mark.parametrize(
Expand Down Expand Up @@ -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())
Expand All @@ -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

0 comments on commit 885e6e6

Please sign in to comment.