Browse Source

exchange_rate: more robust spot price against temporary network issues

We poll the fx rate provider every 2.5 minutes (unchanged).
Previously if there was any error during a tick, there was no fx rate
available in the client until the next tick.
Now, instead, we keep the last rates received with a 10 minute expiry.

One potential drawback is that previously there was instant feedback
to the user when e.g. changing proxy settings, and this is no longer
the case. E.g. consider a provider that bans Tor exit nodes. If a user
enables using a Tor proxy in the network settings, the fxrate used to
disappear immediately - but now the cached rate would still be
available.
patch-4
SomberNight 2 years ago
parent
commit
03b514863e
No known key found for this signature in database GPG Key ID: B33B5F232C6271E9
  1. 41
      electrum/exchange_rate.py
  2. 3
      electrum/tests/test_wallet.py

41
electrum/exchange_rate.py

@ -48,12 +48,17 @@ def to_decimal(x: Union[str, float, int, Decimal]) -> Decimal:
return Decimal(str(x))
POLL_PERIOD_SPOT_RATE = 150 # approx. every 2.5 minutes, try to refresh spot price
EXPIRY_SPOT_RATE = 600 # spot price becomes stale after 10 minutes
class ExchangeBase(Logger):
def __init__(self, on_quotes, on_history):
Logger.__init__(self)
self._history = {} # type: Dict[str, Dict[str, str]]
self.quotes = {} # type: Dict[str, Optional[Decimal]]
self._quotes = {} # type: Dict[str, Optional[Decimal]]
self._quotes_timestamp = 0 # type: Union[int, float]
self.on_quotes = on_quotes
self.on_history = on_history
@ -89,16 +94,15 @@ class ExchangeBase(Logger):
async def update_safe(self, ccy: str) -> None:
try:
self.logger.info(f"getting fx quotes for {ccy}")
self.quotes = await self.get_rates(ccy)
assert all(isinstance(rate, (Decimal, type(None))) for rate in self.quotes.values()), \
f"fx rate must be Decimal, got {self.quotes}"
self._quotes = await self.get_rates(ccy)
assert all(isinstance(rate, (Decimal, type(None))) for rate in self._quotes.values()), \
f"fx rate must be Decimal, got {self._quotes}"
self._quotes_timestamp = time.time()
self.logger.info("received fx quotes")
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
self.logger.info(f"failed fx quotes: {repr(e)}")
self.quotes = {}
except Exception as e:
self.logger.exception(f"failed fx quotes: {repr(e)}")
self.quotes = {}
self.on_quotes()
def read_historical_rates(self, ccy: str, cache_dir: str) -> Optional[dict]:
@ -167,6 +171,16 @@ class ExchangeBase(Logger):
rates = await self.get_rates('')
return sorted([str(a) for (a, b) in rates.items() if b is not None and len(a)==3])
def get_cached_spot_quote(self, ccy: str) -> Decimal:
"""Returns the cached exchange rate as a Decimal"""
rate = self._quotes.get(ccy)
if rate is None:
return Decimal('NaN')
if self._quotes_timestamp + EXPIRY_SPOT_RATE < time.time():
# Our rate is stale. Probably better to return no rate than an incorrect one.
return Decimal('NaN')
return Decimal(rate)
class BitcoinAverage(ExchangeBase):
# note: historical rates used to be freely available
@ -429,7 +443,7 @@ class Biscoint(ExchangeBase):
class Walltime(ExchangeBase):
async def get_rates(self, ccy):
json = await self.get_json('s3.amazonaws.com',
json = await self.get_json('s3.amazonaws.com',
'/data-production-walltime-info/production/dynamic/walltime-info.json')
return {'BRL': to_decimal(json['BRL_XBT']['last_inexact'])}
@ -542,9 +556,9 @@ class FxThread(ThreadJob, EventListener):
async def run(self):
while True:
# approx. every 2.5 minutes, refresh spot price
# every few minutes, refresh spot price
try:
async with timeout_after(150):
async with timeout_after(POLL_PERIOD_SPOT_RATE):
await self._trigger.wait()
self._trigger.clear()
# we were manually triggered, so get historical rates
@ -583,7 +597,7 @@ class FxThread(ThreadJob, EventListener):
def set_fiat_address_config(self, b):
self.config.set_key('fiat_address', bool(b))
def get_currency(self):
def get_currency(self) -> str:
'''Use when dynamic fetching is needed'''
return self.config.get("currency", DEFAULT_CURRENCY)
@ -625,10 +639,7 @@ class FxThread(ThreadJob, EventListener):
"""Returns the exchange rate as a Decimal"""
if not self.is_enabled():
return Decimal('NaN')
rate = self.exchange.quotes.get(self.ccy)
if rate is None:
return Decimal('NaN')
return Decimal(rate)
return self.exchange.get_cached_spot_quote(self.ccy)
def format_amount(self, btc_balance, *, timestamp: int = None) -> str:
if timestamp is None:
@ -667,7 +678,7 @@ class FxThread(ThreadJob, EventListener):
# Frequently there is no rate for today, until tomorrow :)
# Use spot quotes in that case
if rate.is_nan() and (datetime.today().date() - d_t.date()).days <= 2:
rate = self.exchange.quotes.get(self.ccy, 'NaN')
rate = self.exchange.get_cached_spot_quote(self.ccy)
self.history_used_spot = True
if rate is None:
rate = 'NaN'

3
electrum/tests/test_wallet.py

@ -89,7 +89,8 @@ class TestWalletStorage(WalletTestCase):
class FakeExchange(ExchangeBase):
def __init__(self, rate):
super().__init__(lambda self: None, lambda self: None)
self.quotes = {'TEST': rate}
self._quotes = {'TEST': rate}
self._quotes_timestamp = float("inf") # spot price from the far future never becomes stale :P
class FakeFxThread:
def __init__(self, exchange):

Loading…
Cancel
Save