diff --git a/README.md b/README.md index b5095ea..ddc647e 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,62 @@ pricehist fetch coindesk BTC/USD -s 2021-01-01 -e 2021-01-05 -vvv 2>&1 \ } ``` +### Use via `bean-price` + +Beancount users may wish to use `pricehist` sources via `bean-price`. To do so, +ensure the `pricehist` package is installed in an accessible location. + +You can fetch the latest price directly from the command line. + +``` +bean-price -e "USD:pricehist.beanprice.coindesk/BTC:USD" +``` +``` +2021-08-18 price BTC:USD 44725.12 USD +``` + +You can fetch a series of prices by providing a Beancount file as input. + +``` +; input.beancount +2021-08-14 commodity BTC + price: "USD:pricehist.beanprice.coindesk/BTC:USD:close" +``` + +``` +bean-price input.beancount --update --update-rate daily --inactive --clear-cache +``` +``` +2021-08-14 price BTC 47098.2633 USD +2021-08-15 price BTC 47018.9017 USD +2021-08-16 price BTC 45927.405 USD +2021-08-17 price BTC 44686.3333 USD +2021-08-18 price BTC 44725.12 USD +``` + +Adding `-v` will print progress information, `-vv` will print debug information, +including that from `pricehist`. + +A source map specification for `bean-price` has the form +`:/[^]`. Additional `/[^]` parts can +be appended, separated by commas. + +The module name will be of the form `pricehist.beanprice.`. + +The ticker symbol will be of the form `BASE:QUOTE:TYPE`. + +Any non-alphanumeric characters except the equals sign (`=`), hyphen (`-`), +period (`.`), or parentheses (`(` or `)`) are special characters that need to +be encoded as their a two-digit hexadecimal code prefixed with an underscore, +because `bean-price` ticker symbols don't allow all the characters used by +`pricehist` pairs. +[This page](https://replit.com/@chrisberkhout/bpticker) will do it for you. + +For example, the Yahoo! Finance symbol for the Dow Jones Industrial Average is +`^DJI`, and would have the source map specification +`USD:pricehist.beanprice.yahoo/_5eDJI`, or for the daily high price +`USD:pricehist.beanprice.yahoo/_5eDJI::high`. + ### Use as a library You may find `pricehist`'s source classes useful in your own scripts. diff --git a/src/pricehist/beanprice/__init__.py b/src/pricehist/beanprice/__init__.py new file mode 100644 index 0000000..151cfb2 --- /dev/null +++ b/src/pricehist/beanprice/__init__.py @@ -0,0 +1,77 @@ +import re +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from typing import List, NamedTuple, Optional + +from pricehist import exceptions +from pricehist.series import Series + +SourcePrice = NamedTuple( + "SourcePrice", + [ + ("price", Decimal), + ("time", Optional[datetime]), + ("quote_currency", Optional[str]), + ], +) + + +def source(pricehist_source): + class Source: + def get_latest_price(self, ticker: str) -> Optional[SourcePrice]: + time_end = datetime.combine(date.today(), datetime.min.time()) + time_begin = time_end - timedelta(days=7) + prices = self.get_prices_series(ticker, time_begin, time_end) + if prices: + return prices[-1] + else: + return None + + def get_historical_price( + self, ticker: str, time: datetime + ) -> Optional[SourcePrice]: + prices = self.get_prices_series(ticker, time, time) + if prices: + return prices[-1] + else: + return None + + def get_prices_series( + self, + ticker: str, + time_begin: datetime, + time_end: datetime, + ) -> Optional[List[SourcePrice]]: + base, quote, type = self._decode(ticker) + + start = time_begin.date().isoformat() + end = time_end.date().isoformat() + + local_tz = datetime.now(timezone.utc).astimezone().tzinfo + user_tz = time_begin.tzinfo or local_tz + + try: + series = pricehist_source.fetch(Series(base, quote, type, start, end)) + except exceptions.SourceError: + return None + + return [ + SourcePrice( + price.amount, + datetime.fromisoformat(price.date).replace(tzinfo=user_tz), + series.quote, + ) + for price in series.prices + ] + + def _decode(self, ticker): + # https://github.com/beancount/beanprice/blob/b05203/beanprice/price.py#L166 + parts = [ + re.sub(r"_[0-9a-fA-F]{2}", lambda m: chr(int(m.group(0)[1:], 16)), part) + for part in ticker.split(":") + ] + base, quote, candidate_type = (parts + [""] * 3)[0:3] + type = candidate_type or pricehist_source.types()[0] + return (base, quote, type) + + return Source diff --git a/src/pricehist/beanprice/alphavantage.py b/src/pricehist/beanprice/alphavantage.py new file mode 100644 index 0000000..1f17a80 --- /dev/null +++ b/src/pricehist/beanprice/alphavantage.py @@ -0,0 +1,4 @@ +from pricehist import beanprice +from pricehist.sources.alphavantage import AlphaVantage + +Source = beanprice.source(AlphaVantage()) diff --git a/src/pricehist/beanprice/coindesk.py b/src/pricehist/beanprice/coindesk.py new file mode 100644 index 0000000..8936456 --- /dev/null +++ b/src/pricehist/beanprice/coindesk.py @@ -0,0 +1,4 @@ +from pricehist import beanprice +from pricehist.sources.coindesk import CoinDesk + +Source = beanprice.source(CoinDesk()) diff --git a/src/pricehist/beanprice/coinmarketcap.py b/src/pricehist/beanprice/coinmarketcap.py new file mode 100644 index 0000000..ae87a12 --- /dev/null +++ b/src/pricehist/beanprice/coinmarketcap.py @@ -0,0 +1,4 @@ +from pricehist import beanprice +from pricehist.sources.coinmarketcap import CoinMarketCap + +Source = beanprice.source(CoinMarketCap()) diff --git a/src/pricehist/beanprice/ecb.py b/src/pricehist/beanprice/ecb.py new file mode 100644 index 0000000..76109c9 --- /dev/null +++ b/src/pricehist/beanprice/ecb.py @@ -0,0 +1,4 @@ +from pricehist import beanprice +from pricehist.sources.ecb import ECB + +Source = beanprice.source(ECB()) diff --git a/src/pricehist/beanprice/yahoo.py b/src/pricehist/beanprice/yahoo.py new file mode 100644 index 0000000..43d479c --- /dev/null +++ b/src/pricehist/beanprice/yahoo.py @@ -0,0 +1,4 @@ +from pricehist import beanprice +from pricehist.sources.yahoo import Yahoo + +Source = beanprice.source(Yahoo()) diff --git a/tests/pricehist/test_beanprice.py b/tests/pricehist/test_beanprice.py new file mode 100644 index 0000000..9793752 --- /dev/null +++ b/tests/pricehist/test_beanprice.py @@ -0,0 +1,141 @@ +import importlib +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from pricehist import beanprice, exceptions, sources +from pricehist.price import Price +from pricehist.series import Series + + +@pytest.fixture +def series(): + series = Series( + "BTC", + "USD", + "high", + "2021-01-01", + "2021-01-03", + prices=[ + Price("2021-01-01", Decimal("1.1")), + Price("2021-01-02", Decimal("1.2")), + Price("2021-01-03", Decimal("1.3")), + ], + ) + return series + + +@pytest.fixture +def pricehist_source(mocker, series): + mock = mocker.MagicMock() + mock.types = mocker.MagicMock(return_value=["close", "high", "low"]) + mock.fetch = mocker.MagicMock(return_value=series) + return mock + + +@pytest.fixture +def source(pricehist_source): + return beanprice.source(pricehist_source)() + + +@pytest.fixture +def ltz(): + return datetime.now(timezone.utc).astimezone().tzinfo + + +def test_get_prices_series(pricehist_source, source, ltz): + ticker = "BTC:USD:high" + begin = datetime(2021, 1, 1, tzinfo=ltz) + end = datetime(2021, 1, 3, tzinfo=ltz) + result = source.get_prices_series(ticker, begin, end) + + pricehist_source.fetch.assert_called_once_with( + Series("BTC", "USD", "high", "2021-01-01", "2021-01-03") + ) + + assert result == [ + beanprice.SourcePrice(Decimal("1.1"), datetime(2021, 1, 1, tzinfo=ltz), "USD"), + beanprice.SourcePrice(Decimal("1.2"), datetime(2021, 1, 2, tzinfo=ltz), "USD"), + beanprice.SourcePrice(Decimal("1.3"), datetime(2021, 1, 3, tzinfo=ltz), "USD"), + ] + + +def test_get_prices_series_exception(pricehist_source, source, ltz, mocker): + pricehist_source.fetch = mocker.MagicMock( + side_effect=exceptions.RequestError("Message") + ) + ticker = "_5eDJI::low" + begin = datetime(2021, 1, 1, tzinfo=ltz) + end = datetime(2021, 1, 3, tzinfo=ltz) + result = source.get_prices_series(ticker, begin, end) + assert result is None + + +def test_get_prices_series_special_chars(pricehist_source, source, ltz): + ticker = "_5eDJI::low" + begin = datetime(2021, 1, 1, tzinfo=ltz) + end = datetime(2021, 1, 3, tzinfo=ltz) + source.get_prices_series(ticker, begin, end) + pricehist_source.fetch.assert_called_once_with( + Series("^DJI", "", "low", "2021-01-01", "2021-01-03") + ) + + +def test_get_prices_series_price_type(pricehist_source, source, ltz): + ticker = "TSLA" + begin = datetime(2021, 1, 1, tzinfo=ltz) + end = datetime(2021, 1, 3, tzinfo=ltz) + source.get_prices_series(ticker, begin, end) + pricehist_source.fetch.assert_called_once_with( + Series("TSLA", "", "close", "2021-01-01", "2021-01-03") + ) + + +def test_get_historical_price(pricehist_source, source, ltz): + ticker = "BTC:USD:high" + time = datetime(2021, 1, 3, tzinfo=ltz) + result = source.get_historical_price(ticker, time) + pricehist_source.fetch.assert_called_once_with( + Series("BTC", "USD", "high", "2021-01-03", "2021-01-03") + ) + assert result == beanprice.SourcePrice( + Decimal("1.3"), datetime(2021, 1, 3, tzinfo=ltz), "USD" + ) + + +def test_get_historical_price_none_available(pricehist_source, source, ltz, mocker): + pricehist_source.fetch = mocker.MagicMock( + return_value=Series("BTC", "USD", "high", "2021-01-03", "2021-01-03", prices=[]) + ) + ticker = "BTC:USD:high" + time = datetime(2021, 1, 3, tzinfo=ltz) + result = source.get_historical_price(ticker, time) + assert result is None + + +def test_get_latest_price(pricehist_source, source, ltz): + ticker = "BTC:USD:high" + start = datetime.combine((date.today() - timedelta(days=7)), datetime.min.time()) + today = datetime.combine(date.today(), datetime.min.time()) + result = source.get_latest_price(ticker) + pricehist_source.fetch.assert_called_once_with( + Series("BTC", "USD", "high", start.date().isoformat(), today.date().isoformat()) + ) + assert result == beanprice.SourcePrice( + Decimal("1.3"), datetime(2021, 1, 3, tzinfo=ltz), "USD" + ) + + +def test_get_latest_price_none_available(pricehist_source, source, ltz, mocker): + pricehist_source.fetch = mocker.MagicMock( + return_value=Series("BTC", "USD", "high", "2021-01-01", "2021-01-03", prices=[]) + ) + ticker = "BTC:USD:high" + result = source.get_latest_price(ticker) + assert result is None + + +def test_all_sources_available_for_beanprice(): + for identifier in sources.by_id.keys(): + importlib.import_module(f"pricehist.beanprice.{identifier}").Source()