Support use via bean-price.
This commit is contained in:
parent
1430ce97f7
commit
799aaf37cc
8 changed files with 294 additions and 0 deletions
56
README.md
56
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
|
||||||
|
`<currency>:<module>/[^]<ticker>`. Additional `<module>/[^]<ticker>` parts can
|
||||||
|
be appended, separated by commas.
|
||||||
|
|
||||||
|
The module name will be of the form `pricehist.beanprice.<source_id>`.
|
||||||
|
|
||||||
|
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
|
### Use as a library
|
||||||
|
|
||||||
You may find `pricehist`'s source classes useful in your own scripts.
|
You may find `pricehist`'s source classes useful in your own scripts.
|
||||||
|
|
77
src/pricehist/beanprice/__init__.py
Normal file
77
src/pricehist/beanprice/__init__.py
Normal file
|
@ -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
|
4
src/pricehist/beanprice/alphavantage.py
Normal file
4
src/pricehist/beanprice/alphavantage.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from pricehist import beanprice
|
||||||
|
from pricehist.sources.alphavantage import AlphaVantage
|
||||||
|
|
||||||
|
Source = beanprice.source(AlphaVantage())
|
4
src/pricehist/beanprice/coindesk.py
Normal file
4
src/pricehist/beanprice/coindesk.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from pricehist import beanprice
|
||||||
|
from pricehist.sources.coindesk import CoinDesk
|
||||||
|
|
||||||
|
Source = beanprice.source(CoinDesk())
|
4
src/pricehist/beanprice/coinmarketcap.py
Normal file
4
src/pricehist/beanprice/coinmarketcap.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from pricehist import beanprice
|
||||||
|
from pricehist.sources.coinmarketcap import CoinMarketCap
|
||||||
|
|
||||||
|
Source = beanprice.source(CoinMarketCap())
|
4
src/pricehist/beanprice/ecb.py
Normal file
4
src/pricehist/beanprice/ecb.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from pricehist import beanprice
|
||||||
|
from pricehist.sources.ecb import ECB
|
||||||
|
|
||||||
|
Source = beanprice.source(ECB())
|
4
src/pricehist/beanprice/yahoo.py
Normal file
4
src/pricehist/beanprice/yahoo.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from pricehist import beanprice
|
||||||
|
from pricehist.sources.yahoo import Yahoo
|
||||||
|
|
||||||
|
Source = beanprice.source(Yahoo())
|
141
tests/pricehist/test_beanprice.py
Normal file
141
tests/pricehist/test_beanprice.py
Normal file
|
@ -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()
|
Loading…
Add table
Reference in a new issue