diff --git a/installer/installer_setup.iss b/installer/installer_setup.iss index 2ac87c41..841652ae 100644 --- a/installer/installer_setup.iss +++ b/installer/installer_setup.iss @@ -1,5 +1,5 @@ #define MyAppName "Kapytal" -#define MyAppVersion "0.14.0" +#define MyAppVersion "0.15.0" #define MyAppPublisher "Jakub Franek" #define MyAppURL "https://github.com/JakubFranek/Kapytal" #define MyAppExeName "Kapytal.exe" diff --git a/main.py b/main.py index 3f2a16c1..c105f306 100644 --- a/main.py +++ b/main.py @@ -66,8 +66,9 @@ def main() -> None: app.processEvents() # draw MainView so WelcomeDialog can be properly centered - logging.debug("Checking for updates") - main_presenter.check_for_updates() + if user_settings.settings.check_for_updates_on_startup: + logging.debug("Checking for updates") + main_presenter.check_for_updates() logging.debug("Showing Welcome dialog") main_presenter.show_welcome_dialog() diff --git a/pyproject.toml b/pyproject.toml index 6176750e..da8298b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,6 @@ extend-ignore = [ ] [tool.ruff.per-file-ignores] -"tests/*" = ["S101", "ANN401", "SLF001", "PLR2004"] +"tests/*" = ["S101", "ANN401", "SLF001", "PLR2004", "FBT001"] "src/views/*" = ["FBT003", "N802"] "src/view_models/*" = ["N802"] diff --git a/requirements.txt b/requirements.txt index 2d27f6ff..1e9f65c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ altgraph==0.17.4 appdirs==1.4.4 attrs==23.1.0 beautifulsoup4==4.12.2 -black==23.12.1 certifi==2023.11.17 charset-normalizer==3.3.2 click==8.1.7 @@ -20,7 +19,6 @@ iniconfig==2.0.0 kiwisolver==1.4.5 lxml==4.9.4 matplotlib==3.8.2 -mplcursors==0.5.2 multitasking==0.0.11 mypy==1.8.0 mypy-extensions==1.0.0 diff --git a/src/models/base_classes/transaction.py b/src/models/base_classes/transaction.py index 0e7d7709..f3de20bf 100644 --- a/src/models/base_classes/transaction.py +++ b/src/models/base_classes/transaction.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from collections.abc import Collection -from datetime import datetime +from datetime import date, datetime from typing import TYPE_CHECKING from src.models.custom_exceptions import NotFoundError @@ -54,6 +54,10 @@ def _validate_description(self, value: str) -> None: def datetime_(self) -> datetime: return self._datetime + @property + def date_(self) -> date: + return self._datetime.date() + @property def timestamp(self) -> float: return self._timestamp diff --git a/src/models/model_objects/cash_objects.py b/src/models/model_objects/cash_objects.py index 7ce70f15..cfd9df84 100644 --- a/src/models/model_objects/cash_objects.py +++ b/src/models/model_objects/cash_objects.py @@ -169,7 +169,7 @@ def balances(self) -> tuple[CashAmount, ...]: def get_balance(self, currency: Currency, date_: date | None = None) -> CashAmount: if date_ is None: - amount = self._balance_history[-1][1] + amount = self._balance_history[-1][1].convert(currency) else: index = bisect_right( self._balance_history, date_, key=lambda x: x[0].date() diff --git a/src/models/model_objects/currency_objects.py b/src/models/model_objects/currency_objects.py index 86a24ade..b6a06906 100644 --- a/src/models/model_objects/currency_objects.py +++ b/src/models/model_objects/currency_objects.py @@ -10,6 +10,10 @@ from src.models.mixins.copyable_mixin import CopyableMixin from src.models.mixins.json_serializable_mixin import JSONSerializableMixin from src.models.user_settings import user_settings +from src.presenters.utilities.event import Event + +# TODO: add CurrencyManager class to take care of Currency cache resets +# and offload RecordKeeper methods to CurrencyManager quantizers: dict[int, Decimal] = {} for i in range(18 + 1): @@ -26,10 +30,16 @@ class ConversionFactorNotFoundError(ValueError): class Currency(CopyableMixin, JSONSerializableMixin): - __slots__ = ("_code", "_places", "_exchange_rates", "_factor_cache", "_zero_amount") + __slots__ = ( + "_code", + "_decimals", + "_exchange_rates", + "_factor_cache", + "_zero_amount", + ) CODE_LENGTH = 3 - def __init__(self, code: str, places: int) -> None: + def __init__(self, code: str, decimals: int) -> None: super().__init__() if not isinstance(code, str): @@ -38,11 +48,11 @@ def __init__(self, code: str, places: int) -> None: raise ValueError("Currency.code must be a three letter ISO-4217 code.") self._code = code.upper() - if not isinstance(places, int): - raise TypeError("Currency.places must be an integer.") - if places < 0: - raise ValueError("Currency.places must not be negative.") - self._places = places + if not isinstance(decimals, int): + raise TypeError("Currency.decimals must be an integer.") + if decimals < 0: + raise ValueError("Currency.decimals must not be negative.") + self._decimals = decimals self._exchange_rates: dict[Currency, "ExchangeRate"] = {} self._factor_cache: dict[str, Decimal] = {} @@ -53,8 +63,8 @@ def code(self) -> str: return self._code @property - def places(self) -> int: - return self._places + def decimals(self) -> int: + return self._decimals @property def zero_amount(self) -> "CashAmount": @@ -83,6 +93,9 @@ def __eq__(self, __o: object) -> bool: return self._code == __o._code def add_exchange_rate(self, exchange_rate: "ExchangeRate") -> None: + """The class managing Currencies must reset all Currency caches + after this method is called.""" + if not isinstance(exchange_rate, ExchangeRate): raise TypeError("Parameter 'exchange_rate' must be an ExchangeRate.") if self not in exchange_rate.currencies: @@ -92,14 +105,15 @@ def add_exchange_rate(self, exchange_rate: "ExchangeRate") -> None: ) other_currency = exchange_rate.currencies - {self} self._exchange_rates[other_currency.pop()] = exchange_rate - self.reset_cache() def remove_exchange_rate(self, exchange_rate: "ExchangeRate") -> None: + """The class managing Currencies must reset all Currency caches + after this method is called.""" + if not isinstance(exchange_rate, ExchangeRate): raise TypeError("Parameter 'exchange_rate' must be an ExchangeRate.") other_currency = exchange_rate.currencies - {self} del self._exchange_rates[other_currency.pop()] - self.reset_cache() def reset_cache(self) -> None: self._factor_cache = {} @@ -160,14 +174,13 @@ def _get_exchange_rates( # Direct ExchangeRate found! return [current_currency.exchange_rates[target_currency]] - # Direct ExchangeRate not found... - # Get unexplored currencies to iterate over. + # Direct ExchangeRate not found...get unexplored currencies to iterate over iterable_currencies = [ currency for currency in current_currency.convertible_to if currency not in ignore_currencies ] - # Ignore these currencies in future deeper searches (no need to go back). + # Ignore these currencies in future deeper searches (no need to go back) ignore_currencies = ignore_currencies | current_currency.convertible_to for loop_currency in iterable_currencies: exchange_rates = Currency._get_exchange_rates( @@ -176,17 +189,21 @@ def _get_exchange_rates( if exchange_rates is None: continue # Reached a dead end. # ExchangeRate to target_currency found! - # Append ExchangeRate needed to get there from the current_currency. + # Append ExchangeRate needed to get there from the current_currency exchange_rates.insert(0, current_currency.exchange_rates[loop_currency]) return exchange_rates return None # Reached a dead-end. def serialize(self) -> dict: - return {"datatype": "Currency", "code": self._code, "places": self._places} + return { + "datatype": "Currency", + "code": self._code, + "places": self._decimals, # TODO: rename key + } @staticmethod def deserialize(data: dict[str, Any]) -> "Currency": - return Currency(code=data["code"], places=data["places"]) + return Currency(code=data["code"], decimals=data["places"]) class ExchangeRate(CopyableMixin, JSONSerializableMixin): @@ -200,6 +217,7 @@ class ExchangeRate(CopyableMixin, JSONSerializableMixin): "_latest_date", "_earliest_date", "_recalculate_rate_history_pairs", + "event_reset_currency_caches", ) def __init__( @@ -222,6 +240,8 @@ def __init__( self._rate_decimals = 0 self._recalculate_rate_history_pairs = False + self.event_reset_currency_caches = Event() + @property def primary_currency(self) -> Currency: return self._primary_currency @@ -326,10 +346,8 @@ def delete_rate(self, date_: date, *, update: bool = True) -> None: self.update_values() def prepare_for_deletion(self) -> None: - self.primary_currency.remove_exchange_rate(self) - self.primary_currency.reset_cache() - self.secondary_currency.remove_exchange_rate(self) - self.secondary_currency.reset_cache() + self._primary_currency.remove_exchange_rate(self) + self._secondary_currency.remove_exchange_rate(self) def calculate_return( self, start: date | None = None, end: date | None = None @@ -413,8 +431,7 @@ def update_values(self) -> None: default=0, ) - self.primary_currency.reset_cache() - self.secondary_currency.reset_cache() + self.event_reset_currency_caches() self._recalculate_rate_history_pairs = True @@ -455,8 +472,8 @@ def value_rounded(self) -> Decimal: if self._raw_value.is_nan(): self._value_rounded = self._raw_value else: - self._value_rounded = round(self._raw_value, self._currency.places) - min_places = min(self._currency.places, 4) + self._value_rounded = round(self._raw_value, self._currency.decimals) + min_places = min(self._currency.decimals, 4) if -self._value_rounded.as_tuple().exponent > min_places: self._value_rounded = self._value_rounded.normalize() if -self._value_rounded.as_tuple().exponent < min_places: @@ -470,7 +487,7 @@ def value_rounded(self) -> Decimal: def value_normalized(self) -> Decimal: if not hasattr(self, "_value_normalized"): self._value_normalized = self._raw_value.normalize() - places = min(self._currency.places, 4) + places = min(self._currency.decimals, 4) if ( not self._value_normalized.is_nan() and -self._value_normalized.as_tuple().exponent < places diff --git a/src/models/model_objects/security_objects.py b/src/models/model_objects/security_objects.py index da8dbc9b..df6d7c53 100644 --- a/src/models/model_objects/security_objects.py +++ b/src/models/model_objects/security_objects.py @@ -8,6 +8,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal from enum import Enum, auto +from types import NoneType from typing import Any from uuid import UUID @@ -340,12 +341,14 @@ class SecurityAccount(Account): "allow_update_balance", "event_balance_updated", "_securities_history", + "_related_securities", ) def __init__(self, name: str, parent: AccountGroup | None = None) -> None: super().__init__(name, parent) self._securities_history: list[tuple[datetime, dict[Security, Decimal]]] = [] self._transactions: list[SecurityRelatedTransaction] = [] + self._related_securities: frozenset[Security] = frozenset() # allow_update_balance attribute is used to block updating the balance # when a transaction is added or removed during deserialization @@ -361,6 +364,17 @@ def securities(self) -> dict[Security, Decimal]: def transactions(self) -> tuple["SecurityRelatedTransaction", ...]: return tuple(self._transactions) + @property + def currency(self) -> Currency | None: + currencies = {security.currency for security in self._related_securities} + if len(currencies) == 1: + return next(iter(currencies)) + return None + + @property + def related_securities(self) -> frozenset[Security]: + return self._related_securities + def get_balance(self, currency: Currency, date_: date | None = None) -> CashAmount: if date_ is None: return sum( @@ -424,6 +438,7 @@ def update_securities(self) -> None: datetime_=new_datetime, block_account_update=True ) + related_securities = set() for transaction in self._transactions: security_dict = ( defaultdict(lambda: Decimal(0)) @@ -440,18 +455,14 @@ def update_securities(self) -> None: }, ) self._securities_history.append((transaction.datetime_, security_dict)) + related_securities.add(transaction.security) if len(self._securities_history) != 0: for security in self._securities_history[-1][1]: security.event_price_updated.append(self._update_balances) + self._related_securities = frozenset(related_securities) self._update_balances() - def is_security_related(self, security: Security) -> bool: - for _, security_dict in self._securities_history: - if security in security_dict: - return True - return False - def serialize(self) -> dict[str, Any]: index = self._parent.children.index(self) if self._parent is not None else None return { @@ -480,11 +491,19 @@ def deserialize( obj._parent = parent # noqa: SLF001 return obj - def get_average_price( - self, security: Security, date_: date | None = None + def get_average_price( # add method for average sell price + self, + security: Security, + date_: date | None = None, # latest date if None + currency: Currency | None = None, # Security.currency if None + type_: SecurityTransactionType = SecurityTransactionType.BUY, ) -> CashAmount: if not isinstance(security, Security): raise TypeError("Parameter 'security' must be a Security.") + if not isinstance(date_, (date, NoneType)): + raise TypeError("Parameter 'date' must be a date or None.") + if not isinstance(currency, (Currency, NoneType)): + raise TypeError("Parameter 'currency' must be a Currency or None.") if date_ is None and ( len(self._securities_history) == 0 or security not in self._securities_history[-1][1] @@ -495,34 +514,42 @@ def get_average_price( if date_ is not None: for _datetime, security_dict in reversed(self._securities_history): if _datetime.date() <= date_ and security in security_dict: - break + break # OK else: raise ValueError( f"Security {security.name} is not in this SecurityAccount." ) + if currency is None: + currency = security.currency + shares_price_pairs: list[tuple[int, CashAmount]] = [] for transaction in self._transactions: - _transaction_date = transaction.datetime_.date() - if date_ is not None and _transaction_date > date_: - continue + _transaction_date = transaction.date_ if transaction.security != security: continue - if isinstance(transaction, SecurityTransaction): - shares_price_pairs.append( - (transaction.shares, transaction.price_per_share) - ) + if date_ is not None and _transaction_date > date_: + continue + if ( + isinstance(transaction, SecurityTransaction) + and transaction.type_ == type_ + ): + amount = transaction.price_per_share elif ( isinstance(transaction, SecurityTransfer) and transaction.recipient == self ): - avg_price = transaction.sender.get_average_price( - security, _transaction_date + amount = transaction.sender.get_average_price( + security, _transaction_date, currency ) - shares_price_pairs.append((transaction.shares, avg_price)) + else: + continue + shares_price_pairs.append( + (transaction.shares, amount.convert(currency, _transaction_date)) + ) total_shares = 0 - total_price = CashAmount(0, security.currency) + total_price = currency.zero_amount for shares, price in shares_price_pairs: total_price += price * shares total_shares += shares @@ -530,7 +557,7 @@ def get_average_price( return ( total_price / total_shares if total_shares != 0 - else CashAmount("NaN", security.currency) + else CashAmount("NaN", currency) ) def _validate_transaction(self, transaction: "SecurityRelatedTransaction") -> None: diff --git a/src/models/record_keeper.py b/src/models/record_keeper.py index 173f3bee..dda12f1b 100644 --- a/src/models/record_keeper.py +++ b/src/models/record_keeper.py @@ -137,7 +137,6 @@ def securities(self) -> tuple[Security, ...]: @property def payees(self) -> tuple[Attribute, ...]: - self._payees.sort(key=lambda payee: payee.name) return tuple(self._payees) @property @@ -250,6 +249,8 @@ def add_exchange_rate( secondary_currency = self.get_currency(secondary_currency_code) exchange_rate = ExchangeRate(primary_currency, secondary_currency) self._exchange_rates.append(exchange_rate) + exchange_rate.event_reset_currency_caches.append(self._reset_currency_caches) + self._reset_currency_caches() def add_security( # noqa: PLR0913 self, @@ -1212,6 +1213,7 @@ def remove_exchange_rate(self, exchange_rate_code: str) -> None: removed_exchange_rate.prepare_for_deletion() self._exchange_rates.remove(removed_exchange_rate) + self._reset_currency_caches() del removed_exchange_rate def remove_category(self, path: str) -> None: @@ -1501,6 +1503,10 @@ def deserialize( obj._exchange_rates = RecordKeeper._deserialize_exchange_rates( # noqa: SLF001 data["exchange_rates"], currencies ) + for exchange_rate in obj._exchange_rates: # noqa: SLF001 + exchange_rate.event_reset_currency_caches.append( + obj._reset_currency_caches # noqa: SLF001 + ) securities = RecordKeeper._deserialize_securities( data["securities"], currencies @@ -1952,3 +1958,7 @@ def _update_descriptions(self) -> None: for transaction in self._transactions: if transaction.description: self._descriptions[transaction.description] += 1 + + def _reset_currency_caches(self) -> None: + for currency in self._currencies: + currency.reset_cache() diff --git a/src/models/statistics/attribute_stats.py b/src/models/statistics/attribute_stats.py index 1ac3cf12..a05cd1d1 100644 --- a/src/models/statistics/attribute_stats.py +++ b/src/models/statistics/attribute_stats.py @@ -1,4 +1,3 @@ -import itertools from collections.abc import Collection from dataclasses import dataclass, field @@ -15,7 +14,7 @@ class AttributeStats: attribute: Attribute no_of_transactions: int - balance: CashAmount + balance: CashAmount | None transactions: set[CashTransaction | RefundTransaction] = field(default_factory=set) @@ -87,7 +86,7 @@ def calculate_periodic_attribute_stats( def calculate_attribute_stats( transactions: Collection[CashTransaction | RefundTransaction], - base_currency: Currency, + base_currency: Currency | None, all_attributes: Collection[Attribute], ) -> dict[Attribute, AttributeStats]: attribute_types = {attribute.type_ for attribute in all_attributes} @@ -97,10 +96,14 @@ def calculate_attribute_stats( stats_dict: dict[Attribute, AttributeStats] = {} for attribute in all_attributes: - stats = AttributeStats(attribute, 0, base_currency.zero_amount) + if base_currency is not None: + stats = AttributeStats(attribute, 0, base_currency.zero_amount) + else: + stats = AttributeStats(attribute, 0, None) stats_dict[attribute] = stats + for transaction in transactions: - date_ = transaction.datetime_.date() + date_ = transaction.date_ if attribute_type == AttributeType.TAG: for tag in transaction.tags: stats = stats_dict[tag] diff --git a/src/models/statistics/cashflow_stats.py b/src/models/statistics/cashflow_stats.py index 6a9e7332..055ea572 100644 --- a/src/models/statistics/cashflow_stats.py +++ b/src/models/statistics/cashflow_stats.py @@ -104,9 +104,9 @@ def calculate_cash_flow( transactions = sorted(transactions, key=lambda x: x.timestamp) if start_date is None: - start_date = transactions[0].datetime_.date() + start_date = transactions[0].date_ if end_date is None: - end_date = transactions[-1].datetime_.date() + end_date = transactions[-1].date_ start_balance = base_currency.zero_amount end_balance = base_currency.zero_amount @@ -130,7 +130,7 @@ def calculate_cash_flow( delta_security += _end_balance for transaction in transactions: - date_ = transaction.datetime_.date() + date_ = transaction.date_ if date_ < start_date or date_ > end_date: raise ValueError(f"Unexpected Transaction date: {date_}") if isinstance(transaction, CashTransaction): @@ -196,13 +196,13 @@ def calculate_cash_flow( elif transaction.sender in accounts: shares = transaction.shares security = transaction.security - amount = shares * security.get_price(transaction.datetime_.date()) + amount = shares * security.get_price(date_) stats.outward_transfers.balance += amount stats.outward_transfers.transactions.add(transaction) else: shares = transaction.shares security = transaction.security - amount = shares * security.get_price(transaction.datetime_.date()) + amount = shares * security.get_price(date_) stats.inward_transfers.balance += amount stats.inward_transfers.transactions.add(transaction) @@ -240,8 +240,8 @@ def calculate_periodic_cash_flow( end_date: date | None, ) -> tuple[CashFlowStats]: transactions = sorted(transactions, key=lambda x: x.timestamp) - start_date = transactions[0].datetime_.date() if start_date is None else start_date - end_date = transactions[-1].datetime_.date() if end_date is None else end_date + start_date = transactions[0].date_ if start_date is None else start_date + end_date = transactions[-1].date_ if end_date is None else end_date periods = get_periods(start_date, end_date, period_type) period_format = "%Y" if period_type == PeriodType.YEAR else "%b %Y" diff --git a/src/models/statistics/category_stats.py b/src/models/statistics/category_stats.py index 46dd97a6..26873f30 100644 --- a/src/models/statistics/category_stats.py +++ b/src/models/statistics/category_stats.py @@ -16,7 +16,7 @@ class CategoryStats: category: Category transactions_self: int | float transactions_total: int | float - balance: CashAmount + balance: CashAmount | None transactions: set[CashTransaction | RefundTransaction] = field(default_factory=set) @@ -62,7 +62,7 @@ def calculate_periodic_totals_and_averages( expense_data = TransactionBalance(currency.zero_amount) for transaction in stats.transactions: - date_ = transaction.datetime_.date() + date_ = transaction.date_ amount = transaction.get_amount_for_category( stats.category, total=True ).convert(currency, date_) @@ -129,17 +129,23 @@ def calculate_periodic_category_stats( def calculate_category_stats( transactions: Collection[CashTransaction | RefundTransaction], - base_currency: Currency, + base_currency: Currency | None, categories: Collection[Category], ) -> dict[Category, CategoryStats]: stats_dict: dict[Category, CategoryStats] = {} for category in categories: - stats = CategoryStats(category, 0, 0, base_currency.zero_amount) + if base_currency is None: + stats = CategoryStats(category, 0, 0, None) + else: + stats = CategoryStats(category, 0, 0, base_currency.zero_amount) stats_dict[category] = stats + if base_currency is None: + return stats_dict # no base Currency means no Transactions + for transaction in transactions: already_counted_ancestors = set() - date_ = transaction.datetime_.date() + date_ = transaction.date_ for category in transaction.categories: stats = stats_dict[category] stats.transactions.add(transaction) diff --git a/src/models/statistics/security_stats.py b/src/models/statistics/security_stats.py index a9e3b16f..30257011 100644 --- a/src/models/statistics/security_stats.py +++ b/src/models/statistics/security_stats.py @@ -2,18 +2,24 @@ from decimal import Decimal from pyxirr import InvalidPaymentsError, xirr +from src.models.model_objects.currency_objects import Currency from src.models.model_objects.security_objects import ( Security, SecurityAccount, SecurityTransaction, SecurityTransfer, ) +from src.models.record_keeper import RecordKeeper from src.models.user_settings import user_settings -def calculate_irr(security: Security, accounts: list[SecurityAccount]) -> Decimal: +def calculate_irr( + security: Security, + accounts: list[SecurityAccount], + currency: Currency | None = None, +) -> Decimal: # 'transactions' is first created as a set to remove duplicates - # (SecurityTransfers can relate to multiple accounts) + # (as SecurityTransfers can relate to multiple accounts) transactions = { transaction for account in accounts @@ -26,21 +32,31 @@ def calculate_irr(security: Security, accounts: list[SecurityAccount]) -> Decima if len(transactions) == 0: return Decimal("NaN") + currency = currency or security.currency + dates: list[date] = [] cashflows: list[Decimal] = [] for transaction in transactions: - _date = transaction.datetime_.date() + _date = transaction.date_ if isinstance(transaction, SecurityTransaction): - amount = transaction.get_amount(transaction.cash_account).value_normalized + amount = ( + transaction.get_amount(transaction.cash_account) + .convert(currency, _date) + .value_normalized + ) else: if transaction.sender in accounts and transaction.recipient in accounts: continue if transaction.recipient in accounts: - avg_price = transaction.sender.get_average_price(security, _date) + avg_price = transaction.sender.get_average_price( + security, _date, currency + ) amount = -avg_price.value_normalized * transaction.shares else: - avg_price = transaction.recipient.get_average_price(security, _date) + avg_price = transaction.recipient.get_average_price( + security, _date, currency + ) amount = avg_price.value_normalized * transaction.shares if len(dates) > 0 and _date == dates[-1]: @@ -54,9 +70,70 @@ def calculate_irr(security: Security, accounts: list[SecurityAccount]) -> Decima # add last fictitious outflow as if all investment was liquidated sell_all_amount = Decimal(0) - price = security.price + price = security.price.convert(currency) for account in accounts: sell_all_amount += account.securities[security] * price.value_normalized + + return _calculate_irr(dates, cashflows, sell_all_amount) + + +def calculate_total_irr(record_keeper: RecordKeeper) -> Decimal: + # REFACTOR: move shared code to separate functions + + currency = record_keeper.base_currency + accounts = record_keeper.security_accounts + + # 'transactions' is first created as a set to remove duplicates + # (as SecurityTransfers can relate to multiple accounts) + transactions = { + transaction + for account in accounts + for transaction in account.transactions + if isinstance(transaction, SecurityTransaction | SecurityTransfer) + } + transactions = sorted(transactions, key=lambda t: t.datetime_) + + if len(transactions) == 0: + return Decimal("NaN") + + dates: list[date] = [] + cashflows: list[Decimal] = [] + for transaction in transactions: + _date = transaction.date_ + + if isinstance(transaction, SecurityTransaction): + amount = ( + transaction.get_amount(transaction.cash_account) + .convert(currency, _date) + .value_normalized + ) + else: + # SecurityTransfers can be ignored as they do not affect performance + continue + + if len(dates) > 0 and _date == dates[-1]: + cashflows[-1] += amount + else: + dates.append(_date) + cashflows.append(amount) + + if len(dates) == 0: + return Decimal("NaN") + + # add last fictitious outflow as if all investment was liquidated + sell_all_amount = currency.zero_amount + for account in accounts: + for security in account.securities: + sell_all_amount += account.securities[security] * security.price.convert( + currency + ) + sell_all_amount = sell_all_amount.value_normalized + return _calculate_irr(dates, cashflows, sell_all_amount) + + +def _calculate_irr( + dates: list[date], cashflows: list[Decimal], sell_all_amount: Decimal +) -> Decimal: if sell_all_amount.is_zero() or sell_all_amount.is_nan(): return Decimal("NaN") today = datetime.now(user_settings.settings.time_zone).date() diff --git a/src/models/transaction_filters/payee_filter.py b/src/models/transaction_filters/payee_filter.py index 3c64f528..de05ab67 100644 --- a/src/models/transaction_filters/payee_filter.py +++ b/src/models/transaction_filters/payee_filter.py @@ -24,7 +24,7 @@ def __init__(self, payees: Collection[Attribute], mode: FilterMode) -> None: super().__init__(mode=mode) if any(not isinstance(payee, Attribute) for payee in payees): - raise TypeError("Parameter 'payees' must be a Collection ofAttributes.") + raise TypeError("Parameter 'payees' must be a Collection of Attributes.") if any(tag.type_ != AttributeType.PAYEE for tag in payees): raise InvalidAttributeError( "Parameter 'payees' must contain only Attributes with type_=PAYEE." diff --git a/src/models/user_settings/user_settings_class.py b/src/models/user_settings/user_settings_class.py index 7d7b471b..2ff62bb1 100644 --- a/src/models/user_settings/user_settings_class.py +++ b/src/models/user_settings/user_settings_class.py @@ -1,11 +1,12 @@ import logging -from collections.abc import Collection +from collections.abc import Collection, Sequence from datetime import datetime from pathlib import Path from typing import Any, Self from src.models.mixins.copyable_mixin import CopyableMixin from src.models.mixins.json_serializable_mixin import JSONSerializableMixin +from src.views.constants import TransactionTableColumn from tzlocal import get_localzone_name from zoneinfo import ZoneInfo @@ -22,6 +23,8 @@ class UserSettings(JSONSerializableMixin, CopyableMixin): "_transaction_date_format", "_exchange_rate_decimals", "_price_per_share_decimals", + "_check_for_updates_on_startup", + "_transaction_table_column_order", ) LOGS_DEFAULT_MAX_SIZE = 1_000_000 @@ -41,6 +44,10 @@ def __init__(self) -> None: self._backup_paths = [] + self._check_for_updates_on_startup = True + + self._transaction_table_column_order = () + @property def time_zone(self) -> ZoneInfo: return self._time_zone @@ -219,11 +226,69 @@ def price_per_share_decimals(self, value: int) -> None: ) self._price_per_share_decimals = value + @property + def check_for_updates_on_startup(self) -> bool: + return self._check_for_updates_on_startup + + @check_for_updates_on_startup.setter + def check_for_updates_on_startup(self, value: bool) -> None: + if not isinstance(value, bool): + raise TypeError("UserSettings.check_for_updates_on_startup must be a bool.") + if self._check_for_updates_on_startup == value: + return + + logging.info( + "Changing UserSettings.check_for_updates_on_startup from " + f"{self._check_for_updates_on_startup} to {value}" + ) + self._check_for_updates_on_startup = value + + @property + def transaction_table_column_order(self) -> tuple[TransactionTableColumn]: + return self._transaction_table_column_order + + @transaction_table_column_order.setter + def transaction_table_column_order( + self, columns: Sequence[TransactionTableColumn] + ) -> None: + if not isinstance(columns, Sequence): + raise TypeError( + "UserSettings.transaction_table_column_order must be a Sequence." + ) + if not all(isinstance(column, TransactionTableColumn) for column in columns): + raise TypeError( + "UserSettings.transaction_table_column_order must be a Sequence " + "of TransactionTableColumn." + ) + if len(columns) != 0 and len(columns) != len(TransactionTableColumn): + raise ValueError( + "UserSettings.transaction_table_column_order must be a Sequence of " + f"exactly {len(TransactionTableColumn)} TransactionTableColumns " + "or empty." + ) + _column_set = set(columns) + if len(_column_set) != len(columns): + raise ValueError( + "UserSettings.transaction_table_column_order must not contain " + "duplicate values." + ) + if self._transaction_table_column_order == tuple(columns): + return + + logging.info( + "Changing UserSettings.transaction_table_column_order from " + f"{self._transaction_table_column_order} to {columns}" + ) + self._transaction_table_column_order = tuple(columns) + def __repr__(self) -> str: return "UserSettings" def serialize(self) -> dict[str, Any]: backup_paths = [str(path) for path in self._backup_paths] + transaction_table_column_names = [ + column.name for column in self._transaction_table_column_order + ] return { "datatype": "UserSettings", "time_zone": self._time_zone.key, @@ -234,6 +299,8 @@ def serialize(self) -> dict[str, Any]: "transaction_date_format": self._transaction_date_format, "exchange_rate_decimals": self._exchange_rate_decimals, "price_per_share_decimals": self._price_per_share_decimals, + "check_for_updates_on_startup": self._check_for_updates_on_startup, + "transaction_table_column_order": transaction_table_column_names, } @staticmethod @@ -251,6 +318,15 @@ def deserialize(data: dict[str, Any]) -> Self: exchange_rate_decimals: int = data.get("exchange_rate_decimals", 9) price_per_share_decimals: int = data.get("price_per_share_decimals", 9) + check_for_updates_on_startup: bool = data.get( + "check_for_updates_on_startup", True + ) + + transaction_table_column_order: tuple[TransactionTableColumn] = tuple( + TransactionTableColumn[name] + for name in data.get("transaction_table_column_order", ()) + ) + obj = UserSettings() obj._time_zone = time_zone # noqa: SLF001 obj._logs_max_size_bytes = logs_max_size_bytes # noqa: SLF001 @@ -260,5 +336,9 @@ def deserialize(data: dict[str, Any]) -> Self: obj._transaction_date_format = transaction_date_format # noqa: SLF001 obj._exchange_rate_decimals = exchange_rate_decimals # noqa: SLF001 obj._price_per_share_decimals = price_per_share_decimals # noqa: SLF001 + obj._check_for_updates_on_startup = check_for_updates_on_startup # noqa: SLF001 + obj._transaction_table_column_order = ( # noqa: SLF001 + transaction_table_column_order + ) return obj diff --git a/src/presenters/dialog/cash_account_dialog_presenter.py b/src/presenters/dialog/cash_account_dialog_presenter.py index e6b57ce3..d32923bc 100644 --- a/src/presenters/dialog/cash_account_dialog_presenter.py +++ b/src/presenters/dialog/cash_account_dialog_presenter.py @@ -40,7 +40,7 @@ def run_add_dialog(self) -> None: account_group_paths = self._get_account_group_paths() code_places_pairs = [ - (currency.code, currency.places) + (currency.code, currency.decimals) for currency in self._record_keeper.currencies ] self._dialog = CashAccountDialog( @@ -70,7 +70,7 @@ def run_edit_dialog(self) -> None: account_group_paths = self._get_account_group_paths() code_places_pairs = ( - (selected_item.currency.code, selected_item.currency.places), + (selected_item.currency.code, selected_item.currency.decimals), ) self._dialog = CashAccountDialog( parent=self._view, diff --git a/src/presenters/dialog/cash_transaction_dialog_presenter.py b/src/presenters/dialog/cash_transaction_dialog_presenter.py index 5869b2b4..2e3e53b2 100644 --- a/src/presenters/dialog/cash_transaction_dialog_presenter.py +++ b/src/presenters/dialog/cash_transaction_dialog_presenter.py @@ -68,7 +68,7 @@ def run_duplicate_dialog(self, transaction: CashTransaction) -> None: self._dialog.type_ = transaction.type_ self._dialog.account_path = transaction.account.path - self._dialog.amount_decimals = transaction.account.currency.places + self._dialog.amount_decimals = transaction.account.currency.decimals self._dialog.currency_code = transaction.account.currency.code self._dialog.payee = transaction.payee.name self._dialog.datetime_ = transaction.datetime_ @@ -123,7 +123,7 @@ def run_edit_dialog(self, transactions: Sequence[CashTransaction]) -> None: currencies = {transaction.currency for transaction in transactions} first_currency = currencies.pop() - self._dialog.amount_decimals = first_currency.places + self._dialog.amount_decimals = first_currency.decimals self._dialog.currency_code = first_currency.code payees = {transaction.payee.name for transaction in transactions} diff --git a/src/presenters/dialog/refund_transaction_dialog_presenter.py b/src/presenters/dialog/refund_transaction_dialog_presenter.py index 082a1f34..b07bc1fc 100644 --- a/src/presenters/dialog/refund_transaction_dialog_presenter.py +++ b/src/presenters/dialog/refund_transaction_dialog_presenter.py @@ -78,7 +78,7 @@ def _add_refund( if not validate_datetime(datetime_, self._dialog): return if ( - datetime_.date() == refunded_transaction.datetime_.date() + datetime_.date() == refunded_transaction.date_ and datetime_ <= refunded_transaction.datetime_ ): datetime_ = refunded_transaction.datetime_ + timedelta(seconds=1) @@ -143,7 +143,7 @@ def _edit_refund( if datetime_ is not None and not validate_datetime(datetime_, self._dialog): return if ( - datetime_.date() == refunded_transaction.datetime_.date() + datetime_.date() == refunded_transaction.date_ and datetime_ <= refunded_transaction.datetime_ ): datetime_ = refunded_transaction.datetime_ + timedelta(seconds=1) diff --git a/src/presenters/form/currency_form_presenter.py b/src/presenters/form/currency_form_presenter.py index e3ca77f8..992352ea 100644 --- a/src/presenters/form/currency_form_presenter.py +++ b/src/presenters/form/currency_form_presenter.py @@ -93,6 +93,7 @@ def reset_and_update_exchange_rate_table(self) -> None: self._exchange_rate_table_model.pre_reset_model() self.update_exchange_rate_table_data() self._exchange_rate_table_model.post_reset_model() + self._set_exchange_rate_table_column_visibility() if selected_index.isValid(): self.view.exchangeRateTable.selectRow(selected_index.row()) @@ -102,7 +103,6 @@ def update_exchange_rate_table_data(self) -> None: self._exchange_rate_table_model.load_data( self._record_keeper.exchange_rates, stats ) - self._set_exchange_rate_table_column_visibility() def show_form(self) -> None: self._busy_dialog = create_simple_busy_indicator( @@ -212,6 +212,7 @@ def _add_exchange_rate(self) -> None: self._exchange_rate_table_model.pre_add() self.update_exchange_rate_table_data() self._exchange_rate_table_model.post_add() + self._set_exchange_rate_table_column_visibility() self._dialog.close() self.event_data_changed() @@ -241,6 +242,7 @@ def _remove_exchange_rate(self) -> None: self._exchange_rate_table_model.pre_remove_item(exchange_rate) self.update_exchange_rate_table_data() self._exchange_rate_table_model.post_remove_item() + self._set_exchange_rate_table_column_visibility() self.event_data_changed() def _run_add_data_point_dialog(self) -> None: diff --git a/src/presenters/form/payee_form_presenter.py b/src/presenters/form/payee_form_presenter.py index 9684f216..7a612413 100644 --- a/src/presenters/form/payee_form_presenter.py +++ b/src/presenters/form/payee_form_presenter.py @@ -34,14 +34,14 @@ def __init__( self._record_keeper = record_keeper self._transaction_table_form_presenter = transaction_table_form_presenter - self._proxy_model = QSortFilterProxyModel(self._view.tableView) - self._model = AttributeTableModel(self._view.tableView, self._proxy_model) + self._proxy = QSortFilterProxyModel(self._view.tableView) + self._model = AttributeTableModel(self._view.tableView, self._proxy) self._update_model_data() - self._proxy_model.setSourceModel(self._model) - self._proxy_model.setSortRole(Qt.ItemDataRole.UserRole) - self._proxy_model.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self._view.tableView.setModel(self._proxy_model) + self._proxy.setSourceModel(self._model) + self._proxy.setSortRole(Qt.ItemDataRole.UserRole) + self._proxy.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._view.tableView.setModel(self._proxy) self._view.signal_add_payee.connect(lambda: self._run_payee_dialog(edit=False)) self._view.signal_remove_payee.connect(self._remove_payee) @@ -147,9 +147,10 @@ def _rename_payee(self) -> None: logging.info(f"Renaming Payee '{current_name}' to '{new_name=}'") try: self._record_keeper.edit_attribute( - current_name, new_name, AttributeType.PAYEE + current_name, new_name, AttributeType.PAYEE, merge=False ) self._update_model_data_with_busy_dialog() + self._model.item_changed(payee) except AlreadyExistsError: if not ask_yes_no_question( self._dialog, @@ -200,7 +201,7 @@ def _remove_payee(self) -> None: def _filter(self, pattern: str) -> None: if ("[" in pattern and "]" not in pattern) or "[]" in pattern: return - self._proxy_model.setFilterWildcard(pattern) + self._proxy.setFilterWildcard(pattern) def _selection_changed(self) -> None: payees = self._model.get_selected_attributes() diff --git a/src/presenters/form/security_form_presenter.py b/src/presenters/form/security_form_presenter.py index 191f19de..16f73835 100644 --- a/src/presenters/form/security_form_presenter.py +++ b/src/presenters/form/security_form_presenter.py @@ -11,7 +11,7 @@ from src.models.model_objects.currency_objects import CashAmount from src.models.model_objects.security_objects import Security, SecurityAccount from src.models.record_keeper import RecordKeeper -from src.models.statistics.security_stats import calculate_irr +from src.models.statistics.security_stats import calculate_irr, calculate_total_irr from src.models.user_settings import user_settings from src.presenters.utilities.event import Event from src.presenters.utilities.handle_exception import handle_exception @@ -26,6 +26,13 @@ from src.views.forms.security_form import SecurityForm from src.views.utilities.message_box_functions import ask_yes_no_question +OVERVIEW_COLUMNS_NATIVE = { + OwnedSecuritiesTreeColumn.GAIN_NATIVE, + OwnedSecuritiesTreeColumn.RETURN_NATIVE, + OwnedSecuritiesTreeColumn.IRR_NATIVE, + OwnedSecuritiesTreeColumn.AMOUNT_NATIVE, +} + class SecurityFormPresenter: event_data_changed = Event() @@ -57,7 +64,7 @@ def reset_models(self) -> None: self.reset_overview_model_data() self._price_table_model.pre_reset_model() - self.update_price_model_data() + self.reset_price_model_data() self._price_table_model.post_reset_model() self._update_chart(None) @@ -78,23 +85,21 @@ def reset_overview_model_data(self) -> None: def update_overview_model_data(self) -> None: irrs = self._calculate_irrs() + total_irr = calculate_total_irr(self._record_keeper) self._overview_tree_model.load_data( self._record_keeper.security_accounts, irrs, + total_irr, self._record_keeper.base_currency, ) hide_native_column = all( security.currency == self._record_keeper.base_currency for security in self._record_keeper.securities ) - self.view.treeView.setColumnHidden( - OwnedSecuritiesTreeColumn.AMOUNT_NATIVE, hide_native_column - ) - self.view.treeView.setColumnHidden( - OwnedSecuritiesTreeColumn.GAIN_NATIVE, hide_native_column - ) + for column in OVERVIEW_COLUMNS_NATIVE: + self.view.treeView.setColumnHidden(column, hide_native_column) - def update_price_model_data(self) -> None: + def reset_price_model_data(self) -> None: self._price_table_model.load_data(()) def data_changed(self) -> None: @@ -653,16 +658,27 @@ def _set_security_table_column_visibility(self) -> None: column_empty = self._security_table_model.is_column_empty(column) self.view.securityTableView.setColumnHidden(column, column_empty) - def _calculate_irrs(self) -> dict[Security, dict[SecurityAccount | None, Decimal]]: - irrs: dict[Security, dict[SecurityAccount | None, Decimal]] = {} + def _calculate_irrs( + self, + ) -> dict[Security, dict[SecurityAccount | None, tuple[Decimal, Decimal]]]: + irrs: dict[Security, dict[SecurityAccount | None, tuple[Decimal, Decimal]]] = {} + base_currency = self._record_keeper.base_currency for security in self._record_keeper.securities: accounts = [ account for account in self._record_keeper.security_accounts - if account.is_security_related(security) + if security in account.related_securities ] - irrs[security] = {None: calculate_irr(security, accounts)} + irrs[security] = { + None: ( + calculate_irr(security, accounts), + calculate_irr(security, accounts, base_currency), + ) + } for account in accounts: - irrs[security][account] = calculate_irr(security, [account]) + irrs[security][account] = ( + calculate_irr(security, [account]), + calculate_irr(security, [account], base_currency), + ) return irrs diff --git a/src/presenters/form/settings_form_presenter.py b/src/presenters/form/settings_form_presenter.py index 50a7629a..c41e513e 100644 --- a/src/presenters/form/settings_form_presenter.py +++ b/src/presenters/form/settings_form_presenter.py @@ -53,6 +53,9 @@ def show_form(self) -> None: self._view.price_per_share_decimals = ( user_settings.settings.price_per_share_decimals ) + self._view.check_for_updates_on_startup = ( + user_settings.settings.check_for_updates_on_startup + ) self._backup_paths = list(user_settings.settings.backup_paths) self._backup_paths_list_model.pre_reset_model() self.update_model_data() @@ -122,6 +125,9 @@ def save(self, *, close: bool) -> None: user_settings.settings.price_per_share_decimals = ( self._view.price_per_share_decimals ) + user_settings.settings.check_for_updates_on_startup = ( + self._view.check_for_updates_on_startup + ) except Exception as exception: # noqa: BLE001 handle_exception(exception) user_settings.settings = settings_copy diff --git a/src/presenters/form/tag_form_presenter.py b/src/presenters/form/tag_form_presenter.py index f0e4b44b..bc7d9b68 100644 --- a/src/presenters/form/tag_form_presenter.py +++ b/src/presenters/form/tag_form_presenter.py @@ -34,14 +34,14 @@ def __init__( self._record_keeper = record_keeper self._transaction_table_form_presenter = transaction_table_form_presenter - self._proxy_model = QSortFilterProxyModel(self._view.tableView) - self._model = AttributeTableModel(self._view.tableView, self._proxy_model) + self._proxy = QSortFilterProxyModel(self._view.tableView) + self._model = AttributeTableModel(self._view.tableView, self._proxy) self._update_model_data() - self._proxy_model.setSourceModel(self._model) - self._proxy_model.setSortRole(Qt.ItemDataRole.UserRole) - self._proxy_model.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self._view.tableView.setModel(self._proxy_model) + self._proxy.setSourceModel(self._model) + self._proxy.setSortRole(Qt.ItemDataRole.UserRole) + self._proxy.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._view.tableView.setModel(self._proxy) self._view.signal_add_tag.connect(lambda: self._run_tag_dialog(edit=False)) self._view.signal_remove_tag.connect(self._remove_tag) @@ -148,6 +148,7 @@ def _rename_tag(self) -> None: current_name, new_name, AttributeType.TAG ) self._update_model_data_with_busy_dialog() + self._model.item_changed(tag) except AlreadyExistsError: if not ask_yes_no_question( self._dialog, @@ -198,7 +199,7 @@ def _remove_tag(self) -> None: def _filter(self, pattern: str) -> None: if ("[" in pattern and "]" not in pattern) or "[]" in pattern: return - self._proxy_model.setFilterWildcard(pattern) + self._proxy.setFilterWildcard(pattern) def _selection_changed(self) -> None: tags = self._model.get_selected_attributes() diff --git a/src/presenters/form/transaction_table_form_presenter.py b/src/presenters/form/transaction_table_form_presenter.py index a25f7091..59b45ca3 100644 --- a/src/presenters/form/transaction_table_form_presenter.py +++ b/src/presenters/form/transaction_table_form_presenter.py @@ -131,13 +131,11 @@ def show_data( self._model.post_reset_model() self._update_table_columns() - self._form.table_view.resizeColumnsToContents() self._form.set_window_title(title) - self._update_number_of_shown_transactions() self._update_selected_transactions_amount() - self._form.setParent(parent, Qt.WindowType.Window) + self._form.table_view.resizeColumnsToContents() self._form.show_form() except: # noqa: TRY302 raise @@ -323,6 +321,6 @@ def _update_selected_transactions_amount(self) -> None: for transaction in transactions: if isinstance(transaction, CashTransaction | RefundTransaction): _amount = transaction.get_amount(transaction.account) - amount += _amount.convert(base_currency, transaction.datetime_.date()) + amount += _amount.convert(base_currency, transaction.date_) self._form.set_selected_amount(amount.to_str_rounded()) diff --git a/src/presenters/reports/category_report_presenter.py b/src/presenters/reports/category_report_presenter.py index 0031b811..f8fa0a20 100644 --- a/src/presenters/reports/category_report_presenter.py +++ b/src/presenters/reports/category_report_presenter.py @@ -320,7 +320,7 @@ def separate_stats( income_data = TransactionBalance(currency.zero_amount) expense_data = TransactionBalance(currency.zero_amount) for transaction in transactions: - date_ = transaction.datetime_.date() + date_ = transaction.date_ amount = transaction.get_amount_for_category(category, total=True).convert( currency, date_ ) diff --git a/src/presenters/reports/net_worth_report_presenter.py b/src/presenters/reports/net_worth_report_presenter.py index f4371427..92d946fe 100644 --- a/src/presenters/reports/net_worth_report_presenter.py +++ b/src/presenters/reports/net_worth_report_presenter.py @@ -225,7 +225,7 @@ def _create_time_report(self) -> None: title="Warning", ) return - start = transactions[0].datetime_.date() + start = transactions[0].date_ end = datetime.now(tz=user_settings.settings.time_zone).date() base_currency = self._record_keeper.base_currency @@ -275,8 +275,8 @@ def _create_time_report(self) -> None: y.append(net_worth) places = ( - base_currency.places - 2 - if base_currency.places >= 2 # noqa: PLR2004 + base_currency.decimals - 2 + if base_currency.decimals >= 2 # noqa: PLR2004 else 0 ) self._report.load_data( @@ -342,7 +342,7 @@ def calculate_accounts_sunburst_data( level = 1 nodes: list[SunburstNode] = [] root_node = SunburstNode( - "Total", "Total", 0, base_currency.code, base_currency.places, [], None + "Total", "Total", 0, base_currency.code, base_currency.decimals, [], None ) for account in account_items: if account.parent is not None: @@ -375,7 +375,7 @@ def _create_account_item_node( account_item.path, 0, currency.code, - currency.places, + currency.decimals, [], parent, ) @@ -406,7 +406,7 @@ def calculate_asset_type_sunburst_data( try: currency = stats[0].amount_base.currency currency_code = currency.code - currency_places = currency.places + currency_places = currency.decimals except IndexError: currency_code = "" currency_places = 0 diff --git a/src/presenters/update_presenter.py b/src/presenters/update_presenter.py index 10137c4f..8c253439 100644 --- a/src/presenters/update_presenter.py +++ b/src/presenters/update_presenter.py @@ -48,7 +48,7 @@ def _check_for_updates(self, *, silent: bool, timeout: int = 5) -> None: title="No response from GitHub", ) return - except ConnectionError: + except requests.exceptions.ConnectionError: logging.warning("Connection error while checking for updates") if not silent: display_error_message( diff --git a/src/presenters/widget/transactions_presenter.py b/src/presenters/widget/transactions_presenter.py index 9fe80592..832488f7 100644 --- a/src/presenters/widget/transactions_presenter.py +++ b/src/presenters/widget/transactions_presenter.py @@ -24,6 +24,7 @@ ) from src.models.record_keeper import RecordKeeper from src.models.transaction_filters.transaction_filter import TransactionFilter +from src.models.user_settings import user_settings from src.presenters.dialog.cash_transaction_dialog_presenter import ( CashTransactionDialogPresenter, ) @@ -97,6 +98,7 @@ def __init__( self._connect_to_signals() self._connect_events() self._update_model_data() + self._load_column_order() self._view.finalize_setup() @property @@ -184,6 +186,9 @@ def _update_model_data(self) -> None: ) def _update_table_columns(self) -> None: + if not self._view.auto_column_visibility: + return + visible_transactions = self._model.get_visible_items() any_security_related = False @@ -203,7 +208,10 @@ def _update_table_columns(self) -> None: any_with_categories = True if ( not any_non_base_amount - and isinstance(transaction, CashTransaction | RefundTransaction) + and isinstance( + transaction, + CashTransaction | RefundTransaction | SecurityTransaction, + ) and transaction.currency != self._record_keeper.base_currency ): any_non_base_amount = True @@ -221,6 +229,8 @@ def _update_table_columns(self) -> None: ) for column in TransactionTableColumn: + if column not in COLUMNS_HIDDEN_BY_DEFAULT: + self._view.set_column_visibility(column, show=True) if column in COLUMNS_SECURITY_RELATED: self._view.set_column_visibility(column, show=any_security_related) if column in COLUMNS_CASH_TRANSFERS: @@ -365,6 +375,8 @@ def _connect_to_signals(self) -> None: self._view.signal_refund.connect(self._refund_transaction) self._view.signal_find_related.connect(self._find_related) self._view.signal_reset_columns.connect(self._reset_columns) + self._view.signal_save_column_order.connect(self._save_column_order) + self._view.signal_load_column_order.connect(self._load_column_order) def _connect_events(self) -> None: for presenter in self._transaction_dialog_presenters: @@ -622,19 +634,33 @@ def _update_selected_transactions_amount( for transaction in transactions: if isinstance(transaction, CashTransaction | RefundTransaction): _amount = transaction.get_amount(transaction.account) - amount += _amount.convert(base_currency, transaction.datetime_.date()) + amount += _amount.convert(base_currency, transaction.date_) self._view.set_selected_amount(amount.to_str_rounded()) def _reset_columns(self) -> None: for column in TransactionTableColumn: - if column in COLUMNS_HIDDEN_BY_DEFAULT: - continue - self._view.set_column_visibility(column, show=True) + self._view.set_column_visibility( + column, show=column not in COLUMNS_HIDDEN_BY_DEFAULT + ) self._update_table_columns() self._view.reset_column_order() + self._view.auto_column_visibility = True def _copy_uuids(self) -> None: selected_transactions = self._model.get_selected_items() uuids = [str(transaction.uuid) for transaction in selected_transactions] QApplication.clipboard().setText(",\n".join(uuids)) + + def _save_column_order(self) -> None: + order = self._view.get_column_order() + user_settings.settings.transaction_table_column_order = order + user_settings.save() + self._view.column_order_saved() + + def _load_column_order(self) -> None: + order = user_settings.settings.transaction_table_column_order + if not order: + return + + self._view.load_column_order(order) diff --git a/src/utilities/constants.py b/src/utilities/constants.py index 7f006404..bd1fdedf 100644 --- a/src/utilities/constants.py +++ b/src/utilities/constants.py @@ -1,7 +1,7 @@ from pathlib import Path # Literal constants -VERSION = "0.14.0" +VERSION = "0.15.0" GITHUB_URL = "https://github.com/JakubFranek/Kapytal" GITHUB_API_URL = "https://api.github.com/repos/JakubFranek/Kapytal" diff --git a/src/view_models/account_tree_model.py b/src/view_models/account_tree_model.py index 78615bdb..a49b82d3 100644 --- a/src/view_models/account_tree_model.py +++ b/src/view_models/account_tree_model.py @@ -144,7 +144,9 @@ def sync_nodes( balance_base = item.get_balance(currency=base_currency) except (ConversionFactorNotFoundError, AttributeError): balance_base = None - if isinstance(item, CashAccount): + if isinstance(item, CashAccount) or ( + isinstance(item, SecurityAccount) and item.currency is not None + ): balance_native = item.get_balance(item.currency) else: balance_native = None @@ -165,7 +167,9 @@ def sync_nodes( node.balance_base = item.get_balance(currency=base_currency) except (ConversionFactorNotFoundError, AttributeError): node.balance_base = None - if isinstance(item, CashAccount): + if isinstance(item, CashAccount) or ( + isinstance(item, SecurityAccount) and item.currency is not None + ): node.balance_native = item.get_balance(item.currency) node.parent = parent_node node.children = [] diff --git a/src/view_models/attribute_table_model.py b/src/view_models/attribute_table_model.py index 7ed02d21..fb81b7d9 100644 --- a/src/view_models/attribute_table_model.py +++ b/src/view_models/attribute_table_model.py @@ -71,7 +71,7 @@ def data( index.column(), self._attribute_stats[index.row()] ) if role == Qt.ItemDataRole.UserRole: - return self._get_user_role_data( + return self._get_sort_role_data( index.column(), self._attribute_stats[index.row()] ) column = index.column() @@ -91,10 +91,10 @@ def _get_display_role_data( if column == AttributeTableColumn.TRANSACTIONS: return stats.no_of_transactions if column == AttributeTableColumn.BALANCE: - return stats.balance.to_str_rounded() + return stats.balance.to_str_rounded() if stats.balance is not None else None return None - def _get_user_role_data( + def _get_sort_role_data( self, column: int, stats: AttributeStats ) -> str | int | float | None: if column == AttributeTableColumn.NAME: @@ -102,7 +102,11 @@ def _get_user_role_data( if column == AttributeTableColumn.TRANSACTIONS: return stats.no_of_transactions if column == AttributeTableColumn.BALANCE: - return float(stats.balance.value_normalized) + return ( + float(stats.balance.value_normalized) + if stats.balance is not None + else None + ) return None def _get_foreground_role_data( @@ -110,6 +114,8 @@ def _get_foreground_role_data( ) -> QBrush | None: if column != AttributeTableColumn.BALANCE: return None + if stats.balance is None: + return None if stats.balance.is_positive(): return colors.get_green_brush() if stats.balance.is_negative(): @@ -173,3 +179,7 @@ def get_index_from_item(self, item: Attribute | None) -> QModelIndex: f"Parameter {item=} not in AttributeTableModel.attribute_stats." ) return QAbstractTableModel.createIndex(self, row, 0) + + def item_changed(self, item: Attribute) -> None: + index = self.get_index_from_item(item) + self.dataChanged.emit(index, index) diff --git a/src/view_models/category_tree_model.py b/src/view_models/category_tree_model.py index fdceee02..237f4c10 100644 --- a/src/view_models/category_tree_model.py +++ b/src/view_models/category_tree_model.py @@ -33,11 +33,12 @@ class CategoryTreeNode: transactions_self: int transactions_total: int transactions: set[Transaction] - balance: CashAmount + balance: CashAmount | None parent: Self | None children: list[Self] uuid: UUID # TODO: replace stats attributes with CategoryStats attribute directly + # and maybe uuid won't be needed anymore? def __repr__(self) -> str: return f"CategoryTreeNode({self.path})" @@ -209,7 +210,7 @@ def _get_display_role_data( return f"{node.transactions_total} ({node.transactions_self})" return f"{node.transactions_total}" if column == CategoryTreeColumn.BALANCE: - return node.balance.to_str_rounded() + return node.balance.to_str_rounded() if node.balance is not None else None return None def _get_user_role_data( @@ -220,7 +221,11 @@ def _get_user_role_data( if column == CategoryTreeColumn.TRANSACTIONS: return node.transactions_total if column == CategoryTreeColumn.BALANCE: - return float(node.balance.value_normalized) + return ( + float(node.balance.value_normalized) + if node.balance is not None + else None + ) return None def _get_foreground_role_data( @@ -228,6 +233,8 @@ def _get_foreground_role_data( ) -> QBrush | None: if column != CategoryTreeColumn.BALANCE: return None + if node.balance is None: + return None if node.balance.is_positive(): return colors.get_green_brush() if node.balance.is_negative(): diff --git a/src/view_models/currency_table_model.py b/src/view_models/currency_table_model.py index 371823bb..74ab1cd8 100644 --- a/src/view_models/currency_table_model.py +++ b/src/view_models/currency_table_model.py @@ -66,7 +66,7 @@ def data( # noqa: PLR0911 if column == CurrencyTableColumn.CODE: return currency.code if column == CurrencyTableColumn.PLACES: - return str(currency.places) + return str(currency.decimals) if ( role == Qt.ItemDataRole.DecorationRole and column == CurrencyTableColumn.CODE diff --git a/src/view_models/owned_securities_tree_model.py b/src/view_models/owned_securities_tree_model.py index ebcc6b02..9bcd3fa3 100644 --- a/src/view_models/owned_securities_tree_model.py +++ b/src/view_models/owned_securities_tree_model.py @@ -1,11 +1,9 @@ -import contextlib -import numbers import unicodedata from collections.abc import Collection from decimal import Decimal from PyQt6.QtCore import QAbstractItemModel, QModelIndex, QSortFilterProxyModel, Qt -from PyQt6.QtGui import QBrush, QIcon +from PyQt6.QtGui import QBrush, QFont, QIcon from PyQt6.QtWidgets import QTreeView from src.models.model_objects.currency_objects import ( CashAmount, @@ -25,8 +23,10 @@ OwnedSecuritiesTreeColumn.PRICE_AVERAGE: "Avg. Price", OwnedSecuritiesTreeColumn.GAIN_NATIVE: "Native Gain", OwnedSecuritiesTreeColumn.GAIN_BASE: "Base Gain", - OwnedSecuritiesTreeColumn.ABSOLUTE_RETURN: "Abs. Return", - OwnedSecuritiesTreeColumn.IRR: "IRR p.a.", + OwnedSecuritiesTreeColumn.RETURN_NATIVE: "Native Return", + OwnedSecuritiesTreeColumn.RETURN_BASE: "Base Return", + OwnedSecuritiesTreeColumn.IRR_NATIVE: "Native IRR p.a.", + OwnedSecuritiesTreeColumn.IRR_BASE: "Base IRR p.a.", OwnedSecuritiesTreeColumn.AMOUNT_NATIVE: "Native Value", OwnedSecuritiesTreeColumn.AMOUNT_BASE: "Base Value", } @@ -36,26 +36,61 @@ OwnedSecuritiesTreeColumn.PRICE_AVERAGE, OwnedSecuritiesTreeColumn.GAIN_NATIVE, OwnedSecuritiesTreeColumn.GAIN_BASE, - OwnedSecuritiesTreeColumn.ABSOLUTE_RETURN, - OwnedSecuritiesTreeColumn.IRR, + OwnedSecuritiesTreeColumn.RETURN_NATIVE, + OwnedSecuritiesTreeColumn.RETURN_BASE, + OwnedSecuritiesTreeColumn.IRR_NATIVE, + OwnedSecuritiesTreeColumn.IRR_BASE, OwnedSecuritiesTreeColumn.AMOUNT_NATIVE, OwnedSecuritiesTreeColumn.AMOUNT_BASE, } # TODO: add sync_nodes function +bold_font = QFont() +bold_font.setBold(True) # noqa: FBT003 + + +class TotalItem: + def __init__( + self, base_amount: CashAmount, gain_base: CashAmount, irr: Decimal + ) -> None: + self.base_amount = base_amount + + self.gain_base = gain_base + self.return_base = ( + round(100 * gain_base.value_normalized / base_amount.value_normalized, 2) + if not base_amount.value_normalized.is_zero() + else Decimal("NaN") + ) + self.irr_base = round(100 * irr, 2) + + @property + def name(self) -> str: + return "Σ Total" + class SecurityItem: - def __init__(self, security: Security, irr: Decimal) -> None: + def __init__( + self, security: Security, irr_native: Decimal, irr_base: Decimal + ) -> None: self.security = security self.shares = Decimal(0) self.accounts: list[AccountItem] = [] self.native_currency = security.currency - self.irr = round(100 * irr, 2) + self.irr_native = round(100 * irr_native, 2) + self.irr_base = round(100 * irr_base, 2) def __repr__(self) -> str: return f"SecurityItem(security={self.security.name})" + @property + def name(self) -> str: + return self.security.name + + @property + def is_base_currency(self) -> bool: + return self.base_amount.currency == self.native_amount.currency + def calculate_amounts(self, base_currency: Currency) -> None: self.native_amount = self.shares * self.security.price if base_currency is not None: @@ -68,15 +103,23 @@ def calculate_amounts(self, base_currency: Currency) -> None: else: self.base_amount = "N/A" - avg_price = CashAmount(0, self.security.currency) + avg_price_native = self.security.currency.zero_amount + avg_price_base = base_currency.zero_amount for account in self.accounts: - avg_price += account.avg_price * account.shares - avg_price = avg_price / self.shares - self.avg_price = avg_price - - self.gain_native = self.native_amount - avg_price * self.shares - self.gain_base = self.gain_native.convert(base_currency) - self.gain_pct = round(100 * self.gain_native / (avg_price * self.shares), 2) + avg_price_native += account.avg_price_native * account.shares + avg_price_base += account.avg_price_base * account.shares + avg_price_native /= self.shares + avg_price_base /= self.shares + self.avg_price_native = avg_price_native + + self.gain_native = self.native_amount - avg_price_native * self.shares + self.gain_base = self.base_amount - avg_price_base * self.shares + self.return_native = round( + 100 * self.gain_native / (avg_price_native * self.shares), 2 + ) + self.return_base = round( + 100 * self.gain_base / (avg_price_base * self.shares), 2 + ) class AccountItem: @@ -85,16 +128,20 @@ def __init__( parent: SecurityItem, account: SecurityAccount, shares: Decimal, - avg_price: CashAmount, - irr: Decimal, - base_currency: Currency | None, + avg_price_native: CashAmount, + avg_price_base: CashAmount, + irr_native: Decimal, + irr_base: Decimal, + base_currency: Currency, ) -> None: self.parent = parent self.account = account self.shares = shares - self.avg_price = avg_price + self.avg_price_native = avg_price_native + self.avg_price_base = avg_price_base self.security = parent.security - self.irr = round(100 * irr, 2) + self.irr_native = round(100 * irr_native, 2) + self.irr_base = round(100 * irr_base, 2) self.native_amount = shares * parent.security.price if base_currency is not None: @@ -105,12 +152,20 @@ def __init__( else: self.base_amount = "N/A" - self.gain_native = self.native_amount - avg_price * shares - self.gain_base = self.gain_native.convert(base_currency) - with contextlib.suppress(Exception): - self.gain_pct = round(100 * self.gain_native / (avg_price * shares), 2) + self.gain_native = self.native_amount - avg_price_native * shares + self.gain_base = self.base_amount - avg_price_base * shares + self.return_native = round( + 100 * self.gain_native / (avg_price_native * shares), 2 + ) + self.return_base = round(100 * self.gain_base / (avg_price_base * shares), 2) - self.native_currency = parent.security.currency + @property + def name(self) -> str: + return self.account.path + + @property + def is_base_currency(self) -> bool: + return self.base_amount.currency == self.native_amount.currency def __repr__(self) -> str: return ( @@ -133,14 +188,21 @@ def __init__( def load_data( self, accounts: Collection[SecurityAccount], - irrs: dict[Security, dict[SecurityAccount | None, Decimal]], + irrs: dict[Security, dict[SecurityAccount | None, tuple[Decimal, Decimal]]], + total_irr: Decimal, base_currency: Currency | None, ) -> None: - tree_items: dict[Security, SecurityItem] = {} + if base_currency is None: + self._tree_items = () + return + + tree_items: dict[Security, SecurityItem | TotalItem] = {} for account in accounts: for security, shares in account.securities.items(): if security not in tree_items: - tree_items[security] = SecurityItem(security, irrs[security][None]) + tree_items[security] = SecurityItem( + security, irrs[security][None][0], irrs[security][None][1] + ) tree_items[security].shares += shares tree_items[security].accounts.append( AccountItem( @@ -148,20 +210,29 @@ def load_data( account, shares, account.get_average_price(security), - irrs[security][account], + account.get_average_price(security, None, base_currency), + irrs[security][account][0], + irrs[security][account][1], base_currency, ) ) - self._tree_items = tuple(tree_items.values()) - for item in self._tree_items: + + total_gain = base_currency.zero_amount + total_amount = base_currency.zero_amount + for item in tree_items.values(): item.calculate_amounts(base_currency) + total_gain += item.gain_base + total_amount += item.base_amount + total_item = TotalItem(total_amount, total_gain, total_irr) + tree_items["Total"] = total_item + self._tree_items = tuple(tree_items.values()) def rowCount(self, index: QModelIndex = ...) -> int: if index.isValid(): if index.column() != 0: return 0 - item: SecurityItem | AccountItem = index.internalPointer() - if isinstance(item, AccountItem): + item: SecurityItem | AccountItem | TotalItem = index.internalPointer() + if isinstance(item, (AccountItem, TotalItem)): return 0 return len(item.accounts) return len(self._tree_items) @@ -218,7 +289,7 @@ def data( item: SecurityItem | AccountItem = index.internalPointer() if role == Qt.ItemDataRole.DisplayRole: return self._get_display_role_data(column, item) - if role == Qt.ItemDataRole.UserRole: # sort role + if role == Qt.ItemDataRole.UserRole: return self._get_sort_data(column, item) if role == Qt.ItemDataRole.TextAlignmentRole and column in COLUMNS_NUMBERS: return ALIGNMENT_AMOUNTS @@ -226,41 +297,48 @@ def data( return self._get_decoration_role_data(column, item) if role == Qt.ItemDataRole.ForegroundRole: return self._get_foreground_role_data(column, item) + if role == Qt.ItemDataRole.FontRole: + return self._get_font_role_data(item) return None def _get_display_role_data( # noqa: PLR0911 - self, column: int, item: SecurityItem | AccountItem + self, column: int, item: SecurityItem | AccountItem | TotalItem ) -> str | Decimal | None: if column == OwnedSecuritiesTreeColumn.NAME: - if isinstance(item, SecurityItem): - return item.security.name - return item.account.path + return item.name if column == OwnedSecuritiesTreeColumn.SHARES: - if isinstance(item, SecurityItem): - return f"{item.shares:,}" + if isinstance(item, TotalItem): + return None return f"{item.shares:,}" if column == OwnedSecuritiesTreeColumn.PRICE_MARKET: if isinstance(item, SecurityItem): return item.security.price.to_str_rounded(item.security.price_decimals) return None if column == OwnedSecuritiesTreeColumn.PRICE_AVERAGE: - return item.avg_price.to_str_rounded(item.security.price_decimals) + if isinstance(item, TotalItem): + return None + return item.avg_price_native.to_str_rounded(item.security.price_decimals) if column == OwnedSecuritiesTreeColumn.GAIN_NATIVE: - if item.gain_native.currency != item.gain_base.currency: - return item.gain_native.to_str_rounded() - return None + if isinstance(item, TotalItem) or item.is_base_currency: + return None + return item.gain_native.to_str_rounded() if column == OwnedSecuritiesTreeColumn.GAIN_BASE: return item.gain_base.to_str_rounded() - if column == OwnedSecuritiesTreeColumn.ABSOLUTE_RETURN: - return get_short_percentage_string(item.gain_pct) - if column == OwnedSecuritiesTreeColumn.IRR: - return get_short_percentage_string(item.irr) + if column == OwnedSecuritiesTreeColumn.RETURN_NATIVE: + if isinstance(item, TotalItem) or item.is_base_currency: + return None + return get_short_percentage_string(item.return_native) + if column == OwnedSecuritiesTreeColumn.RETURN_BASE: + return get_short_percentage_string(item.return_base) + if column == OwnedSecuritiesTreeColumn.IRR_NATIVE: + if isinstance(item, TotalItem) or item.is_base_currency: + return None + return get_short_percentage_string(item.irr_native) + if column == OwnedSecuritiesTreeColumn.IRR_BASE: + return get_short_percentage_string(item.irr_base) if column == OwnedSecuritiesTreeColumn.AMOUNT_NATIVE: - if ( - isinstance(item.base_amount, CashAmount) - and item.native_amount.currency == item.base_amount.currency - ): - return "" + if isinstance(item, TotalItem) or item.is_base_currency: + return None return item.native_amount.to_str_rounded() if column == OwnedSecuritiesTreeColumn.AMOUNT_BASE: if isinstance(item.base_amount, CashAmount): @@ -269,27 +347,43 @@ def _get_display_role_data( # noqa: PLR0911 return None def _get_sort_data( # noqa: PLR0911 - self, column: int, item: SecurityItem | AccountItem + self, column: int, item: SecurityItem | AccountItem | TotalItem ) -> str | Decimal | None: if column == OwnedSecuritiesTreeColumn.NAME: - if isinstance(item, SecurityItem): - return unicodedata.normalize("NFD", item.security.name) - return unicodedata.normalize("NFD", item.account.path) + return unicodedata.normalize("NFD", item.name) if column == OwnedSecuritiesTreeColumn.SHARES: + if isinstance(item, TotalItem): + return None return float(item.shares) if column == OwnedSecuritiesTreeColumn.PRICE_MARKET: + if isinstance(item, TotalItem): + return None return float(item.security.price.value_rounded) if column == OwnedSecuritiesTreeColumn.PRICE_AVERAGE: - return float(item.avg_price.value_rounded) + if isinstance(item, TotalItem): + return None + return float(item.avg_price_native.value_rounded) if column == OwnedSecuritiesTreeColumn.GAIN_NATIVE: + if isinstance(item, TotalItem) or item.is_base_currency: + return None return float(item.gain_native.value_rounded) if column == OwnedSecuritiesTreeColumn.GAIN_BASE: return float(item.gain_base.value_rounded) - if column == OwnedSecuritiesTreeColumn.ABSOLUTE_RETURN: - return float(item.gain_pct) - if column == OwnedSecuritiesTreeColumn.IRR: - return float(item.irr) + if column == OwnedSecuritiesTreeColumn.RETURN_NATIVE: + if isinstance(item, TotalItem) or item.is_base_currency: + return None + return float(item.return_native) + if column == OwnedSecuritiesTreeColumn.RETURN_BASE: + return float(item.return_base) + if column == OwnedSecuritiesTreeColumn.IRR_NATIVE: + if isinstance(item, TotalItem) or item.is_base_currency: + return None + return float(item.irr_native) + if column == OwnedSecuritiesTreeColumn.IRR_BASE: + return float(item.irr_base) if column == OwnedSecuritiesTreeColumn.AMOUNT_NATIVE: + if isinstance(item, TotalItem) or item.is_base_currency: + return None return float(item.native_amount.value_rounded) if column == OwnedSecuritiesTreeColumn.AMOUNT_BASE: if isinstance(item.base_amount, CashAmount): @@ -298,27 +392,40 @@ def _get_sort_data( # noqa: PLR0911 return None def _get_decoration_role_data( - self, column: int, item: SecurityItem | AccountItem + self, column: int, item: SecurityItem | AccountItem | TotalItem ) -> QIcon | None: - if column != OwnedSecuritiesTreeColumn.NAME: + if column != OwnedSecuritiesTreeColumn.NAME or isinstance(item, TotalItem): return None if isinstance(item, SecurityItem): return QIcon(icons.security) return QIcon(icons.security_account) def _get_foreground_role_data( - self, column: int, item: SecurityItem | AccountItem + self, column: int, item: SecurityItem | AccountItem | TotalItem ) -> QBrush | None: - if column == OwnedSecuritiesTreeColumn.SHARES and item.shares < 0: - return colors.get_red_brush() + if column == OwnedSecuritiesTreeColumn.SHARES: + if isinstance(item, TotalItem): + return None + if item.shares < 0: + return colors.get_red_brush() if column in { OwnedSecuritiesTreeColumn.GAIN_NATIVE, + OwnedSecuritiesTreeColumn.RETURN_NATIVE, + }: + if isinstance(item, TotalItem) or item.is_base_currency: + return None + return _get_brush_color_from_number(item.return_native) + if column in { OwnedSecuritiesTreeColumn.GAIN_BASE, - OwnedSecuritiesTreeColumn.ABSOLUTE_RETURN, + OwnedSecuritiesTreeColumn.RETURN_BASE, }: - return _get_brush_color_from_number(item.gain_pct) - if column == OwnedSecuritiesTreeColumn.IRR: - return _get_brush_color_from_number(item.irr) + return _get_brush_color_from_number(item.return_base) + if column == OwnedSecuritiesTreeColumn.IRR_NATIVE: + if isinstance(item, TotalItem) or item.is_base_currency: + return None + return _get_brush_color_from_number(item.irr_native) + if column == OwnedSecuritiesTreeColumn.IRR_BASE: + return _get_brush_color_from_number(item.irr_base) if ( column in { @@ -331,6 +438,13 @@ def _get_foreground_role_data( return colors.get_red_brush() return None + def _get_font_role_data( + self, item: SecurityItem | AccountItem | TotalItem + ) -> QFont | None: + if isinstance(item, TotalItem): + return bold_font + return None + def pre_reset_model(self) -> None: self.beginResetModel() diff --git a/src/view_models/periodic_category_stats_tree_model.py b/src/view_models/periodic_category_stats_tree_model.py index c116a5a7..4d94af5f 100644 --- a/src/view_models/periodic_category_stats_tree_model.py +++ b/src/view_models/periodic_category_stats_tree_model.py @@ -165,7 +165,7 @@ def load_periodic_category_stats( total_sum = sum(periodic_totals_row_data) average_sum = round( - total_sum / len(periodic_totals_row_data), base_currency.places + total_sum / len(periodic_totals_row_data), base_currency.decimals ) periodic_totals_row_data.append(average_sum) periodic_totals_row_data.append(total_sum) @@ -174,7 +174,7 @@ def load_periodic_category_stats( income_sum = sum(periodic_income_totals_row_data) average_income_sum = round( - income_sum / len(periodic_income_totals_row_data), base_currency.places + income_sum / len(periodic_income_totals_row_data), base_currency.decimals ) periodic_income_totals_row_data.append(average_income_sum) periodic_income_totals_row_data.append(income_sum) @@ -183,7 +183,7 @@ def load_periodic_category_stats( expense_sum = sum(periodic_expense_totals_row_data) average_expense_sum = round( - expense_sum / len(periodic_expense_totals_row_data), base_currency.places + expense_sum / len(periodic_expense_totals_row_data), base_currency.decimals ) periodic_expense_totals_row_data.append(average_expense_sum) periodic_expense_totals_row_data.append(expense_sum) diff --git a/src/view_models/transaction_table_model.py b/src/view_models/transaction_table_model.py index 3e298443..2d9a2da2 100644 --- a/src/view_models/transaction_table_model.py +++ b/src/view_models/transaction_table_model.py @@ -507,7 +507,7 @@ def _get_transaction_price_per_share(transaction: Transaction) -> Decimal: @staticmethod def _get_transaction_price_per_share_string(transaction: Transaction) -> str: if isinstance(transaction, SecurityTransaction): - return f"{transaction.price_per_share.value_normalized:,f}" + return transaction.price_per_share.to_str_normalized() return "" def _get_transaction_amount_string( @@ -521,7 +521,7 @@ def _get_transaction_amount_string( if base: try: return amount.convert( - self._base_currency, transaction.datetime_.date() + self._base_currency, transaction.date_ ).to_str_rounded() except ConversionFactorNotFoundError: return "Error!" @@ -543,9 +543,7 @@ def _get_transaction_amount_value( if base: return float( - amount.convert( - self._base_currency, transaction.datetime_.date() - ).value_rounded + amount.convert(self._base_currency, transaction.date_).value_rounded ) return float(amount.value_rounded) diff --git a/src/views/constants.py b/src/views/constants.py index bb5cf24d..b3197ae7 100644 --- a/src/views/constants.py +++ b/src/views/constants.py @@ -93,10 +93,12 @@ class OwnedSecuritiesTreeColumn(IntEnum): PRICE_AVERAGE = 3 GAIN_NATIVE = 4 GAIN_BASE = 5 - ABSOLUTE_RETURN = 6 - IRR = 7 - AMOUNT_NATIVE = 8 - AMOUNT_BASE = 9 + RETURN_NATIVE = 6 + RETURN_BASE = 7 + IRR_NATIVE = 8 + IRR_BASE = 9 + AMOUNT_NATIVE = 10 + AMOUNT_BASE = 11 class CategoryTreeColumn(IntEnum): diff --git a/src/views/dialogs/cash_transaction_dialog.py b/src/views/dialogs/cash_transaction_dialog.py index d30e0737..28cf35f4 100644 --- a/src/views/dialogs/cash_transaction_dialog.py +++ b/src/views/dialogs/cash_transaction_dialog.py @@ -757,4 +757,4 @@ def _account_changed(self) -> None: else: return self.currency_code = _account.currency.code - self.amount_decimals = _account.currency.places + self.amount_decimals = _account.currency.decimals diff --git a/src/views/dialogs/cash_transfer_dialog.py b/src/views/dialogs/cash_transfer_dialog.py index 0c17088b..96c7b053 100644 --- a/src/views/dialogs/cash_transfer_dialog.py +++ b/src/views/dialogs/cash_transfer_dialog.py @@ -194,7 +194,7 @@ def _set_spinbox_currency( spinbox.setSuffix("") return spinbox.setSuffix(" " + account.currency.code) - spinbox.setDecimals(account.currency.places) + spinbox.setDecimals(account.currency.decimals) def _get_account(self, account_path: str) -> CashAccount | None: for account in self._accounts: @@ -220,8 +220,8 @@ def _set_exchange_rate(self) -> None: try: rate_primary = self.amount_received / self.amount_sent rate_secondary = self.amount_sent / self.amount_received - rate_primary = round(rate_primary, recipient.currency.places) - rate_secondary = round(rate_secondary, sender.currency.places) + rate_primary = round(rate_primary, recipient.currency.decimals) + rate_secondary = round(rate_secondary, sender.currency.decimals) except (InvalidOperation, DivisionByZero, TypeError): self.exchangeRateLineEdit.setText("Undefined") return diff --git a/src/views/dialogs/security_transaction_dialog.py b/src/views/dialogs/security_transaction_dialog.py index 17464b99..f44e4e67 100644 --- a/src/views/dialogs/security_transaction_dialog.py +++ b/src/views/dialogs/security_transaction_dialog.py @@ -211,6 +211,7 @@ def shares(self) -> Decimal | None: @shares.setter def shares(self, shares: Decimal) -> None: self.sharesDoubleSpinBox.setValue(shares) + self._fixed_spinboxes = [] @property def price_per_share(self) -> Decimal | None: @@ -222,6 +223,7 @@ def price_per_share(self) -> Decimal | None: @price_per_share.setter def price_per_share(self, price_per_share: Decimal) -> None: self.priceDoubleSpinBox.setValue(price_per_share) + self._fixed_spinboxes = [] @property def currency_code(self) -> str | None: @@ -433,6 +435,8 @@ def _update_spinbox_values(self, spinbox: QDoubleSpinBox) -> None: total = Decimal(self.totalDoubleSpinBox.cleanText().replace(",", "")) if shares is None or shares == 0: self.priceDoubleSpinBox.setValue(0) + with QSignalBlocker(self.totalDoubleSpinBox): + self.totalDoubleSpinBox.setValue(0) return self.priceDoubleSpinBox.setValue(total / shares) elif self.sharesDoubleSpinBox not in self._fixed_spinboxes: @@ -441,6 +445,8 @@ def _update_spinbox_values(self, spinbox: QDoubleSpinBox) -> None: total = Decimal(self.totalDoubleSpinBox.cleanText().replace(",", "")) if price_per_share is None or price_per_share == 0: self.sharesDoubleSpinBox.setValue(0) + with QSignalBlocker(self.totalDoubleSpinBox): + self.totalDoubleSpinBox.setValue(0) return shares = total / price_per_share self.sharesDoubleSpinBox.setValue(shares) @@ -517,7 +523,7 @@ def _security_changed(self) -> None: self.sharesDoubleSpinBox.setSingleStep(10 ** (-security.shares_decimals)) self._update_shares_spinbox_suffix() - self.totalDoubleSpinBox.setDecimals(security.currency.places) + self.totalDoubleSpinBox.setDecimals(security.currency.decimals) def _update_shares_spinbox_suffix(self) -> None: if self.type_ == SecurityTransactionType.BUY: diff --git a/src/views/forms/settings_form.py b/src/views/forms/settings_form.py index 1e606692..00e84d46 100644 --- a/src/views/forms/settings_form.py +++ b/src/views/forms/settings_form.py @@ -38,6 +38,13 @@ def __init__(self, parent: QWidget | None = None) -> None: self.transactionTableDateFormatLineEdit.textEdited.connect( self.signal_data_changed.emit ) + self.exchangeRateDecimalsSpinBox.valueChanged.connect( + self.signal_data_changed.emit + ) + self.pricePerShareDecimalsSpinBox.valueChanged.connect( + self.signal_data_changed.emit + ) + self.checkforUpdatesCheckBox.toggled.connect(self.signal_data_changed.emit) @property def exchange_rate_decimals(self) -> int: @@ -87,6 +94,14 @@ def logs_max_size_kb(self) -> int: def logs_max_size_kb(self, value: int) -> None: self.logsSizeLimitSpinBox.setValue(value) + @property + def check_for_updates_on_startup(self) -> bool: + return self.checkforUpdatesCheckBox.isChecked() + + @check_for_updates_on_startup.setter + def check_for_updates_on_startup(self, value: bool) -> None: + self.checkforUpdatesCheckBox.setChecked(value) + def get_directory_path(self) -> str: return QFileDialog.getExistingDirectory(self) diff --git a/src/views/main_view.py b/src/views/main_view.py index 109fcbf4..569769a1 100644 --- a/src/views/main_view.py +++ b/src/views/main_view.py @@ -154,8 +154,6 @@ def _initial_setup(self) -> None: self.transaction_table_widget = TransactionTableWidget(self) self.horizontalLayout.addWidget(self.account_tree_widget) self.horizontalLayout.addWidget(self.transaction_table_widget) - self.horizontalLayout.setStretch(0, 0) - self.horizontalLayout.setStretch(1, 1) app_icon = QIcon() app_icon.addFile("icons_custom:coin-k.png", QSize(24, 24)) diff --git a/src/views/reports/attribute_report.py b/src/views/reports/attribute_report.py index 384f56d5..eaaf04c5 100644 --- a/src/views/reports/attribute_report.py +++ b/src/views/reports/attribute_report.py @@ -158,7 +158,7 @@ def _combobox_text_changed(self) -> None: ] try: currency: Currency = _periodic_stats[selected_period][0].balance.currency - places = currency.places + places = currency.decimals currency_code = currency.code except IndexError: places = 0 diff --git a/src/views/reports/cashflow_total_report.py b/src/views/reports/cashflow_total_report.py index adc23839..a40580cf 100644 --- a/src/views/reports/cashflow_total_report.py +++ b/src/views/reports/cashflow_total_report.py @@ -1,7 +1,9 @@ +from decimal import Decimal from enum import Enum, auto from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QWidget +from PyQt6.QtWidgets import QLabel, QWidget +from src.models.model_objects.currency_objects import CashAmount from src.models.statistics.cashflow_stats import CashFlowStats from src.presenters.utilities.event import Event from src.views import colors, icons @@ -44,103 +46,29 @@ def __init__(self, parent: QWidget | None = None) -> None: self.resize(1150, 600) def load_stats(self, stats: CashFlowStats) -> None: - self.incomeAmountLabel.setText(stats.incomes.balance.to_str_rounded()) - if stats.incomes.balance.is_positive(): - self.incomeAmountLabel.setStyleSheet(f"color: {colors.get_green().name()}") - - self.inwardTransfersAmountLabel.setText( - stats.inward_transfers.balance.to_str_rounded() - ) - if stats.inward_transfers.balance.is_positive(): - self.inwardTransfersAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - - self.refundsAmountLabel.setText(stats.refunds.balance.to_str_rounded()) - if stats.refunds.balance.is_positive(): - self.refundsAmountLabel.setStyleSheet(f"color: {colors.get_green().name()}") - - self.initialBalancesAmountLabel.setText(stats.initial_balances.to_str_rounded()) - if stats.initial_balances.is_positive(): - self.initialBalancesAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - - self.inflowAmountLabel.setText(stats.inflows.balance.to_str_rounded()) - if stats.inflows.balance.is_positive(): - self.inflowAmountLabel.setStyleSheet(f"color: {colors.get_green().name()}") - - self.expensesAmountLabel.setText("-" + stats.expenses.balance.to_str_rounded()) - if stats.expenses.balance.is_positive(): - self.expensesAmountLabel.setStyleSheet(f"color: {colors.get_red().name()}") - self.outwardTransfersAmountLabel.setText( - "-" + stats.outward_transfers.balance.to_str_rounded() - ) - if stats.outward_transfers.balance.is_positive(): - self.outwardTransfersAmountLabel.setStyleSheet( - f"color: {colors.get_red().name()}" - ) - self.outflowAmountLabel.setText("-" + stats.outflows.balance.to_str_rounded()) - if stats.outflows.balance.is_positive(): - self.outflowAmountLabel.setStyleSheet(f"color: {colors.get_red().name()}") - - self.gainLossSecuritiesAmountLabel.setText( - stats.delta_performance_securities.to_str_rounded() - ) - if stats.delta_performance_securities.is_positive(): - self.gainLossSecuritiesAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - elif stats.delta_performance_securities.is_negative(): - self.gainLossSecuritiesAmountLabel.setStyleSheet( - f"color: {colors.get_red().name()}" - ) - - self.gainLossCurrenciesAmountLabel.setText( - stats.delta_performance_currencies.to_str_rounded() - ) - if stats.delta_performance_currencies.is_positive(): - self.gainLossCurrenciesAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - elif stats.delta_performance_currencies.is_negative(): - self.gainLossCurrenciesAmountLabel.setStyleSheet( - f"color: {colors.get_red().name()}" - ) - - self.gainLossAmountLabel.setText(stats.delta_performance.to_str_rounded()) - if stats.delta_performance.is_positive(): - self.gainLossAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - elif stats.delta_performance.is_negative(): - self.gainLossAmountLabel.setStyleSheet(f"color: {colors.get_red().name()}") - - self.cashFlowAmountLabel.setText(stats.delta_neutral.balance.to_str_rounded()) - if stats.delta_neutral.balance.is_positive(): - self.cashFlowAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - elif stats.delta_neutral.balance.is_negative(): - self.cashFlowAmountLabel.setStyleSheet(f"color: {colors.get_red().name()}") + set_label(self.incomeAmountLabel, stats.incomes.balance) + set_label(self.inwardTransfersAmountLabel, stats.inward_transfers.balance) + set_label(self.refundsAmountLabel, stats.refunds.balance) + set_label(self.initialBalancesAmountLabel, stats.initial_balances) + set_label(self.inflowAmountLabel, stats.inflows.balance) + + set_label(self.expensesAmountLabel, -stats.expenses.balance) + set_label(self.outwardTransfersAmountLabel, -stats.outward_transfers.balance) + set_label(self.outflowAmountLabel, -stats.outflows.balance) + + set_label(self.cashFlowAmountLabel, stats.delta_neutral.balance) + set_label( + self.gainLossSecuritiesAmountLabel, stats.delta_performance_securities + ) + set_label( + self.gainLossCurrenciesAmountLabel, stats.delta_performance_currencies + ) + set_label(self.gainLossAmountLabel, stats.delta_performance) self.savingsRateAmountLabel.setText(f"{100 * stats.savings_rate:.2f}%") - if stats.savings_rate > 0: - self.savingsRateAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - elif stats.savings_rate < 0: - self.savingsRateAmountLabel.setStyleSheet( - f"color: {colors.get_red().name()}" - ) - - self.netGrowthAmountLabel.setText(stats.delta_total.to_str_rounded()) - if stats.delta_total.is_positive(): - self.netGrowthAmountLabel.setStyleSheet( - f"color: {colors.get_green().name()}" - ) - elif stats.delta_total.is_negative(): - self.netGrowthAmountLabel.setStyleSheet(f"color: {colors.get_red().name()}") + set_label_color(self.savingsRateAmountLabel, stats.savings_rate) + + set_label(self.netGrowthAmountLabel, stats.delta_total) self.actionShow_Income_Transactions.setEnabled( len(stats.incomes.transactions) > 0 @@ -225,3 +153,19 @@ def _initialize_actions(self) -> None: ) self.cashflowToolButton.setDefaultAction(self.actionShow_All_Transactions) self.recalculateToolButton.setDefaultAction(self.actionRecalculate_Report) + + +def set_label(label: QLabel, value: CashAmount) -> None: + label.setText(value.to_str_rounded()) + set_label_color(label, value) + + +def set_label_color(label: QLabel, value: CashAmount | Decimal) -> None: + _value = value.value_rounded if isinstance(value, CashAmount) else value + + if _value.is_nan() or _value == 0: + label.setStyleSheet(f"color: {colors.get_gray().name()}") + elif _value > 0: + label.setStyleSheet(f"color: {colors.get_green().name()}") + else: + label.setStyleSheet(f"color: {colors.get_red().name()}") diff --git a/src/views/reports/category_report.py b/src/views/reports/category_report.py index ce0247cb..b0e3bac5 100644 --- a/src/views/reports/category_report.py +++ b/src/views/reports/category_report.py @@ -198,7 +198,7 @@ def _convert_category_stats_to_sunburst_data( children: list[SunburstNode] = [] root_node = SunburstNode( - "Total", "Total", 0, currency.code, currency.places, [], None + "Total", "Total", 0, currency.code, currency.decimals, [], None ) for item in stats: if item.category.parent is not None: diff --git a/src/views/ui_files/dialogs/Ui_security_dialog.py b/src/views/ui_files/dialogs/Ui_security_dialog.py index 42634bd5..79bd7dcf 100644 --- a/src/views/ui_files/dialogs/Ui_security_dialog.py +++ b/src/views/ui_files/dialogs/Ui_security_dialog.py @@ -63,7 +63,7 @@ def retranslateUi(self, SecurityDialog): self.nameLabel.setText(_translate("SecurityDialog", "Name")) self.symbolLabel.setText(_translate("SecurityDialog", "Symbol")) self.symbolLineEdit.setPlaceholderText(_translate("SecurityDialog", "Optional field")) - self.decimalsLabel.setToolTip(_translate("SecurityDialog", "
Number of decimals of Security shares.
For example:
0 decimals - shares are multiples of 1
1 decimals - shares are multiples of 0.1
")) + self.decimalsLabel.setToolTip(_translate("SecurityDialog", "Number of decimals of Security shares.
For example:
0 decimals - shares are multiples of 1
1 decimals - shares are multiples of 0.1
Number of decimals of Security shares.
For example:
0 decimals - shares are multiples of 1
1 decimals - shares are multiples of 0.1
")) + self.decimalsSpinBox.setToolTip(_translate("SecurityDialog", "Number of decimals of Security shares.
For example:
0 decimals - shares are multiples of 1
1 decimals - shares are multiples of 0.1
Number of decimal places of Price per Share spinbox in Security Transaction Dialog.
")) self.pricePerShareDecimalsLabel.setText(_translate("SettingsForm", "Price per Share decimals")) self.pricePerShareDecimalsSpinBox.setToolTip(_translate("SettingsForm", "Number of decimal places of Price per Share spinbox in Security Transaction Dialog.
")) + self.checkForUpdatesLabel.setText(_translate("SettingsForm", "Check for updates on startup")) self.label.setText(_translate("SettingsForm", "For details on valid date format syntax, see Python datetime library documentation
")) self.tabWidget.setTabText(self.tabWidget.indexOf(self.generalTab), _translate("SettingsForm", "General")) self.backupsSizeLimitLabel.setText(_translate("SettingsForm", "Maximum backup directory size")) diff --git a/src/views/ui_files/forms/security_form.ui b/src/views/ui_files/forms/security_form.ui index b45af450..a0dc2e70 100644 --- a/src/views/ui_files/forms/security_form.ui +++ b/src/views/ui_files/forms/security_form.ui @@ -228,16 +228,6 @@