Skip to content

Commit

Permalink
add portfolio-report.net source
Browse files Browse the repository at this point in the history
  • Loading branch information
bastidest committed Feb 2, 2025
1 parent 5fee4e5 commit 28a3fd5
Show file tree
Hide file tree
Showing 3 changed files with 232 additions and 14 deletions.
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,21 @@ For more detailed guide for price fetching, read <https://beancount.github.io/do
## Price source info
The following price sources are available:

| Name | Module | Provides prices for | Base currency | Latest price? | Historical price? |
|-------------------------|---------------------------|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------|---------------|-------------------|
| Alphavantage | `beanprice.alphavantage` | [Stocks, FX, Crypto](http://alphavantage.co) | Many currencies |||
| Coinbase | `beanprice.coinbase` | [Most common (crypto)currencies](https://api.coinbase.com/v2/exchange-rates) | [Many currencies](https://api.coinbase.com/v2/currencies) |||
| Coincap | `beanprice.coincap` | [Most common (crypto)currencies](https://docs.coincap.io) | USD |||
| Coinmarketcap | `beanprice.coinmarketcap` | [Most common (crypto)currencies](https://coinmarketcap.com/api/documentation/v1/) | Many Currencies |||
| European Central Bank API| `beanprice.ecbrates` | [Many currencies](https://data.ecb.europa.eu/search-results?searchTerm=exchange%20rates) | [Many currencies](https://data.ecb.europa.eu/search-results?searchTerm=exchange%20rates) (Derived from EUR rates)|||
| IEX | `beanprice.iex` | [Trading symbols](https://iextrading.com/trading/eligible-symbols/) | USD || 🚧 (Not yet!) |
| OANDA | `beanprice.oanda` | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) |||
| Quandl | `beanprice.quandl` | [Various datasets](https://www.quandl.com/search) | [Various datasets](https://www.quandl.com/search) |||
| Rates API | `beanprice.ratesapi` | [Many currencies](https://api.exchangerate.host/symbols) | [Many currencies](https://api.exchangerate.host/symbols) |||
| Thrift Savings Plan | `beanprice.tsp` | TSP Funds | USD |||
| Yahoo | `beanprice.yahoo` | Many currencies | Many currencies |||
| EastMoneyFund(天天基金) | `beanprice.eastmoneyfund` | [Chinese Funds](http://fund.eastmoney.com/js/fundcode_search.js) | CNY |||
| Name | Module | Provides prices for | Base currency | Latest price? | Historical price? |
|---------------------------|-----------------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|---------------|-------------------|
| Alphavantage | `beanprice.alphavantage` | [Stocks, FX, Crypto](http://alphavantage.co) | Many currencies |||
| Coinbase | `beanprice.coinbase` | [Most common (crypto)currencies](https://api.coinbase.com/v2/exchange-rates) | [Many currencies](https://api.coinbase.com/v2/currencies) |||
| Coincap | `beanprice.coincap` | [Most common (crypto)currencies](https://docs.coincap.io) | USD |||
| Coinmarketcap | `beanprice.coinmarketcap` | [Most common (crypto)currencies](https://coinmarketcap.com/api/documentation/v1/) | Many Currencies |||
| European Central Bank API | `beanprice.ecbrates` | [Many currencies](https://data.ecb.europa.eu/search-results?searchTerm=exchange%20rates) | [Many currencies](https://data.ecb.europa.eu/search-results?searchTerm=exchange%20rates) (Derived from EUR rates) |||
| IEX | `beanprice.iex` | [Trading symbols](https://iextrading.com/trading/eligible-symbols/) | USD || 🚧 (Not yet!) |
| OANDA | `beanprice.oanda` | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) |||
| Portfolio Report | `beanprice.portfolioreport` | [Various datasets](https://www.portfolio-report.net/search) | Many Currencies |||
| Quandl | `beanprice.quandl` | [Various datasets](https://www.quandl.com/search) | [Various datasets](https://www.quandl.com/search) |||
| Rates API | `beanprice.ratesapi` | [Many currencies](https://api.exchangerate.host/symbols) | [Many currencies](https://api.exchangerate.host/symbols) |||
| Thrift Savings Plan | `beanprice.tsp` | TSP Funds | USD |||
| Yahoo | `beanprice.yahoo` | Many currencies | Many currencies |||
| EastMoneyFund(天天基金) | `beanprice.eastmoneyfund` | [Chinese Funds](http://fund.eastmoney.com/js/fundcode_search.js) | CNY |||


More price sources can be found at [awesome-beancount.com](https://awesome-beancount.com/#price-sources) website.
Expand Down
93 changes: 93 additions & 0 deletions beanprice/sources/portfolioreport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Fetch prices from the https://api.portfolio-report.net API.
The symbols must be referred to by the symbol's UUID and the desired currency in
the format "<UUID>:<CURRENCY>". The symbol's UUIDs can be looked up here:
https://www.portfolio-report.net/search
Timezone information: Input and output datetimes are limited to dates, and I
believe the dates are presumed to be UTC (It's unclear, not documented.)
"""

import datetime
from decimal import Decimal
from typing import List, Tuple

import requests

from beanprice import source


class PortfolioreportError(ValueError):
"An error from the portfolio-report.net API."


def _get_price_series(
ticker: str,
currency: str,
time_begin: datetime.datetime = datetime.datetime.min,
time_end: datetime.datetime = datetime.datetime.max,
) -> List[source.SourcePrice]:
base_url = \
f"https://api.portfolio-report.net/securities/uuid/{ticker}/prices/{currency}"

query = {}
if time_begin != datetime.datetime.min:
query = {
'from': time_begin.date().isoformat(),
}
response = requests.get(base_url, params=query)
if response.status_code != requests.codes.ok:
raise PortfolioreportError(
f"Invalid response ({response.status_code}): {response.text}"
)
response_data = response.json()

def to_decimal(val: float) -> Decimal:
return Decimal(str(val)).quantize(Decimal('0.00000000'))

ret = []
for entry in response_data:
date = datetime.datetime.fromisoformat(entry['date']).date()
if date < time_begin.date() or date > time_end.date():
continue
ret.append(source.SourcePrice(
price=to_decimal(entry['close']),
time=datetime.datetime(
date.year,
date.month,
date.day,
tzinfo=datetime.timezone.utc,
),
quote_currency=currency
))
return ret


def _parse_ticker(ticker: str) -> Tuple[str, str]:
if ticker.count(':') != 1:
raise PortfolioreportError('ticker must be in the format "UUID:CURRENCY"')
parts = ticker.split(':', maxsplit=1)
return parts[0], parts[1]


class Source(source.Source):
def get_latest_price(self, ticker):
uuid, currency = _parse_ticker(ticker)
prices = _get_price_series(uuid, currency)
if len(prices) < 1:
return None
return prices[-1]

def get_historical_price(self, ticker, time):
uuid, currency = _parse_ticker(ticker)
prices = _get_price_series(uuid, currency, time)
if len(prices) < 1:
return None
return prices[0]

def get_prices_series(self, ticker, time_begin, time_end):
uuid, currency = _parse_ticker(ticker)
prices = _get_price_series(uuid, currency, time_begin, time_end)
if len(prices) < 1:
return None
return prices
124 changes: 124 additions & 0 deletions beanprice/sources/portfolioreport_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import datetime
import decimal
import unittest
from decimal import Decimal
from unittest import mock

import requests

from beanprice import source
from beanprice.sources import portfolioreport

SYMBOL_A1JX52_UUID = '9a49754d8fd941bfa1dd2cff1922fe9b:EUR'


def response(contents, status_code=requests.codes.ok):
"""Produce a context manager to patch a JSON response."""
response = mock.Mock()
response.status_code = status_code
response.text = ""
response.json.return_value = contents
return mock.patch("requests.get", return_value=response)


class PortfolioreportPriceFetcher(unittest.TestCase):
def setUp(self):
# reset the Decimal context since other tests override this
decimal.getcontext().prec = 10
decimal.getcontext().rounding = decimal.ROUND_HALF_UP
self.sut = portfolioreport.Source()

def test_error_network(self):
with response(None, 404):
with self.assertRaises(portfolioreport.PortfolioreportError):
self.sut.get_historical_price(
SYMBOL_A1JX52_UUID,
datetime.datetime(2024, 1, 1),
)
with self.assertRaises(portfolioreport.PortfolioreportError):
self.sut.get_latest_price(SYMBOL_A1JX52_UUID)
with self.assertRaises(portfolioreport.PortfolioreportError):
self.sut.get_prices_series(
SYMBOL_A1JX52_UUID,
datetime.datetime(2024, 1, 1),
datetime.datetime(2024, 1, 14),
)

def test_valid_response(self):
contents = [
{"date":"2024-01-02","close":88.37800000},
{"date":"2024-01-03","close":87.99600000},
{"date":"2024-01-04","close":87.89400000},
{"date":"2024-01-05","close":87.72000000},
]
with response(contents):
price_1 = self.sut.get_historical_price(
SYMBOL_A1JX52_UUID,
datetime.datetime(2024, 1, 1),
)
self.assertEqual(
source.SourcePrice(
Decimal('88.37800000'),
datetime.datetime(2024, 1, 2, tzinfo=datetime.timezone.utc),
'EUR',
),
price_1,
)
self.assertTrue(price_1.time.tzinfo)

price_2 = self.sut.get_latest_price(SYMBOL_A1JX52_UUID)
self.assertEqual(
source.SourcePrice(
Decimal('87.72000000'),
datetime.datetime(2024, 1, 5, tzinfo=datetime.timezone.utc),
'EUR',
),
price_2,
)
self.assertTrue(price_2.time.tzinfo)

price_3 = self.sut.get_prices_series(
SYMBOL_A1JX52_UUID,
datetime.datetime(2024, 1, 3),
datetime.datetime(2024, 1, 4),
)
self.assertEqual(
[
source.SourcePrice(
Decimal('87.99600000'),
datetime.datetime(2024, 1, 3, tzinfo=datetime.timezone.utc),
'EUR',
),
source.SourcePrice(
Decimal('87.89400000'),
datetime.datetime(2024, 1, 4, tzinfo=datetime.timezone.utc),
'EUR',
),
],
price_3,
)
self.assertTrue(price_3[0].time.tzinfo)
self.assertTrue(price_3[1].time.tzinfo)

def test_empty_response(self):
contents = []
with response(contents):
price_1 = self.sut.get_historical_price(
SYMBOL_A1JX52_UUID,
datetime.datetime(2024, 1, 1),
)
self.assertIsNone(price_1)

price_2 = self.sut.get_latest_price(SYMBOL_A1JX52_UUID)
self.assertIsNone(price_2)

price_3 = self.sut.get_prices_series(
SYMBOL_A1JX52_UUID,
datetime.datetime(2024, 1, 3),
datetime.datetime(2024, 1, 4),
)
self.assertIsNone(price_3)


if __name__ == "__main__":
unittest.main()

0 comments on commit 28a3fd5

Please sign in to comment.