Repository: beancount/beanprice

Issue:
ratesapi source no longer works
Looks like [exchangerate.host](https://exchangerate.host/) no longer works - they require an API key now and also a the response structure changed. I think it's a similar thing that happened to ratesapi.io back in 2021. Anybody knows an alternative?

Discussion:
@tarioch: The alphavantage one works, but requires a free api key. e.g. CHF:alphavantage/fx:USD:CHF@tarioch: Found a simple alternative which I think has a bit less currencies, but for the main ones it should be good.@mrlimacz: #82 

I went ahead and implemented a source that fetches directly from ECB data

Solution patch:
diff --git a/beanprice/__init__.py b/beanprice/__init__.py
index f943ffc..0fd8ef8 100644
--- a/beanprice/__init__.py
+++ b/beanprice/__init__.py
@@ -88,7 +88,7 @@ Beancount input file:
   some other commodity in the past (i.e., for currencies).
 
 - The list of all Commodity directives present in the file. For each of those
-  holdings, the corresponding Commodity directive is consulted and its "ticker"
+  holdings, the corresponding Commodity directive is consulted and its "price"
   metadata field is used to specify where to attempt to fetch prices. You should
   have directives like this in your input file:
 
diff --git a/beanprice/price.py b/beanprice/price.py
index 38fb04a..8ac3871 100644
--- a/beanprice/price.py
+++ b/beanprice/price.py
@@ -388,8 +388,6 @@ def get_price_jobs_up_to_date(entries,
     # Look at both latest prices and start dates.
     lifetimes_map = lifetimes.get_commodity_lifetimes(entries)
     commodity_map = getters.get_commodity_directives(entries)
-    price_start_dates = {}
-    stale_currencies = set()
 
     if inactive:
         for base_quote in currencies:
diff --git a/beanprice/sources/coincap.py b/beanprice/sources/coincap.py
index 50fb98b..e7081da 100644
--- a/beanprice/sources/coincap.py
+++ b/beanprice/sources/coincap.py
@@ -19,7 +19,7 @@ from typing import List, Optional, Tuple, Dict
 import requests
 from beanprice import source
 
-API_BASE_URL = 'https://api.coincap.io/v2/'
+API_BASE_URL = "https://api.coincap.io/v2/"
 
 
 class CoincapError(ValueError):
@@ -32,10 +32,10 @@ def get_asset_list() -> List[Dict[str, str]]:
     elements with many properties, including "id", representing the Coincap id,
     and "symbol", representing the ticker symbol.
     """
-    path = 'assets/'
+    path = "assets/"
     url = API_BASE_URL + path
     response = requests.get(url)
-    data = response.json()['data']
+    data = response.json()["data"]
     return data
 
 
@@ -46,8 +46,8 @@ def get_currency_id(currency: str) -> Optional[str]:
     """
     # Array is already sorted based on market cap
     for coin in get_asset_list():
-        if coin['symbol'] == currency:
-            return coin['id']
+        if coin["symbol"] == currency:
+            return coin["id"]
     return None
 
 
@@ -60,40 +60,45 @@ def resolve_currency_id(base_currency: str) -> str:
         # Try to find currency ID by its symbol
         base_currency_id = get_currency_id(base_currency)
         if not isinstance(base_currency_id, str):
-            raise CoincapError("Could not find currency id with ticker '"
-                               + base_currency + "'")
+            raise CoincapError(
+                "Could not find currency id with ticker '" + base_currency + "'"
+            )
         return base_currency_id
     else:
         return base_currency
 
 
 def get_latest_price(base_currency: str) -> Tuple[float, float]:
-    path = 'assets/'
+    path = "assets/"
     url = API_BASE_URL + path + resolve_currency_id(base_currency)
     response = requests.get(url)
     data = response.json()
-    timestamp = data['timestamp'] / 1000.0
-    price_float = data['data']['priceUsd']
+    timestamp = data["timestamp"] / 1000.0
+    price_float = data["data"]["priceUsd"]
     return price_float, timestamp
 
 
-def get_price_series(base_currency_id: str, time_begin: datetime.datetime,
-                     time_end: datetime.datetime) -> List[source.SourcePrice]:
-    path = 'assets/{}/history'.format(base_currency_id)
+def get_price_series(
+    base_currency_id: str, time_begin: datetime.datetime, time_end: datetime.datetime
+) -> List[source.SourcePrice]:
+    path = "assets/{}/history".format(base_currency_id)
     params = {
-        'interval': 'd1',
-        'start': str(math.floor(time_begin.timestamp() * 1000.0)),
-        'end': str(math.ceil(time_end.timestamp() * 1000.0))
+        "interval": "d1",
+        "start": str(math.floor(time_begin.timestamp() * 1000.0)),
+        "end": str(math.ceil(time_end.timestamp() * 1000.0)),
     }
     url = API_BASE_URL + path
     response = requests.get(url, params=params)
-    return [source.SourcePrice(
-        Decimal(item['priceUsd']),
-        datetime.datetime.fromtimestamp(
-            item['time'] / 1000.0).replace(tzinfo=datetime.timezone.utc),
-        'USD'
-    )
-        for item in response.json()['data']]
+    return [
+        source.SourcePrice(
+            Decimal(item["priceUsd"]),
+            datetime.datetime.fromtimestamp(item["time"] / 1000.0).replace(
+                tzinfo=datetime.timezone.utc
+            ),
+            "USD",
+        )
+        for item in response.json()["data"]
+    ]
 
 
 class Source(source.Source):
@@ -104,22 +109,25 @@ class Source(source.Source):
     def get_latest_price(self, ticker) -> source.SourcePrice:
         price_float, timestamp = get_latest_price(ticker)
         price = Decimal(price_float)
-        price_time = datetime.datetime.fromtimestamp(timestamp).\
-            replace(tzinfo=datetime.timezone.utc)
-        return source.SourcePrice(price, price_time, 'USD')
-
-    def get_historical_price(self, ticker: str,
-                             time: datetime.datetime) -> Optional[source.SourcePrice]:
-        for datapoint in self.get_prices_series(ticker,
-                                                time +
-                                                datetime.timedelta(days=-1),
-                                                time + datetime.timedelta(days=1)):
-            if not datapoint.time is None and datapoint.time.date() == time.date():
+        price_time = datetime.datetime.fromtimestamp(timestamp).replace(
+            tzinfo=datetime.timezone.utc
+        )
+        return source.SourcePrice(price, price_time, "USD")
+
+    def get_historical_price(
+        self, ticker: str, time: datetime.datetime
+    ) -> Optional[source.SourcePrice]:
+        for datapoint in self.get_prices_series(
+            ticker, time + datetime.timedelta(days=-1), time + datetime.timedelta(days=1)
+        ):
+            # TODO(blais): This is poorly thought out, the date may not match
+            # that in the differing timezone. You probably want the last price
+            # before the datapoint time.
+            if datapoint.time is not None and datapoint.time.date() == time.date():
                 return datapoint
         return None
 
-    def get_prices_series(self, ticker: str,
-                          time_begin: datetime.datetime,
-                          time_end: datetime.datetime
-                          ) -> List[source.SourcePrice]:
+    def get_prices_series(
+        self, ticker: str, time_begin: datetime.datetime, time_end: datetime.datetime
+    ) -> List[source.SourcePrice]:
         return get_price_series(resolve_currency_id(ticker), time_begin, time_end)
diff --git a/beanprice/sources/ratesapi.py b/beanprice/sources/ratesapi.py
index 630abfe..3ee652d 100644
--- a/beanprice/sources/ratesapi.py
+++ b/beanprice/sources/ratesapi.py
@@ -3,11 +3,11 @@
 Valid tickers are in the form "XXX-YYY", such as "EUR-CHF".
 
 Here is the API documentation:
-https://exchangerate.host/
+https://api.frankfurter.app/
 
 For example:
 
-https://api.exchangerate.host/api/latest?base=EUR&symbols=CHF
+https://api.frankfurter.app/latest?base=EUR&symbols=CHF
 
 
 Timezone information: Input and output datetimes are specified via UTC
@@ -48,7 +48,7 @@ def _get_quote(ticker, date):
         'base': base,
         'symbol': symbol,
     }
-    response = requests.get(url='https://api.exchangerate.host/' + date, params=params)
+    response = requests.get(url='https://api.frankfurter.app/' + date, params=params)
 
     if response.status_code != requests.codes.ok:
         raise RatesApiError("Invalid response ({}): {}".format(response.status_code,
diff --git a/beanprice/sources/tsp.py b/beanprice/sources/tsp.py
index 514d5d6..9430529 100644
--- a/beanprice/sources/tsp.py
+++ b/beanprice/sources/tsp.py
@@ -45,7 +45,8 @@ TSP_FUND_NAMES = [
 csv.register_dialect('tsp',
                      delimiter=',',
                      quoting=csv.QUOTE_NONE,
-                     quotechar='',
+                     # NOTE(blais): This fails to import in 3.12 (and perhaps before).
+                     # quotechar='',
                      lineterminator='\n')
 
 class TSPError(ValueError):
@@ -120,7 +121,6 @@ class Source(source.Source):
                     "\n\t".join(TSP_FUND_NAMES)))
 
         url = "https://secure.tsp.gov/components/CORS/getSharePricesRaw.html"
-        fields = ['startdate', 'enddate', 'download', 'Lfunds', 'InvFunds']
         payload = {
             # Grabbing the last fourteen days of data in event the markets were closed.
             'startdate' : (time - datetime.timedelta(days=14)).strftime("%Y%m%d"),
@@ -132,7 +132,7 @@ class Source(source.Source):
 
         response = requests.get(url, params=payload)
         result = parse_response(response)
-        trade_day = list(result.items())[0]
+        trade_day = next(iter(result.items()))
         prices = trade_day[1]
 
         try:
diff --git a/beanprice/sources/yahoo.py b/beanprice/sources/yahoo.py
index b08a2c2..9e327f2 100644
--- a/beanprice/sources/yahoo.py
+++ b/beanprice/sources/yahoo.py
@@ -100,7 +100,8 @@ def get_price_series(ticker: str,
     timestamp_array = result['timestamp']
     close_array = result['indicators']['quote'][0]['close']
     series = [(datetime.fromtimestamp(timestamp, tz=tzone), Decimal(price))
-              for timestamp, price in zip(timestamp_array, close_array)]
+              for timestamp, price in zip(timestamp_array, close_array)
+              if price is not None]
 
     currency = result['meta']['currency']
     return series, currency
@@ -113,8 +114,12 @@ class Source(source.Source):
         """See contract in beanprice.source.Source."""
 
         session = requests.Session()
-        session.headers.update({'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0'})
-        session.get('https://fc.yahoo.com')  # This populates the correct cookies in the session
+        session.headers.update({
+            'User-Agent':
+            'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0'
+        })
+        # This populates the correct cookies in the session
+        session.get('https://fc.yahoo.com')
         crumb = session.get('https://query1.finance.yahoo.com/v1/test/getcrumb').text
 
         url = "https://query1.finance.yahoo.com/v7/finance/quote"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9f411db
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,85 @@
+[build-system]
+requires = ['setuptools']
+build-backend = 'setuptools.build_meta'
+
+[project]
+name = 'beanprice'
+version = '1.2.1'
+description = 'Price quotes fetcher for Beancount'
+license = { file = 'COPYING' }
+readme = 'README.md'
+authors = [
+    { name = 'Martin Blais', email = 'blais@furius.ca' },
+]
+maintainers = [
+    { name = 'Martin Blais', email = 'blais@furius.ca' },
+]
+keywords = [
+    'accounting', 'ledger', 'beancount', 'price'
+]
+classifiers = [
+    'License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
+    'Programming Language :: Python :: 3 :: Only',
+    'Programming Language :: Python :: 3.7',
+    'Programming Language :: Python :: 3.8',
+    'Programming Language :: Python :: 3.9',
+    'Programming Language :: Python :: 3.10',
+    'Programming Language :: Python :: 3.11',
+    'Programming Language :: Python :: 3.12',
+    'Programming Language :: SQL',
+    'Topic :: Office/Business :: Financial :: Accounting',
+]
+requires-python = '>= 3.8'
+dependencies = [
+    'beancount >= 2.3.4',
+    'python-dateutil >= 2.6.0',
+    'requests >= 2.0',
+]
+
+[project.scripts]
+bean-price = 'beanprice.price:main'
+
+[project.urls]
+homepage = 'https://github.com/beancount/beanprice'
+issues = 'https://github.com/beancount/beanprice/issues'
+
+[tool.setuptools.packages]
+find = {}
+
+[tool.coverage.run]
+branch = true
+
+[tool.coverage.report]
+exclude_also = [
+    'if typing.TYPE_CHECKING:',
+]
+
+[tool.ruff]
+line-length = 92
+target-version = 'py38'
+
+[tool.ruff.lint]
+select = ['E', 'F', 'W', 'UP', 'B', 'C4', 'PL', 'RUF']
+
+# TODO(blais): Review these ignores.
+ignore = [
+    'RUF013',
+    'RUF005',
+    'PLW0603',
+    'UP014',
+    'UP031',
+    'B007',
+    'B905',
+    'C408',
+    'E731',
+    'PLR0911',
+    'PLR0912',
+    'PLR0913',
+    'PLR0915',
+    'PLR1714',
+    'PLR2004',
+    'PLW2901',
+    'RUF012',
+    'UP007',
+    'UP032',
+]
diff --git a/setup.py b/setup.py
index 5940e49..b118680 100755
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-""" Install script for beanprice. """
+"""Install script for beanprice."""
 
 __copyright__ = "Copyright (C) 2008-2020  Martin Blais"
 __license__ = "GNU GPLv2"
@@ -7,50 +7,44 @@ __license__ = "GNU GPLv2"
 from setuptools import setup, find_packages
 
 
-setup(name="beanprice",
-      version='1.2.0',
-      description="Price quotes fetcher for Beancount",
-      long_description=
-      """
+setup(
+    name="beanprice",
+    version="1.2.1",
+    description="Price quotes fetcher for Beancount",
+    long_description="""
       A script to fetch market data prices from various sources on the internet
       and render them for plain text accounting price syntax (and Beancount).
       """,
-
-      license="GNU GPLv2 only",
-      author="Martin Blais",
-      author_email="blais@furius.ca",
-      url="http://github.com/beancount/beanprice",
-      download_url="https://github.com/beancount/beanprice",
-      packages=find_packages(),
-
-      install_requires=[
-          # Beancount library itself.
-          'beancount>=2.3.4',
-
-          # Testing support now uses the pytest module.
-          'pytest',
-
-          # This is required to parse dates from command-line options in a
-          # loose, accepting format. Note that we use dateutil for timezone
-          # database definitions as well, although it is inferior to pytz, but
-          # because it can use the OS timezone database in the Windows
-          # registry. See this article for context:
-          # https://www.assert.cc/2014/05/25/which-python-time-zone-library.html
-          # However, for creating offset timezones, we use the datetime.timezone
-          # helper class because it is built-in.
-          # Where this matters is for price source fetchers.
-          # (Note: If pytz supported the Windows registry timezone information,
-          # I would switch to that.)
-          'python-dateutil',
-
-          # This library is needed to make requests for price sources.
-          'requests',
-      ],
-
-      entry_points={
-          'console_scripts': [
-              'bean-price = beanprice.price:main',
-          ]
-      },
-
-      python_requires='>=3.5')
+    license="GNU GPLv2 only",
+    author="Martin Blais",
+    author_email="blais@furius.ca",
+    url="http://github.com/beancount/beanprice",
+    download_url="https://github.com/beancount/beanprice",
+    packages=find_packages(),
+    install_requires=[
+        # Beancount library itself.
+        "beancount>=2.3.4",
+        # Testing support now uses the pytest module.
+        "pytest",
+        # This is required to parse dates from command-line options in a
+        # loose, accepting format. Note that we use dateutil for timezone
+        # database definitions as well, although it is inferior to pytz, but
+        # because it can use the OS timezone database in the Windows
+        # registry. See this article for context:
+        # https://www.assert.cc/2014/05/25/which-python-time-zone-library.html
+        # However, for creating offset timezones, we use the datetime.timezone
+        # helper class because it is built-in.
+        # Where this matters is for price source fetchers.
+        # (Note: If pytz supported the Windows registry timezone information,
+        # I would switch to that.)
+        "python-dateutil",
+        # This library is needed to make requests for price sources.
+        "requests",
+    ],
+    entry_points={
+        "console_scripts": [
+            "bean-price = beanprice.price:main",
+        ]
+    },
+    python_requires=">=3.5",
+)


Test patch:
diff --git a/beanprice/sources/coincap_test.py b/beanprice/sources/coincap_test.py
index 7c93de9..5c42daa 100644
--- a/beanprice/sources/coincap_test.py
+++ b/beanprice/sources/coincap_test.py
@@ -5,6 +5,7 @@ from decimal import Decimal
 from unittest import mock
 from dateutil import tz
 
+import pytest
 import requests
 
 from beanprice import source
@@ -13,51 +14,85 @@ from beanprice.sources import coincap
 timezone = tz.gettz("Europe/Amsterdam")
 
 
-response_assets_bitcoin_historical = {"data": [{"priceUsd": "32263.2648195597839546",
-                                                "time": 1609804800000,
-                                                "date": "2021-01-05T00:00:00.000Z"},
-                                               {"priceUsd": "34869.7692419204775049",
-                                                "time": 1609891200000,
-                                                "date": "2021-01-06T00:00:00.000Z"}],
-                                      "timestamp": 1618220568799}
-
-response_assets_bitcoin = {"data":
-                           {"id": "bitcoin",
-                            "rank": "1",
-                            "symbol": "BTC",
-                            "name": "Bitcoin",
-                            "supply": "18672456.0000000000000000",
-                            "maxSupply": "21000000.0000000000000000",
-                            "marketCapUsd": "1134320211245.9295410753733840",
-                            "volumeUsd24Hr": "16998481452.4370929843940509",
-                            "priceUsd": "60748.3135183678858890",
-                            "changePercent24Hr": "1.3457951950518293",
-                            "vwap24Hr": "59970.0332730340881967",
-                            "explorer": "https://blockchain.info/"
-                            }, "timestamp": 1618218375359}
-
-response_bitcoin_history = {"data":
-                            [{"priceUsd": "29232.6707650537687673",
-                              "time": 1609459200000,
-                              "date": "2021-01-01T00:00:00.000Z"},
-                             {"priceUsd": "30688.0967118388768791",
-                              "time": 1609545600000,
-                              "date": "2021-01-02T00:00:00.000Z"},
-                                {"priceUsd": "33373.7277104175704785",
-                                 "time": 1609632000000,
-                                 "date": "2021-01-03T00:00:00.000Z"},
-                                {"priceUsd": "31832.6862288485383625",
-                                 "time": 1609718400000, "date": "2021-01-04T00:00:00.000Z"},
-                                {"priceUsd": "32263.2648195597839546",
-                                 "time": 1609804800000, "date": "2021-01-05T00:00:00.000Z"},
-                                {"priceUsd": "34869.7692419204775049",
-                                 "time": 1609891200000, "date": "2021-01-06T00:00:00.000Z"},
-                                {"priceUsd": "38041.0026368820979411",
-                                 "time": 1609977600000, "date": "2021-01-07T00:00:00.000Z"},
-                                {"priceUsd": "39821.5432664411153366",
-                                 "time": 1610064000000,
-                                 "date": "2021-01-08T00:00:00.000Z"}
-                             ], "timestamp": 1618219315479}
+response_assets_bitcoin_historical = {
+    "data": [
+        {
+            "priceUsd": "32263.2648195597839546",
+            "time": 1609804800000,
+            "date": "2021-01-05T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "34869.7692419204775049",
+            "time": 1609891200000,
+            "date": "2021-01-06T00:00:00.000Z",
+        },
+    ],
+    "timestamp": 1618220568799,
+}
+
+response_assets_bitcoin = {
+    "data": {
+        "id": "bitcoin",
+        "rank": "1",
+        "symbol": "BTC",
+        "name": "Bitcoin",
+        "supply": "18672456.0000000000000000",
+        "maxSupply": "21000000.0000000000000000",
+        "marketCapUsd": "1134320211245.9295410753733840",
+        "volumeUsd24Hr": "16998481452.4370929843940509",
+        "priceUsd": "60748.3135183678858890",
+        "changePercent24Hr": "1.3457951950518293",
+        "vwap24Hr": "59970.0332730340881967",
+        "explorer": "https://blockchain.info/",
+    },
+    "timestamp": 1618218375359,
+}
+
+response_bitcoin_history = {
+    "data": [
+        {
+            "priceUsd": "29232.6707650537687673",
+            "time": 1609459200000,
+            "date": "2021-01-01T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "30688.0967118388768791",
+            "time": 1609545600000,
+            "date": "2021-01-02T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "33373.7277104175704785",
+            "time": 1609632000000,
+            "date": "2021-01-03T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "31832.6862288485383625",
+            "time": 1609718400000,
+            "date": "2021-01-04T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "32263.2648195597839546",
+            "time": 1609804800000,
+            "date": "2021-01-05T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "34869.7692419204775049",
+            "time": 1609891200000,
+            "date": "2021-01-06T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "38041.0026368820979411",
+            "time": 1609977600000,
+            "date": "2021-01-07T00:00:00.000Z",
+        },
+        {
+            "priceUsd": "39821.5432664411153366",
+            "time": 1610064000000,
+            "date": "2021-01-08T00:00:00.000Z",
+        },
+    ],
+    "timestamp": 1618219315479,
+}
 
 
 def response(content, status_code=requests.codes.ok):
@@ -66,43 +101,48 @@ def response(content, status_code=requests.codes.ok):
     response.status_code = status_code
     response.text = ""
     response.json.return_value = content
-    return mock.patch('requests.get', return_value=response)
+    return mock.patch("requests.get", return_value=response)
 
 
 class Source(unittest.TestCase):
     def test_get_latest_price(self):
         with response(content=response_assets_bitcoin):
-            srcprice = coincap.Source().get_latest_price('bitcoin')
+            srcprice = coincap.Source().get_latest_price("bitcoin")
             self.assertIsInstance(srcprice, source.SourcePrice)
-            self.assertEqual(Decimal('60748.3135183678858890'), srcprice.price)
-            self.assertEqual(datetime.datetime(2021, 4, 12)
-                             .replace(tzinfo=datetime.timezone.utc).date(),
-                             srcprice.time.date())
-            self.assertEqual('USD', srcprice.quote_currency)
-
+            self.assertEqual(Decimal("60748.3135183678858890"), srcprice.price)
+            self.assertEqual(
+                datetime.datetime(2021, 4, 12).replace(tzinfo=datetime.timezone.utc).date(),
+                srcprice.time.date(),
+            )
+            self.assertEqual("USD", srcprice.quote_currency)
+
+    @pytest.mark.skip(reason="Query function should take into account the timezone.")
     def test_get_historical_price(self):
         with response(content=response_assets_bitcoin_historical):
             srcprice = coincap.Source().get_historical_price(
-                'bitcoin', datetime.datetime(2021, 1, 6))
-            self.assertEqual(Decimal('34869.7692419204775049'), srcprice.price)
-            self.assertEqual(datetime.datetime(2021, 1, 6)
-                             .replace(tzinfo=datetime.timezone.utc).date(),
-                             srcprice.time.date())
-            self.assertEqual('USD', srcprice.quote_currency)
-
+                "bitcoin", datetime.datetime(2021, 1, 6)
+            )
+            self.assertEqual(Decimal("34869.7692419204775049"), srcprice.price)
+            self.assertEqual(
+                datetime.datetime(2021, 1, 6).replace(tzinfo=datetime.timezone.utc).date(),
+                srcprice.time.date(),
+            )
+            self.assertEqual("USD", srcprice.quote_currency)
+
+    @pytest.mark.skip(reason="Query function should take into account the timezone.")
     def test_get_prices_series(self):
         with response(content=response_bitcoin_history):
             srcprices = coincap.Source().get_prices_series(
-                'bitcoin', datetime.datetime(2021, 1, 1),
-                datetime.datetime(2021, 3, 20))
+                "bitcoin", datetime.datetime(2021, 1, 1), datetime.datetime(2021, 3, 20)
+            )
             self.assertEqual(len(srcprices), 8)
-            self.assertEqual(Decimal('29232.6707650537687673'),
-                             srcprices[0].price)
-            self.assertEqual(datetime.datetime(2021, 1, 1)
-                             .replace(tzinfo=datetime.timezone.utc).date(),
-                             srcprices[0].time.date())
-            self.assertEqual('USD', srcprices[0].quote_currency)
+            self.assertEqual(Decimal("29232.6707650537687673"), srcprices[0].price)
+            self.assertEqual(
+                datetime.datetime(2021, 1, 1).replace(tzinfo=datetime.timezone.utc).date(),
+                srcprices[0].time.date(),
+            )
+            self.assertEqual("USD", srcprices[0].quote_currency)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()
diff --git a/beanprice/sources/ratesapi_test.py b/beanprice/sources/ratesapi_test.py
index 046af80..96718f4 100644
--- a/beanprice/sources/ratesapi_test.py
+++ b/beanprice/sources/ratesapi_test.py
@@ -23,12 +23,12 @@ def response(contents, status_code=requests.codes.ok):
 class RatesapiPriceFetcher(unittest.TestCase):
 
     def test_error_invalid_ticker(self):
-        with self.assertRaises(ValueError) as exc:
+        with self.assertRaises(ValueError):
             ratesapi.Source().get_latest_price('INVALID')
 
     def test_error_network(self):
         with response('Foobar', 404):
-            with self.assertRaises(ValueError) as exc:
+            with self.assertRaises(ValueError):
                 ratesapi.Source().get_latest_price('EUR-CHF')
 
     def test_valid_response(self):
diff --git a/beanprice/sources/yahoo_test.py b/beanprice/sources/yahoo_test.py
index fc4b8b7..da95133 100644
--- a/beanprice/sources/yahoo_test.py
+++ b/beanprice/sources/yahoo_test.py
@@ -8,6 +8,7 @@ import unittest
 from decimal import Decimal
 from unittest import mock
 
+import pytest
 from dateutil import tz
 import requests
 
@@ -27,9 +28,9 @@ class MockResponse:
 
 
 class YahooFinancePriceFetcher(unittest.TestCase):
-
     def _test_get_latest_price(self):
-        response = MockResponse(textwrap.dedent("""
+        response = MockResponse(
+            textwrap.dedent("""
             {"quoteResponse":
              {"error": null,
               "result": [{"esgPopulated": false,
@@ -48,23 +49,27 @@ class YahooFinancePriceFetcher(unittest.TestCase):
                           "sourceInterval": 15,
                           "symbol": "XSP.TO",
                           "tradeable": false}]}}
-            """))
-        with mock.patch('requests.get', return_value=response):
-            srcprice = yahoo.Source().get_latest_price('XSP.TO')
+            """)
+        )
+        with mock.patch("requests.get", return_value=response):
+            srcprice = yahoo.Source().get_latest_price("XSP.TO")
         self.assertTrue(isinstance(srcprice.price, Decimal))
-        self.assertEqual(Decimal('29.99'), srcprice.price)
-        timezone = datetime.timezone(datetime.timedelta(hours=-4), 'America/Toronto')
-        self.assertEqual(datetime.datetime(2018, 3, 29, 15, 59, 49, tzinfo=timezone),
-                         srcprice.time)
-        self.assertEqual('CAD', srcprice.quote_currency)
+        self.assertEqual(Decimal("29.99"), srcprice.price)
+        timezone = datetime.timezone(datetime.timedelta(hours=-4), "America/Toronto")
+        self.assertEqual(
+            datetime.datetime(2018, 3, 29, 15, 59, 49, tzinfo=timezone), srcprice.time
+        )
+        self.assertEqual("CAD", srcprice.quote_currency)
 
+    @pytest.mark.skip(reason="The mock.patch() call is incorrect, has not been updated.")
     def test_get_latest_price(self):
         for tzname in "America/New_York", "Europe/Berlin", "Asia/Tokyo":
             with date_utils.intimezone(tzname):
                 self._test_get_latest_price()
 
     def _test_get_historical_price(self):
-        response = MockResponse(textwrap.dedent("""
+        response = MockResponse(
+            textwrap.dedent("""
             {"chart":
              {"error": null,
               "result": [{"indicators": {"adjclose": [{"adjclose": [29.236251831054688,
@@ -118,16 +123,19 @@ class YahooFinancePriceFetcher(unittest.TestCase):
                           "timestamp": [1509111000,
                                         1509370200,
                                         1509456600,
-                                        1509543000]}]}}"""))
-        with mock.patch('requests.get', return_value=response):
+                                        1509543000]}]}}""")
+        )
+        with mock.patch("requests.get", return_value=response):
             srcprice = yahoo.Source().get_historical_price(
-                'XSP.TO', datetime.datetime(2017, 11, 1, 16, 0, 0, tzinfo=tz.tzutc()))
+                "XSP.TO", datetime.datetime(2017, 11, 1, 16, 0, 0, tzinfo=tz.tzutc())
+            )
         self.assertTrue(isinstance(srcprice.price, Decimal))
-        self.assertEqual(Decimal('29.469999313354492'), srcprice.price)
-        timezone = datetime.timezone(datetime.timedelta(hours=-4), 'America/Toronto')
-        self.assertEqual(datetime.datetime(2017, 11, 1, 9, 30, tzinfo=timezone),
-                         srcprice.time)
-        self.assertEqual('CAD', srcprice.quote_currency)
+        self.assertEqual(Decimal("29.469999313354492"), srcprice.price)
+        timezone = datetime.timezone(datetime.timedelta(hours=-4), "America/Toronto")
+        self.assertEqual(
+            datetime.datetime(2017, 11, 1, 9, 30, tzinfo=timezone), srcprice.time
+        )
+        self.assertEqual("CAD", srcprice.quote_currency)
 
     def test_get_historical_price(self):
         for tzname in "America/New_York", "Europe/Berlin", "Asia/Tokyo":
@@ -136,32 +144,34 @@ class YahooFinancePriceFetcher(unittest.TestCase):
 
     def test_parse_response_error_status_code(self):
         response = MockResponse(
-            '{"quoteResponse": {"error": "Not supported", "result": [{}]}}',
-            status_code=400)
+            '{"quoteResponse": {"error": "Not supported", "result": [{}]}}', status_code=400
+        )
         with self.assertRaises(yahoo.YahooError):
             yahoo.parse_response(response)
 
     def test_parse_response_error_invalid_format(self):
         response = MockResponse(
             """{"quoteResponse": {"error": null, "result": [{}]},
-             "chart": {"error": null, "result": [{}]}}""")
+             "chart": {"error": null, "result": [{}]}}"""
+        )
         with self.assertRaises(yahoo.YahooError):
             yahoo.parse_response(response)
 
     def test_parse_response_error_not_none(self):
         response = MockResponse(
-            '{"quoteResponse": {"error": "Non-zero error", "result": [{}]}}')
+            '{"quoteResponse": {"error": "Non-zero error", "result": [{}]}}'
+        )
         with self.assertRaises(yahoo.YahooError):
             yahoo.parse_response(response)
 
     def test_parse_response_empty_result(self):
-        response = MockResponse(
-            '{"quoteResponse": {"error": null, "result": []}}')
+        response = MockResponse('{"quoteResponse": {"error": null, "result": []}}')
         with self.assertRaises(yahoo.YahooError):
             yahoo.parse_response(response)
 
     def test_parse_response_no_timestamp(self):
-        response = MockResponse(textwrap.dedent("""
+        response = MockResponse(
+            textwrap.dedent("""
             {"chart":
              {"error": null,
               "result": [{"indicators": {"adjclose": [{}],
@@ -190,12 +200,59 @@ class YahooFinancePriceFetcher(unittest.TestCase):
                                    "timezone": "EDT",
                                    "validRanges": ["1d", "5d", "1mo", "3mo", "6mo", "1y",
                                                    "2y", "5y", "10y", "ytd", "max"]}}]}}
-        """))
+        """)
+        )
         with self.assertRaises(yahoo.YahooError):
-            with mock.patch('requests.get', return_value=response):
-                srcprice = yahoo.Source().get_historical_price(
-                    'XSP.TO', datetime.datetime(2017, 11, 1, 16, 0, 0, tzinfo=tz.tzutc()))
+            with mock.patch("requests.get", return_value=response):
+                _ = yahoo.Source().get_historical_price(
+                    "XSP.TO", datetime.datetime(2017, 11, 1, 16, 0, 0, tzinfo=tz.tzutc())
+                )
+
+    def test_parse_null_prices_in_series(self):
+        response = MockResponse(
+            textwrap.dedent("""
+            {"chart": {"result":[ {"meta":{
+                "currency":"USD","symbol":"FBIIX",
+                "exchangeName":"NAS","instrumentType":"MUTUALFUND",
+                "firstTradeDate":1570714200,"regularMarketTime":1646053572,
+                "gmtoffset":-18000,"timezone":"EST",
+                "exchangeTimezoneName":"America/New_York",
+                "regularMarketPrice":9.69,"chartPreviousClose":9.69,
+                "priceHint":2,
+                "currentTradingPeriod":{
+                    "pre":{"timezone":"EST","start":1646038800,"end":1646058600,"gmtoffset":-18000},
+                    "regular":{"timezone":"EST","start":1646058600,"end":1646082000,"gmtoffset":-18000},
+                    "post":{"timezone":"EST","start":1646082000,"end":1646096400,"gmtoffset":-18000}
+                },
+                "dataGranularity":"1d","range":"",
+                "validRanges":["1mo","3mo","6mo","ytd","1y","2y","5y","10y","max"]},
+                "timestamp":[1645626600,1645713000,1645799400,1646058600],
+                "indicators":{
+                    "quote":[
+                        {"open":[9.6899995803833,9.710000038146973,9.6899995803833,null],
+                        "low":[9.6899995803833,9.710000038146973,9.6899995803833,null],
+                        "high":[9.6899995803833,9.710000038146973,9.6899995803833,null],
+                        "volume":[0,0,0,null],
+                        "close":[9.6899995803833,9.710000038146973,9.6899995803833,null]}
+                    ],"adjclose":[
+                        {"adjclose":[9.6899995803833,9.710000038146973,9.6899995803833,null]}
+                    ]
+                }}],"error":null}}
+        """)
+        )
+
+        with mock.patch("requests.get", return_value=response):
+            srcprice = yahoo.Source().get_historical_price(
+                "XSP.TO", datetime.datetime(2022, 2, 28, 16, 0, 0, tzinfo=tz.tzutc())
+            )
+            self.assertTrue(isinstance(srcprice.price, Decimal))
+            self.assertEqual(Decimal("9.6899995803833"), srcprice.price)
+            timezone = datetime.timezone(datetime.timedelta(hours=-5), "America/New_York")
+            self.assertEqual(
+                datetime.datetime(2022, 2, 25, 9, 30, tzinfo=timezone), srcprice.time
+            )
+            self.assertEqual("USD", srcprice.quote_currency)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()