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
|
||||
|
||||
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