tMerge pull request #4872 from spesmilo/qt_fiat_fixes - electrum - Electrum Bitcoin wallet
 (HTM) git clone https://git.parazyd.org/electrum
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) Submodules
       ---
 (DIR) commit 6bf48d0506a36846895d8410a4d9f0db54ffb1d6
 (DIR) parent 12c6a4043b4e5054eedf819657a28e30377a6d47
 (HTM) 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 '':