diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py index ae09e7664..b2809f4de 100644 --- a/electrum/gui/messages.py +++ b/electrum/gui/messages.py @@ -37,3 +37,8 @@ Another instance of this wallet (same seed) has an open channel with the same re Are you sure? """ + + +MSG_CAPITAL_GAINS = """ +This summary covers only on-chain transactions (no lightning!). Capital gains are computed by attaching an acquisition price to each UTXO in the wallet, and uses the order of blockchain events (not FIFO). +""" diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index 4e6d289e9..895fbcc30 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -39,6 +39,7 @@ from PyQt5.QtWidgets import (QMenu, QHeaderView, QLabel, QMessageBox, QPushButton, QComboBox, QVBoxLayout, QCalendarWidget, QGridLayout) +from electrum.gui import messages from electrum.address_synchronizer import TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE from electrum.i18n import _ from electrum.util import (block_explorer_URL, profiler, TxMinedInfo, @@ -49,7 +50,7 @@ from electrum.logging import get_logger, Logger from .custom_model import CustomNode, CustomModel from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton, filename_field, MyTreeView, AcceptFileDragDrop, WindowModalDialog, - CloseButton, webopen) + CloseButton, webopen, WWLabel) if TYPE_CHECKING: from electrum.wallet import Abstract_Wallet @@ -547,40 +548,72 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): return datetime.datetime(date.year, date.month, date.day) def show_summary(self): - h = self.parent.wallet.get_detailed_history()['summary'] - if not h: + fx = self.parent.fx + show_fiat = fx and fx.is_enabled() and fx.get_history_config() + if not show_fiat: + self.parent.show_message(_("Enable fiat exchange rate with history.")) + return + h = self.wallet.get_detailed_history(fx=fx) + summary = h['summary'] + if not summary: self.parent.show_message(_("Nothing to summarize.")) return - start_date = h.get('start_date') - end_date = h.get('end_date') + start = summary['begin'] + end = summary['end'] + flow = summary['flow'] + start_date = start.get('date') + end_date = end.get('date') format_amount = lambda x: self.parent.format_amount(x.value) + ' ' + self.parent.base_unit() + format_fiat = lambda x: str(x) + ' ' + self.parent.fx.ccy + d = WindowModalDialog(self, _("Summary")) d.setMinimumSize(600, 150) vbox = QVBoxLayout() + msg = messages.to_rtf(messages.MSG_CAPITAL_GAINS) + vbox.addWidget(WWLabel(msg)) grid = QGridLayout() - grid.addWidget(QLabel(_("Start")), 0, 0) - grid.addWidget(QLabel(self.format_date(start_date)), 0, 1) - grid.addWidget(QLabel(str(h.get('fiat_start_value')) + '/BTC'), 0, 2) - grid.addWidget(QLabel(_("Initial balance")), 1, 0) - grid.addWidget(QLabel(format_amount(h['start_balance'])), 1, 1) - grid.addWidget(QLabel(str(h.get('fiat_start_balance'))), 1, 2) - grid.addWidget(QLabel(_("End")), 2, 0) - grid.addWidget(QLabel(self.format_date(end_date)), 2, 1) - grid.addWidget(QLabel(str(h.get('fiat_end_value')) + '/BTC'), 2, 2) - grid.addWidget(QLabel(_("Final balance")), 4, 0) - grid.addWidget(QLabel(format_amount(h['end_balance'])), 4, 1) - grid.addWidget(QLabel(str(h.get('fiat_end_balance'))), 4, 2) - grid.addWidget(QLabel(_("Income")), 5, 0) - grid.addWidget(QLabel(format_amount(h.get('incoming'))), 5, 1) - grid.addWidget(QLabel(str(h.get('fiat_incoming'))), 5, 2) - grid.addWidget(QLabel(_("Expenditures")), 6, 0) - grid.addWidget(QLabel(format_amount(h.get('outgoing'))), 6, 1) - grid.addWidget(QLabel(str(h.get('fiat_outgoing'))), 6, 2) - grid.addWidget(QLabel(_("Capital gains")), 7, 0) - grid.addWidget(QLabel(str(h.get('fiat_capital_gains'))), 7, 2) - grid.addWidget(QLabel(_("Unrealized gains")), 8, 0) - grid.addWidget(QLabel(str(h.get('fiat_unrealized_gains', ''))), 8, 2) + grid.addWidget(QLabel(_("Begin")), 0, 1) + grid.addWidget(QLabel(_("End")), 0, 2) + # + grid.addWidget(QLabel(_("Date")), 1, 0) + grid.addWidget(QLabel(self.format_date(start_date)), 1, 1) + grid.addWidget(QLabel(self.format_date(end_date)), 1, 2) + # + grid.addWidget(QLabel(_("BTC balance")), 2, 0) + grid.addWidget(QLabel(format_amount(start['BTC_balance'])), 2, 1) + grid.addWidget(QLabel(format_amount(end['BTC_balance'])), 2, 2) + # + grid.addWidget(QLabel(_("BTC Fiat price")), 3, 0) + grid.addWidget(QLabel(format_fiat(start.get('BTC_fiat_price'))), 3, 1) + grid.addWidget(QLabel(format_fiat(end.get('BTC_fiat_price'))), 3, 2) + # + grid.addWidget(QLabel(_("Fiat balance")), 4, 0) + grid.addWidget(QLabel(format_fiat(start.get('fiat_balance'))), 4, 1) + grid.addWidget(QLabel(format_fiat(end.get('fiat_balance'))), 4, 2) + # + grid.addWidget(QLabel(_("Acquisition price")), 5, 0) + grid.addWidget(QLabel(format_fiat(start.get('acquisition_price', ''))), 5, 1) + grid.addWidget(QLabel(format_fiat(end.get('acquisition_price', ''))), 5, 2) + # + grid.addWidget(QLabel(_("Unrealized capital gains")), 6, 0) + grid.addWidget(QLabel(format_fiat(start.get('unrealized_gains', ''))), 6, 1) + grid.addWidget(QLabel(format_fiat(end.get('unrealized_gains', ''))), 6, 2) + # + grid2 = QGridLayout() + grid2.addWidget(QLabel(_("BTC incoming")), 0, 0) + grid2.addWidget(QLabel(format_amount(flow['BTC_incoming'])), 0, 1) + grid2.addWidget(QLabel(_("Fiat incoming")), 1, 0) + grid2.addWidget(QLabel(format_fiat(flow.get('fiat_incoming'))), 1, 1) + grid2.addWidget(QLabel(_("BTC outgoing")), 2, 0) + grid2.addWidget(QLabel(format_amount(flow['BTC_outgoing'])), 2, 1) + grid2.addWidget(QLabel(_("Fiat outgoing")), 3, 0) + grid2.addWidget(QLabel(format_fiat(flow.get('fiat_outgoing'))), 3, 1) + # + grid2.addWidget(QLabel(_("Realized capital gains")), 4, 0) + grid2.addWidget(QLabel(format_fiat(flow.get('realized_capital_gains'))), 4, 1) vbox.addLayout(grid) + vbox.addWidget(QLabel(_('Cash flow'))) + vbox.addLayout(grid2) vbox.addLayout(Buttons(CloseButton(d))) d.setLayout(vbox) d.exec_() diff --git a/electrum/wallet.py b/electrum/wallet.py index a1e2f5e9b..f65c94efe 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -955,13 +955,21 @@ class Abstract_Wallet(AddressSynchronizer, ABC): return transactions @profiler - def get_detailed_history(self, from_timestamp=None, to_timestamp=None, - fx=None, show_addresses=False, from_height=None, to_height=None): + def get_detailed_history( + self, + from_timestamp=None, + to_timestamp=None, + fx=None, + show_addresses=False, + from_height=None, + to_height=None): # History with capital gains, using utxo pricing # FIXME: Lightning capital gains would requires FIFO if (from_timestamp is not None or to_timestamp is not None) \ and (from_height is not None or to_height is not None): raise Exception('timestamp and block height based filtering cannot be used together') + + show_fiat = fx and fx.is_enabled() and fx.get_history_config() out = [] income = 0 expenditures = 0 @@ -995,7 +1003,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): else: income += value # fiat computations - if fx and fx.is_enabled() and fx.get_history_config(): + if show_fiat: fiat_fields = self.get_tx_item_fiat(tx_hash=tx_hash, amount_sat=value, fx=fx, tx_fee=tx_fee) fiat_value = fiat_fields['fiat_value'].value item.update(fiat_fields) @@ -1007,36 +1015,74 @@ class Abstract_Wallet(AddressSynchronizer, ABC): out.append(item) # add summary if out: - b, v = out[0]['bc_balance'].value, out[0]['bc_value'].value + first_item = out[0] + last_item = out[-1] + if from_height or to_height: + start_height = from_height + end_height = to_height + else: + start_height = first_item['height'] - 1 + end_height = last_item['height'] + + b = first_item['bc_balance'].value + v = first_item['bc_value'].value start_balance = None if b is None or v is None else b - v - end_balance = out[-1]['bc_balance'].value + end_balance = last_item['bc_balance'].value + if from_timestamp is not None and to_timestamp is not None: - start_date = timestamp_to_datetime(from_timestamp) - end_date = timestamp_to_datetime(to_timestamp) + start_timestamp = from_timestamp + end_timestamp = to_timestamp else: - start_date = None - end_date = None + start_timestamp = first_item['timestamp'] + end_timestamp = last_item['timestamp'] + + start_coins = self.get_utxos( + domain=None, + block_height=start_height, + confirmed_funding_only=True, + confirmed_spending_only=True, + nonlocal_only=True) + end_coins = self.get_utxos( + domain=None, + block_height=end_height, + confirmed_funding_only=True, + confirmed_spending_only=True, + nonlocal_only=True) + + def summary_point(timestamp, height, balance, coins): + date = timestamp_to_datetime(timestamp) + out = { + 'date': date, + 'block_height': height, + 'BTC_balance': Satoshis(balance), + } + if show_fiat: + ap = self.acquisition_price(coins, fx.timestamp_rate, fx.ccy) + lp = self.liquidation_price(coins, fx.timestamp_rate, timestamp) + out['acquisition_price'] = Fiat(ap, fx.ccy) + out['liquidation_price'] = Fiat(lp, fx.ccy) + out['unrealized_gains'] = Fiat(lp - ap, fx.ccy) + out['fiat_balance'] = Fiat(fx.historical_value(balance, date), fx.ccy) + out['BTC_fiat_price'] = Fiat(fx.historical_value(COIN, date), fx.ccy) + return out + + summary_start = summary_point(start_timestamp, start_height, start_balance, start_coins) + summary_end = summary_point(end_timestamp, end_height, end_balance, end_coins) + flow = { + 'BTC_incoming': Satoshis(income), + 'BTC_outgoing': Satoshis(expenditures) + } + if show_fiat: + flow['fiat_currency'] = fx.ccy + flow['fiat_incoming'] = Fiat(fiat_income, fx.ccy) + flow['fiat_outgoing'] = Fiat(fiat_expenditures, fx.ccy) + flow['realized_capital_gains'] = Fiat(capital_gains, fx.ccy) summary = { - 'start_date': start_date, - 'end_date': end_date, - 'from_height': from_height, - 'to_height': to_height, - 'start_balance': Satoshis(start_balance), - 'end_balance': Satoshis(end_balance), - 'incoming': Satoshis(income), - 'outgoing': Satoshis(expenditures) + 'begin': summary_start, + 'end': summary_end, + 'flow': flow, } - if fx and fx.is_enabled() and fx.get_history_config(): - unrealized = self.unrealized_gains(None, fx.timestamp_rate, fx.ccy) - summary['fiat_currency'] = fx.ccy - summary['fiat_capital_gains'] = Fiat(capital_gains, fx.ccy) - summary['fiat_incoming'] = Fiat(fiat_income, fx.ccy) - summary['fiat_outgoing'] = Fiat(fiat_expenditures, fx.ccy) - summary['fiat_unrealized_gains'] = Fiat(unrealized, fx.ccy) - summary['fiat_start_balance'] = Fiat(fx.historical_value(start_balance, start_date), fx.ccy) - summary['fiat_end_balance'] = Fiat(fx.historical_value(end_balance, end_date), fx.ccy) - summary['fiat_start_value'] = Fiat(fx.historical_value(COIN, start_date), fx.ccy) - summary['fiat_end_value'] = Fiat(fx.historical_value(COIN, end_date), fx.ccy) + else: summary = {} return { @@ -1044,6 +1090,13 @@ class Abstract_Wallet(AddressSynchronizer, ABC): 'summary': summary } + def acquisition_price(self, coins, price_func, ccy): + return Decimal(sum(self.coin_price(coin.prevout.txid.hex(), price_func, ccy, self.get_txin_value(coin)) for coin in coins)) + + def liquidation_price(self, coins, price_func, timestamp): + p = price_func(timestamp) + return sum([coin.value_sats() for coin in coins]) * p / Decimal(COIN) + def default_fiat_value(self, tx_hash, fx, value_sat): return value_sat / Decimal(COIN) * self.price_at_timestamp(tx_hash, fx.timestamp_rate) @@ -2356,14 +2409,6 @@ class Abstract_Wallet(AddressSynchronizer, ABC): timestamp = self.get_tx_height(txid).timestamp return price_func(timestamp if timestamp else time.time()) - def unrealized_gains(self, domain, price_func, ccy): - coins = self.get_utxos(domain) - now = time.time() - p = price_func(now) - ap = sum(self.coin_price(coin.prevout.txid.hex(), price_func, ccy, self.get_txin_value(coin)) for coin in coins) - lp = sum([coin.value_sats() for coin in coins]) * p / Decimal(COIN) - return lp - ap - def average_price(self, txid, price_func, ccy) -> Decimal: """ Average acquisition price of the inputs of a transaction """ input_value = 0