From 5a0de59aba80d4858b06b776bdb4ac214e4d0868 Mon Sep 17 00:00:00 2001 From: Chris Berkhout Date: Fri, 2 Aug 2024 09:47:07 +0200 Subject: [PATCH] coinmarketcap: fix quote output. --- src/pricehist/beanprice/exchangeratehost.py | 4 + src/pricehist/sources/coinmarketcap.py | 7 +- src/pricehist/sources/exchangeratehost.py | 122 ++++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/pricehist/beanprice/exchangeratehost.py create mode 100644 src/pricehist/sources/exchangeratehost.py diff --git a/src/pricehist/beanprice/exchangeratehost.py b/src/pricehist/beanprice/exchangeratehost.py new file mode 100644 index 0000000..ad6525a --- /dev/null +++ b/src/pricehist/beanprice/exchangeratehost.py @@ -0,0 +1,4 @@ +from pricehist import beanprice +from pricehist.sources.exchangeratehost import ExchangeRateHost + +Source = beanprice.source(ExchangeRateHost()) diff --git a/src/pricehist/sources/coinmarketcap.py b/src/pricehist/sources/coinmarketcap.py index f7e89ab..275df76 100644 --- a/src/pricehist/sources/coinmarketcap.py +++ b/src/pricehist/sources/coinmarketcap.py @@ -166,17 +166,18 @@ class CoinMarketCap(BaseSource): def _output_pair(self, base, quote, data): data_base = data["symbol"] + symbols = {i["id"]: (i["symbol"] or i["code"]) for i in self._symbol_data()} + data_quote = None if len(data["quotes"]) > 0: - data_quote = next(iter(data["quotes"][0]["quote"].keys())) + data_quote = symbols[int(data["quotes"][0]["quote"]["name"])] lookup_quote = None if quote.startswith("ID="): - symbols = {i["id"]: (i["symbol"] or i["code"]) for i in self._symbol_data()} lookup_quote = symbols[int(quote[3:])] output_base = data_base - output_quote = lookup_quote or data_quote or quote + output_quote = data_quote or lookup_quote or quote return (output_base, output_quote) diff --git a/src/pricehist/sources/exchangeratehost.py b/src/pricehist/sources/exchangeratehost.py new file mode 100644 index 0000000..76c412a --- /dev/null +++ b/src/pricehist/sources/exchangeratehost.py @@ -0,0 +1,122 @@ +import dataclasses +import json +from decimal import Decimal + +import requests + +from pricehist import exceptions +from pricehist.price import Price + +from .basesource import BaseSource + + +class ExchangeRateHost(BaseSource): + def id(self): + return "exchangeratehost" + + def name(self): + return "exchangerate.host Exchange rates API" + + def description(self): + return ( + "Exchange rates API is a simple and lightweight free service for " + "current and historical foreign exchange rates & crypto exchange " + "rates." + ) + + def source_url(self): + return "https://exchangerate.host/" + + def start(self): + return "1999-01-01" + + def types(self): + return ["close"] + + def notes(self): + return "" + + def symbols(self): + url = "https://api.coindesk.com/v1/bpi/supported-currencies.json" + + try: + response = self.log_curl(requests.get(url)) + except Exception as e: + raise exceptions.RequestError(str(e)) from e + + try: + response.raise_for_status() + except Exception as e: + raise exceptions.BadResponse(str(e)) from e + + try: + data = json.loads(response.content) + relevant = [i for i in data if i["currency"] not in ["BTC", "XBT"]] + results = [ + (f"BTC/{i['currency']}", f"Bitcoin against {i['country']}") + for i in sorted(relevant, key=lambda i: i["currency"]) + ] + except Exception as e: + raise exceptions.ResponseParsingError(str(e)) from e + + if not results: + raise exceptions.ResponseParsingError("Expected data not found") + else: + return results + + def fetch(self, series): + if series.base != "BTC" or series.quote in ["BTC", "XBT"]: + # BTC is the only valid base. + # BTC as the quote will return BTC/USD, which we don't want. + # XBT as the quote will fail with HTTP status 500. + raise exceptions.InvalidPair(series.base, series.quote, self) + + data = self._data(series) + + prices = [] + for (d, v) in data.get("bpi", {}).items(): + prices.append(Price(d, Decimal(str(v)))) + + return dataclasses.replace(series, prices=prices) + + def _data(self, series): + url = "https://api.coindesk.com/v1/bpi/historical/close.json" + params = { + "currency": series.quote, + "start": series.start, + "end": series.end, + } + + try: + response = self.log_curl(requests.get(url, params=params)) + except Exception as e: + raise exceptions.RequestError(str(e)) from e + + code = response.status_code + text = response.text + if code == 404 and "currency was not found" in text: + raise exceptions.InvalidPair(series.base, series.quote, self) + elif code == 404 and "only covers data from" in text: + raise exceptions.BadResponse(text) + elif code == 404 and "end date is before" in text and series.end < series.start: + raise exceptions.BadResponse("End date is before start date.") + elif code == 404 and "end date is before" in text: + raise exceptions.BadResponse("The start date must be in the past.") + elif code == 500 and "No results returned from database" in text: + raise exceptions.BadResponse( + "No results returned from database. This can happen when data " + "for a valid quote currency (e.g. CUP) doesn't go all the way " + "back to the start date, and potentially for other reasons." + ) + else: + try: + response.raise_for_status() + except Exception as e: + raise exceptions.BadResponse(str(e)) from e + + try: + result = json.loads(response.content) + except Exception as e: + raise exceptions.ResponseParsingError(str(e)) from e + + return result