SatSale

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 5cde11fc31e0127747189f6e1fad11de4483322c
parent 43fb038eb575f4ae95c39c88e59c962ffbf3b823
Author: Nick <nick@nickfarrow.com>
Date:   Fri, 14 Oct 2022 18:06:30 +1100

Merge pull request #106 from kristapsk/price_feed-refactor

Refactor price_feed and add tests
Diffstat:
Mconfig.py | 5++++-
Mpayments/price_feed.py | 149++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Atest/coindesk_price_data.json | 2++
Atest/coingecko_price_data.json | 2++
Atest/config.toml | 5+++++
Atest/test_price_feed.py | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 183 insertions(+), 60 deletions(-)

diff --git a/config.py b/config.py @@ -8,7 +8,10 @@ for i, arg in enumerate(sys.argv): conf_path = sys.argv[i + 1] break else: - conf_path = "config.toml" + if "pytest" in sys.modules: + conf_path = "test/config.toml" + else: + conf_path = "config.toml" with open(conf_path, "r") as config_file: config = toml.load(config_file) diff --git a/payments/price_feed.py b/payments/price_feed.py @@ -1,76 +1,107 @@ import requests import logging +from abc import ABC, abstractmethod import config -def get_currency_provider(currency, currency_provider): - # Define some currency_provider-specific settings - if currency_provider == "COINDESK": - return { - "price_feed": "https://api.coindesk.com/v1/bpi/currentprice.json", - "result_root": "bpi", - "value_attribute": "rate_float", - "ticker": currency.upper(), - } - else: - return { - "price_feed": "https://api.coingecko.com/api/v3/exchange_rates", - "result_root": "rates", - "value_attribute": "value", - "ticker": currency.lower(), - } +class PriceFeed(ABC): + + def __init__(self, price_feed_url: str = None) -> None: + self._price_data = None + self._price_feed_url = price_feed_url + + @abstractmethod + def _get_rate(self, base_currency: str) -> float: + pass + + def _fetch_price_data(self) -> None: + if self._price_feed_url is not None: + for i in range(config.connection_attempts): + try: + r = requests.get(self._price_feed_url) + self._price_data = r.json() + return + except Exception as e: + logging.error(e) + logging.info( + "Attempting again... {}/{}...".format( + i + 1, config.connection_attempts) + ) + + else: + raise RuntimeError("Failed to reach {}.".format( + self._price_feed_url)) + # used by tests + def set_price_data(self, price_data: dict) -> None: + self._price_data = price_data -def get_price(currency, currency_provider=config.currency_provider, bitcoin_rate_multiplier=config.bitcoin_rate_multiplier): - provider = get_currency_provider(currency, currency_provider) - for i in range(config.connection_attempts): + def _get_btc_exchange_rate(self, base_currency: str, bitcoin_rate_multiplier: float) -> float: + self._fetch_price_data() try: - r = requests.get(provider["price_feed"]) - price_data = r.json() - prices = price_data[provider["result_root"]] - break - - except Exception as e: - logging.error(e) - logging.info( - "Attempting again... {}/{}...".format(i + 1, config.connection_attempts) + rate = self._get_rate(base_currency) + if bitcoin_rate_multiplier != 1.00: + logging.debug( + "Adjusting BTC/{} exchange rate from {} to {} " + + "because of rate multiplier {}.".format( + base_currency, rate, rate * bitcoin_rate_multiplier, + bitcoin_rate_multiplier)) + rate = rate * bitcoin_rate_multiplier + return rate + except Exception: + logging.error( + "Failed to find currency {} from {}.".format( + base_currency, self._price_feed_url) ) + return None - else: - raise ("Failed to reach {}.".format(provider["price_feed"])) - - try: - price = prices[provider["ticker"]][provider["value_attribute"]] - if bitcoin_rate_multiplier != 1.00: - logging.debug("Adjusting BTC price from {} to {} because of rate multiplier {}.".format( - price, price * bitcoin_rate_multiplier, bitcoin_rate_multiplier)) - price = price * bitcoin_rate_multiplier - return price - - except Exception: - logging.error( - "Failed to find currency {} from {}.".format(currency, provider["price_feed"]) - ) - return None + def get_btc_value(self, base_amount: float, base_currency: str) -> float: + if base_currency == "BTC": + return float(base_amount) + elif base_currency == "sats": + return float(base_amount) / 10**8 + exchange_rate = self._get_btc_exchange_rate( + base_currency, config.bitcoin_rate_multiplier) -def get_btc_value(base_amount, currency): - if currency == "BTC": - return float(base_amount) - elif currency == "sats": - return float(base_amount) / 10**8 + if exchange_rate is not None: + try: + float_value = float(base_amount) / exchange_rate + except Exception as e: + logging.error(e) - price = get_price(currency) + return round(float_value, 8) + + raise RuntimeError("Failed to get base currency value.") - if price is not None: - try: - float_value = float(base_amount) / float(price) - if not isinstance(float_value, float): - raise Exception("Fiat value should be a float.") - except Exception as e: - logging.error(e) - return float_value +class CoinDeskPriceFeed(PriceFeed): - raise Exception("Failed to get base currency value.") + def __init__(self, price_feed_url: str = "https://api.coindesk.com/v1/bpi/currentprice.json") -> None: + super().__init__(price_feed_url) + + def _get_rate(self, base_currency: str) -> float: + return float(self._price_data["bpi"][base_currency.upper()]["rate_float"]) + + +class CoinGeckoPriceFeed(PriceFeed): + + def __init__(self, price_feed_url: str = "https://api.coingecko.com/api/v3/exchange_rates") -> None: + super().__init__(price_feed_url) + + def _get_rate(self, base_currency: str) -> float: + return float(self._price_data["rates"][base_currency.lower()]["value"]) + + +def get_btc_value(base_amount: float, base_currency: str) -> float: + if config.currency_provider == "COINDESK": + provider = CoinDeskPriceFeed() + elif config.currency_provider == "COINGECKO": + provider = CoinGeckoPriceFeed() + else: + raise Exception( + "Unsupported exchange rate provider (currency_provider): " + + config.currency_provider + ) + return provider.get_btc_value(base_amount, base_currency) diff --git a/test/coindesk_price_data.json b/test/coindesk_price_data.json @@ -0,0 +1 @@ +{"time":{"updated":"Oct 13, 2022 12:36:00 UTC","updatedISO":"2022-10-13T12:36:00+00:00","updateduk":"Oct 13, 2022 at 13:36 BST"},"disclaimer":"This data was produced from the CoinDesk Bitcoin Price Index (USD). Non-USD currency data converted using hourly conversion rate from openexchangerates.org","chartName":"Bitcoin","bpi":{"USD":{"code":"USD","symbol":"&#36;","rate":"18,379.9154","description":"United States Dollar","rate_float":18379.9154},"GBP":{"code":"GBP","symbol":"&pound;","rate":"15,358.1103","description":"British Pound Sterling","rate_float":15358.1103},"EUR":{"code":"EUR","symbol":"&euro;","rate":"17,904.7211","description":"Euro","rate_float":17904.7211}}} +\ No newline at end of file diff --git a/test/coingecko_price_data.json b/test/coingecko_price_data.json @@ -0,0 +1 @@ +{"rates":{"btc":{"name":"Bitcoin","unit":"BTC","value":1.0,"type":"crypto"},"eth":{"name":"Ether","unit":"ETH","value":14.874,"type":"crypto"},"ltc":{"name":"Litecoin","unit":"LTC","value":374.065,"type":"crypto"},"bch":{"name":"Bitcoin Cash","unit":"BCH","value":175.631,"type":"crypto"},"bnb":{"name":"Binance Coin","unit":"BNB","value":71.593,"type":"crypto"},"eos":{"name":"EOS","unit":"EOS","value":19401.117,"type":"crypto"},"xrp":{"name":"XRP","unit":"XRP","value":41747.379,"type":"crypto"},"xlm":{"name":"Lumens","unit":"XLM","value":171888.576,"type":"crypto"},"link":{"name":"Chainlink","unit":"LINK","value":2827.288,"type":"crypto"},"dot":{"name":"Polkadot","unit":"DOT","value":3204.529,"type":"crypto"},"yfi":{"name":"Yearn.finance","unit":"YFI","value":2.52,"type":"crypto"},"usd":{"name":"US Dollar","unit":"$","value":18980.011,"type":"fiat"},"aed":{"name":"United Arab Emirates Dirham","unit":"DH","value":69713.771,"type":"fiat"},"ars":{"name":"Argentine Peso","unit":"$","value":2866519.321,"type":"fiat"},"aud":{"name":"Australian Dollar","unit":"A$","value":30242.939,"type":"fiat"},"bdt":{"name":"Bangladeshi Taka","unit":"৳","value":1930084.243,"type":"fiat"},"bhd":{"name":"Bahraini Dinar","unit":"BD","value":7155.767,"type":"fiat"},"bmd":{"name":"Bermudian Dollar","unit":"$","value":18980.011,"type":"fiat"},"brl":{"name":"Brazil Real","unit":"R$","value":100464.996,"type":"fiat"},"cad":{"name":"Canadian Dollar","unit":"CA$","value":26228.098,"type":"fiat"},"chf":{"name":"Swiss Franc","unit":"Fr.","value":18976.291,"type":"fiat"},"clp":{"name":"Chilean Peso","unit":"CLP$","value":17927000.314,"type":"fiat"},"cny":{"name":"Chinese Yuan","unit":"¥","value":136553.589,"type":"fiat"},"czk":{"name":"Czech Koruna","unit":"Kč","value":481088.245,"type":"fiat"},"dkk":{"name":"Danish Krone","unit":"kr.","value":145681.077,"type":"fiat"},"eur":{"name":"Euro","unit":"€","value":19582.0,"type":"fiat"},"gbp":{"name":"British Pound Sterling","unit":"£","value":17130.807,"type":"fiat"},"hkd":{"name":"Hong Kong Dollar","unit":"HK$","value":148975.058,"type":"fiat"},"huf":{"name":"Hungarian Forint","unit":"Ft","value":8488225.678,"type":"fiat"},"idr":{"name":"Indonesian Rupiah","unit":"Rp","value":291513045.227,"type":"fiat"},"ils":{"name":"Israeli New Shekel","unit":"₪","value":67962.296,"type":"fiat"},"inr":{"name":"Indian Rupee","unit":"₹","value":1563678.673,"type":"fiat"},"jpy":{"name":"Japanese Yen","unit":"¥","value":2786398.525,"type":"fiat"},"krw":{"name":"South Korean Won","unit":"₩","value":27187333.879,"type":"fiat"},"kwd":{"name":"Kuwaiti Dinar","unit":"KD","value":5892.97,"type":"fiat"},"lkr":{"name":"Sri Lankan Rupee","unit":"Rs","value":6957533.448,"type":"fiat"},"mmk":{"name":"Burmese Kyat","unit":"K","value":39866555.944,"type":"fiat"},"mxn":{"name":"Mexican Peso","unit":"MX$","value":379462.982,"type":"fiat"},"myr":{"name":"Malaysian Ringgit","unit":"RM","value":89044.723,"type":"fiat"},"ngn":{"name":"Nigerian Naira","unit":"₦","value":8381327.047,"type":"fiat"},"nok":{"name":"Norwegian Krone","unit":"kr","value":204340.054,"type":"fiat"},"nzd":{"name":"New Zealand Dollar","unit":"NZ$","value":33852.121,"type":"fiat"},"php":{"name":"Philippine Peso","unit":"₱","value":1119146.821,"type":"fiat"},"pkr":{"name":"Pakistani Rupee","unit":"₨","value":4133789.739,"type":"fiat"},"pln":{"name":"Polish Zloty","unit":"zł","value":94990.059,"type":"fiat"},"rub":{"name":"Russian Ruble","unit":"₽","value":1206654.145,"type":"fiat"},"sar":{"name":"Saudi Riyal","unit":"SR","value":71336.353,"type":"fiat"},"sek":{"name":"Swedish Krona","unit":"kr","value":215842.473,"type":"fiat"},"sgd":{"name":"Singapore Dollar","unit":"S$","value":27249.735,"type":"fiat"},"thb":{"name":"Thai Baht","unit":"฿","value":720243.98,"type":"fiat"},"try":{"name":"Turkish Lira","unit":"₺","value":352815.634,"type":"fiat"},"twd":{"name":"New Taiwan Dollar","unit":"NT$","value":605462.342,"type":"fiat"},"uah":{"name":"Ukrainian hryvnia","unit":"₴","value":701117.823,"type":"fiat"},"vef":{"name":"Venezuelan bolívar fuerte","unit":"Bs.F","value":1900.468,"type":"fiat"},"vnd":{"name":"Vietnamese đồng","unit":"₫","value":457791341.297,"type":"fiat"},"zar":{"name":"South African Rand","unit":"R","value":348477.734,"type":"fiat"},"xdr":{"name":"IMF Special Drawing Rights","unit":"XDR","value":14190.044,"type":"fiat"},"xag":{"name":"Silver - Troy Ounce","unit":"XAG","value":1001.222,"type":"commodity"},"xau":{"name":"Gold - Troy Ounce","unit":"XAU","value":11.376,"type":"commodity"},"bits":{"name":"Bits","unit":"μBTC","value":1000000.0,"type":"crypto"},"sats":{"name":"Satoshi","unit":"sats","value":100000000.0,"type":"crypto"}}} +\ No newline at end of file diff --git a/test/config.toml b/test/config.toml @@ -0,0 +1,5 @@ +# Bare minimum config for tests + +payment_methods = [] + +[satsale] diff --git a/test/test_price_feed.py b/test/test_price_feed.py @@ -0,0 +1,80 @@ +import json +import os +import pytest +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) +from payments.price_feed import \ + CoinDeskPriceFeed, \ + CoinGeckoPriceFeed + +testdir = os.path.dirname(os.path.realpath(__file__)) + + +def read_price_feed_data(filename: str) -> dict: + with open(os.path.join(testdir, filename), "r") as f: + json_data = f.read() + return json.loads(json_data) + + +@pytest.mark.parametrize( + "price_feed", + [ + { + "class": CoinDeskPriceFeed, + "data_file": "coindesk_price_data.json" + }, + { + "class": CoinGeckoPriceFeed, + "data_file": "coingecko_price_data.json" + } + ]) +def test_invalid_base_currency(price_feed: dict) -> None: + provider = price_feed["class"](price_feed_url=None) + provider.set_price_data(read_price_feed_data(price_feed["data_file"])) + with pytest.raises(RuntimeError): + provider.get_btc_value(1, "xxx") + + +@pytest.mark.parametrize( + "price_feed", + [ + {"class": CoinDeskPriceFeed}, + {"class": CoinGeckoPriceFeed} + ]) +def test_btc_btc_price(price_feed: dict) -> None: + provider = price_feed["class"](price_feed_url=None) + assert(provider.get_btc_value(1, "BTC") == 1) + assert(provider.get_btc_value(1000000, "sats") == 0.01) + + +@pytest.mark.parametrize( + "price_feed", + [ + { + "class": CoinDeskPriceFeed, + "data_file": "coindesk_price_data.json", + "conversions": [ + { + "base_value": 100, "base_currency": "USD", "btc_value": 0.00544072 + } + ] + }, + { + "class": CoinGeckoPriceFeed, + "data_file": "coingecko_price_data.json", + "conversions": [ + { + "base_value": 100, "base_currency": "USD", "btc_value": 0.0052687 + } + ] + } + ]) +def test_fiat_btc_price(price_feed: dict) -> None: + provider = price_feed["class"](price_feed_url=None) + provider.set_price_data(read_price_feed_data(price_feed["data_file"])) + for conv in price_feed["conversions"]: + assert( + provider.get_btc_value(conv["base_value"], conv["base_currency"]) == + conv["btc_value"] + )