tMerge pull request #4872 from spesmilo/qt_fiat_fixes - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 6bf48d0506a36846895d8410a4d9f0db54ffb1d6 DIR parent 12c6a4043b4e5054eedf819657a28e30377a6d47 HTML Author: ThomasV <thomasv@electrum.org> Date: Tue, 27 Nov 2018 18:16:05 +0100 Merge pull request #4872 from spesmilo/qt_fiat_fixes qt history view custom fiat input fixes Diffstat: M electrum/exchange_rate.py | 6 +++++- M electrum/gui/qt/history_list.py | 6 ++++-- M electrum/tests/test_wallet.py | 71 +++++++++++++++++++++++++++++++ M electrum/util.py | 27 ++++++--------------------- M electrum/wallet.py | 70 +++++++++++++++++++++---------- 5 files changed, 133 insertions(+), 47 deletions(-) --- DIR diff --git a/electrum/exchange_rate.py b/electrum/exchange_rate.py t@@ -464,9 +464,13 @@ class FxThread(ThreadJob): d = get_exchanges_by_ccy(history) return d.get(ccy, []) + @staticmethod + def remove_thousands_separator(text): + return text.replace(',', '') # FIXME use THOUSAND_SEPARATOR in util + def ccy_amount_str(self, amount, commas): prec = CCY_PRECISIONS.get(self.ccy, 2) - fmt_str = "{:%s.%df}" % ("," if commas else "", max(0, prec)) + fmt_str = "{:%s.%df}" % ("," if commas else "", max(0, prec)) # FIXME use util.THOUSAND_SEPARATOR and util.DECIMAL_POINT try: rounded_amount = round(amount, prec) except decimal.InvalidOperation: DIR diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py t@@ -275,10 +275,11 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): if value and value < 0: item.setForeground(3, red_brush) item.setForeground(4, red_brush) - if fiat_value and not tx_item['fiat_default']: + if fiat_value is not None and not tx_item['fiat_default']: item.setForeground(6, blue_brush) if tx_hash: item.setData(0, Qt.UserRole, tx_hash) + item.setData(0, Qt.UserRole+1, value) self.insertTopLevelItem(0, item) if current_tx == tx_hash: self.setCurrentItem(item) t@@ -286,6 +287,7 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): def on_edited(self, item, column, prior): '''Called only when the text actually changes''' key = item.data(0, Qt.UserRole) + value = item.data(0, Qt.UserRole+1) text = item.text(column) # fixme if column == 3: t@@ -293,7 +295,7 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): self.update_labels() self.parent.update_completions() elif column == 6: - self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text) + self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, value) self.on_update() def on_doubleclick(self, item, column): DIR diff --git a/electrum/tests/test_wallet.py b/electrum/tests/test_wallet.py t@@ -3,9 +3,16 @@ import tempfile import sys import os import json +from decimal import Decimal +from unittest import TestCase +import time from io import StringIO from electrum.storage import WalletStorage, FINAL_SEED_VERSION +from electrum.wallet import Abstract_Wallet +from electrum.exchange_rate import ExchangeBase, FxThread +from electrum.util import TxMinedStatus +from electrum.bitcoin import COIN from . import SequentialTestCase t@@ -68,3 +75,67 @@ class TestWalletStorage(WalletTestCase): with open(self.wallet_path, "r") as f: contents = f.read() self.assertEqual(some_dict, json.loads(contents)) + +class FakeExchange(ExchangeBase): + def __init__(self, rate): + super().__init__(lambda self: None, lambda self: None) + self.quotes = {'TEST': rate} + +class FakeFxThread: + def __init__(self, exchange): + self.exchange = exchange + self.ccy = 'TEST' + + remove_thousands_separator = staticmethod(FxThread.remove_thousands_separator) + timestamp_rate = FxThread.timestamp_rate + ccy_amount_str = FxThread.ccy_amount_str + history_rate = FxThread.history_rate + +class FakeWallet: + def __init__(self, fiat_value): + super().__init__() + self.fiat_value = fiat_value + self.transactions = self.verified_tx = {'abc': 'Tx'} + + def get_tx_height(self, txid): + # because we use a current timestamp, and history is empty, + # FxThread.history_rate will use spot prices + return TxMinedStatus(height=10, conf=10, timestamp=time.time(), header_hash='def') + + default_fiat_value = Abstract_Wallet.default_fiat_value + price_at_timestamp = Abstract_Wallet.price_at_timestamp + class storage: + put = lambda self, x: None + +txid = 'abc' +ccy = 'TEST' + +class TestFiat(TestCase): + def setUp(self): + self.value_sat = COIN + self.fiat_value = {} + self.wallet = FakeWallet(fiat_value=self.fiat_value) + self.fx = FakeFxThread(FakeExchange(Decimal('1000.001'))) + default_fiat = Abstract_Wallet.default_fiat_value(self.wallet, txid, self.fx, self.value_sat) + self.assertEqual(Decimal('1000.001'), default_fiat) + self.assertEqual('1,000.00', self.fx.ccy_amount_str(default_fiat, commas=True)) + + def test_save_fiat_and_reset(self): + self.assertEqual(False, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '1000.01', self.fx, self.value_sat)) + saved = self.fiat_value[ccy][txid] + self.assertEqual('1,000.01', self.fx.ccy_amount_str(Decimal(saved), commas=True)) + self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '', self.fx, self.value_sat)) + self.assertNotIn(txid, self.fiat_value[ccy]) + # even though we are not setting it to the exact fiat value according to the exchange rate, precision is truncated away + self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '1,000.002', self.fx, self.value_sat)) + + def test_too_high_precision_value_resets_with_no_saved_value(self): + self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '1,000.001', self.fx, self.value_sat)) + + def test_empty_resets(self): + self.assertEqual(True, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, '', self.fx, self.value_sat)) + self.assertNotIn(ccy, self.fiat_value) + + def test_save_garbage(self): + self.assertEqual(False, Abstract_Wallet.set_fiat_value(self.wallet, txid, ccy, 'garbage', self.fx, self.value_sat)) + self.assertNotIn(ccy, self.fiat_value) DIR diff --git a/electrum/util.py b/electrum/util.py t@@ -39,6 +39,7 @@ import urllib.request, urllib.parse, urllib.error import builtins import json import time +from typing import NamedTuple, Optional import aiohttp from aiohttp_socks import SocksConnector, SocksVer t@@ -129,31 +130,15 @@ class UserCancelled(Exception): '''An exception that is suppressed from the user''' pass -class Satoshis(object): - __slots__ = ('value',) - - def __new__(cls, value): - self = super(Satoshis, cls).__new__(cls) - self.value = value - return self - - def __repr__(self): - return 'Satoshis(%d)'%self.value +class Satoshis(NamedTuple): + value: int def __str__(self): return format_satoshis(self.value) + " BTC" -class Fiat(object): - __slots__ = ('value', 'ccy') - - def __new__(cls, value, ccy): - self = super(Fiat, cls).__new__(cls) - self.ccy = ccy - self.value = value - return self - - def __repr__(self): - return 'Fiat(%s)'% self.__str__() +class Fiat(NamedTuple): + value: Optional[Decimal] + ccy: str def __str__(self): if self.value is None or self.value.is_nan(): DIR diff --git a/electrum/wallet.py b/electrum/wallet.py t@@ -247,24 +247,37 @@ class Abstract_Wallet(AddressSynchronizer): self.storage.put('labels', self.labels) return changed - def set_fiat_value(self, txid, ccy, text): + def set_fiat_value(self, txid, ccy, text, fx, value): if txid not in self.transactions: return - if not text: + # since fx is inserting the thousands separator, + # and not util, also have fx remove it + text = fx.remove_thousands_separator(text) + def_fiat = self.default_fiat_value(txid, fx, value) + formatted = fx.ccy_amount_str(def_fiat, commas=False) + def_fiat_rounded = Decimal(formatted) + reset = not text + if not reset: + try: + text_dec = Decimal(text) + text_dec_rounded = Decimal(fx.ccy_amount_str(text_dec, commas=False)) + reset = text_dec_rounded == def_fiat_rounded + except: + # garbage. not resetting, but not saving either + return False + if reset: d = self.fiat_value.get(ccy, {}) if d and txid in d: d.pop(txid) else: - return - else: - try: - Decimal(text) - except: - return + # avoid saving empty dict + return True if ccy not in self.fiat_value: self.fiat_value[ccy] = {} - self.fiat_value[ccy][txid] = text + if not reset: + self.fiat_value[ccy][txid] = text self.storage.put('fiat_value', self.fiat_value) + return reset def get_fiat_value(self, txid, ccy): fiat_value = self.fiat_value.get(ccy, {}).get(txid) t@@ -423,21 +436,11 @@ class Abstract_Wallet(AddressSynchronizer): income += value # fiat computations if fx and fx.is_enabled() and fx.get_history_config(): - fiat_value = self.get_fiat_value(tx_hash, fx.ccy) - fiat_default = fiat_value is None - fiat_rate = self.price_at_timestamp(tx_hash, fx.timestamp_rate) - fiat_value = fiat_value if fiat_value is not None else value / Decimal(COIN) * fiat_rate - fiat_fee = tx_fee / Decimal(COIN) * fiat_rate if tx_fee is not None else None - item['fiat_value'] = Fiat(fiat_value, fx.ccy) - item['fiat_fee'] = Fiat(fiat_fee, fx.ccy) if fiat_fee else None - item['fiat_default'] = fiat_default + fiat_fields = self.get_tx_item_fiat(tx_hash, value, fx, tx_fee) + fiat_value = fiat_fields['fiat_value'].value + item.update(fiat_fields) if value < 0: - acquisition_price = - value / Decimal(COIN) * self.average_price(tx_hash, fx.timestamp_rate, fx.ccy) - liquidation_price = - fiat_value - item['acquisition_price'] = Fiat(acquisition_price, fx.ccy) - cg = liquidation_price - acquisition_price - item['capital_gain'] = Fiat(cg, fx.ccy) - capital_gains += cg + capital_gains += fiat_fields['capital_gain'].value fiat_expenditures += -fiat_value else: fiat_income += fiat_value t@@ -478,6 +481,27 @@ class Abstract_Wallet(AddressSynchronizer): 'summary': summary } + def default_fiat_value(self, tx_hash, fx, value): + return value / Decimal(COIN) * self.price_at_timestamp(tx_hash, fx.timestamp_rate) + + def get_tx_item_fiat(self, tx_hash, value, fx, tx_fee): + item = {} + fiat_value = self.get_fiat_value(tx_hash, fx.ccy) + fiat_default = fiat_value is None + fiat_rate = self.price_at_timestamp(tx_hash, fx.timestamp_rate) + fiat_value = fiat_value if fiat_value is not None else self.default_fiat_value(tx_hash, fx, value) + fiat_fee = tx_fee / Decimal(COIN) * fiat_rate if tx_fee is not None else None + item['fiat_value'] = Fiat(fiat_value, fx.ccy) + item['fiat_fee'] = Fiat(fiat_fee, fx.ccy) if fiat_fee else None + item['fiat_default'] = fiat_default + if value < 0: + acquisition_price = - value / Decimal(COIN) * self.average_price(tx_hash, fx.timestamp_rate, fx.ccy) + liquidation_price = - fiat_value + item['acquisition_price'] = Fiat(acquisition_price, fx.ccy) + cg = liquidation_price - acquisition_price + item['capital_gain'] = Fiat(cg, fx.ccy) + return item + def get_label(self, tx_hash): label = self.labels.get(tx_hash, '') if label is '':