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

")) self.decimalsLabel.setText(_translate("SecurityDialog", "Shares decimals")) self.currencyLabel.setText(_translate("SecurityDialog", "Currency")) - 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

")) + 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

")) diff --git a/src/views/ui_files/dialogs/security_dialog.ui b/src/views/ui_files/dialogs/security_dialog.ui index 97b756e0..7e1b61f6 100644 --- a/src/views/ui_files/dialogs/security_dialog.ui +++ b/src/views/ui_files/dialogs/security_dialog.ui @@ -59,7 +59,7 @@ - <html><head/><body><p>Number of decimals of Security shares.</p><p><br/>For example:</p><p>0 decimals - shares are multiples of 1</p><p>1 decimals - shares are multiples of 0.1</p></body></html> + <html><head/><body><p>Number of decimals of Security shares.<br/><br/>For example:<br/>0 decimals - shares are multiples of 1<br/>1 decimals - shares are multiples of 0.1</p></body></html> Shares decimals @@ -79,7 +79,7 @@ - <html><head/><body><p>Number of decimals of Security shares.</p><p><br/>For example:</p><p>0 decimals - shares are multiples of 1</p><p>1 decimals - shares are multiples of 0.1</p></body></html> + <html><head/><body><p>Number of decimals of Security shares.<br/><br/>For example:<br/>0 decimals - shares are multiples of 1<br/>1 decimals - shares are multiples of 0.1</p></body></html> Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter diff --git a/src/views/ui_files/forms/Ui_security_form.py b/src/views/ui_files/forms/Ui_security_form.py index e3a2b775..90c0ccbb 100644 --- a/src/views/ui_files/forms/Ui_security_form.py +++ b/src/views/ui_files/forms/Ui_security_form.py @@ -117,10 +117,6 @@ def setupUi(self, SecurityForm): self.treeView.setSortingEnabled(True) self.treeView.setObjectName("treeView") self.verticalLayout.addWidget(self.treeView) - self.label = QtWidgets.QLabel(self.securitiesOverviewTab) - self.label.setWordWrap(True) - self.label.setObjectName("label") - self.verticalLayout.addWidget(self.label) self.tabWidget.addTab(self.securitiesOverviewTab, "") self.verticalLayout_2.addWidget(self.tabWidget) self.actionAdd_Security = QtGui.QAction(SecurityForm) @@ -165,7 +161,6 @@ def retranslateUi(self, SecurityForm): self.expandAllToolButton.setText(_translate("SecurityForm", "...")) self.collapseAllToolButton.setText(_translate("SecurityForm", "...")) self.overviewSearchLineEdit.setPlaceholderText(_translate("SecurityForm", "Search Securities and Security Accounts")) - self.label.setText(_translate("SecurityForm", "NOTE: Absolute Return and Internal Rate of Return (IRR) calculations do NOT take Currency Exchange Rate fluctuation into account. They are calculated using the native Currency of the given Security. Returns can differ in other Currencies as Exchange Rates fluctuate over time.")) self.tabWidget.setTabText(self.tabWidget.indexOf(self.securitiesOverviewTab), _translate("SecurityForm", "Overview")) self.actionAdd_Security.setText(_translate("SecurityForm", "Add Security")) self.actionEdit_Security.setText(_translate("SecurityForm", "Edit Security")) diff --git a/src/views/ui_files/forms/Ui_settings_form.py b/src/views/ui_files/forms/Ui_settings_form.py index 4a50f81e..9ab5dc08 100644 --- a/src/views/ui_files/forms/Ui_settings_form.py +++ b/src/views/ui_files/forms/Ui_settings_form.py @@ -28,30 +28,38 @@ def setupUi(self, SettingsForm): self.formLayout.setObjectName("formLayout") self.generalDateFormatLabel = QtWidgets.QLabel(self.generalTab) self.generalDateFormatLabel.setObjectName("generalDateFormatLabel") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.ItemRole.LabelRole, self.generalDateFormatLabel) + self.formLayout.setWidget(3, QtWidgets.QFormLayout.ItemRole.LabelRole, self.generalDateFormatLabel) self.generalDateFormatLineEdit = QtWidgets.QLineEdit(self.generalTab) self.generalDateFormatLineEdit.setObjectName("generalDateFormatLineEdit") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.ItemRole.FieldRole, self.generalDateFormatLineEdit) + self.formLayout.setWidget(3, QtWidgets.QFormLayout.ItemRole.FieldRole, self.generalDateFormatLineEdit) self.transactionTableDateFormatLabel = QtWidgets.QLabel(self.generalTab) self.transactionTableDateFormatLabel.setObjectName("transactionTableDateFormatLabel") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.ItemRole.LabelRole, self.transactionTableDateFormatLabel) + self.formLayout.setWidget(4, QtWidgets.QFormLayout.ItemRole.LabelRole, self.transactionTableDateFormatLabel) self.transactionTableDateFormatLineEdit = QtWidgets.QLineEdit(self.generalTab) self.transactionTableDateFormatLineEdit.setObjectName("transactionTableDateFormatLineEdit") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.ItemRole.FieldRole, self.transactionTableDateFormatLineEdit) + self.formLayout.setWidget(4, QtWidgets.QFormLayout.ItemRole.FieldRole, self.transactionTableDateFormatLineEdit) self.exchangeRateDecimalsLabel = QtWidgets.QLabel(self.generalTab) self.exchangeRateDecimalsLabel.setObjectName("exchangeRateDecimalsLabel") - self.formLayout.setWidget(0, QtWidgets.QFormLayout.ItemRole.LabelRole, self.exchangeRateDecimalsLabel) + self.formLayout.setWidget(1, QtWidgets.QFormLayout.ItemRole.LabelRole, self.exchangeRateDecimalsLabel) self.exchangeRateDecimalsSpinBox = QtWidgets.QSpinBox(self.generalTab) - self.exchangeRateDecimalsSpinBox.setMinimum(9) + self.exchangeRateDecimalsSpinBox.setMinimum(0) self.exchangeRateDecimalsSpinBox.setObjectName("exchangeRateDecimalsSpinBox") - self.formLayout.setWidget(0, QtWidgets.QFormLayout.ItemRole.FieldRole, self.exchangeRateDecimalsSpinBox) + self.formLayout.setWidget(1, QtWidgets.QFormLayout.ItemRole.FieldRole, self.exchangeRateDecimalsSpinBox) self.pricePerShareDecimalsLabel = QtWidgets.QLabel(self.generalTab) self.pricePerShareDecimalsLabel.setObjectName("pricePerShareDecimalsLabel") - self.formLayout.setWidget(1, QtWidgets.QFormLayout.ItemRole.LabelRole, self.pricePerShareDecimalsLabel) + self.formLayout.setWidget(2, QtWidgets.QFormLayout.ItemRole.LabelRole, self.pricePerShareDecimalsLabel) self.pricePerShareDecimalsSpinBox = QtWidgets.QSpinBox(self.generalTab) - self.pricePerShareDecimalsSpinBox.setMinimum(9) + self.pricePerShareDecimalsSpinBox.setMinimum(0) self.pricePerShareDecimalsSpinBox.setObjectName("pricePerShareDecimalsSpinBox") - self.formLayout.setWidget(1, QtWidgets.QFormLayout.ItemRole.FieldRole, self.pricePerShareDecimalsSpinBox) + self.formLayout.setWidget(2, QtWidgets.QFormLayout.ItemRole.FieldRole, self.pricePerShareDecimalsSpinBox) + self.checkForUpdatesLabel = QtWidgets.QLabel(self.generalTab) + self.checkForUpdatesLabel.setObjectName("checkForUpdatesLabel") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.ItemRole.LabelRole, self.checkForUpdatesLabel) + self.checkforUpdatesCheckBox = QtWidgets.QCheckBox(self.generalTab) + self.checkforUpdatesCheckBox.setText("") + self.checkforUpdatesCheckBox.setChecked(True) + self.checkforUpdatesCheckBox.setObjectName("checkforUpdatesCheckBox") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.ItemRole.FieldRole, self.checkforUpdatesCheckBox) self.verticalLayout_3.addLayout(self.formLayout) self.line = QtWidgets.QFrame(self.generalTab) self.line.setFrameShape(QtWidgets.QFrame.Shape.HLine) @@ -163,6 +171,7 @@ def retranslateUi(self, SettingsForm): self.pricePerShareDecimalsLabel.setToolTip(_translate("SettingsForm", "

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 @@
- - - - NOTE: Absolute Return and Internal Rate of Return (IRR) calculations do NOT take Currency Exchange Rate fluctuation into account. They are calculated using the native Currency of the given Security. Returns can differ in other Currencies as Exchange Rates fluctuate over time. - - - true - - -
diff --git a/src/views/ui_files/forms/settings_form.ui b/src/views/ui_files/forms/settings_form.ui index 9350c48e..dad42bcc 100644 --- a/src/views/ui_files/forms/settings_form.ui +++ b/src/views/ui_files/forms/settings_form.ui @@ -35,27 +35,27 @@ - + General date format - + - + Transaction table date format - + - + <html><head/><body><p>Number of decimal places of Exchange Rate spinbox when manually setting Exchange Rate quote in Currencies and Exchange Rates Form.</p></body></html> @@ -65,17 +65,17 @@ - + <html><head/><body><p>Number of decimal places of Exchange Rate spinbox when manually setting Exchange Rate quote in Currencies and Exchange Rates Form.</p></body></html> - 9 + 0 - + <html><head/><body><p>Number of decimal places of Price per Share spinbox in Security Transaction Dialog.</p></body></html> @@ -85,13 +85,30 @@ - + <html><head/><body><p>Number of decimal places of Price per Share spinbox in Security Transaction Dialog.</p></body></html> - 9 + 0 + + + + + + + Check for updates on startup + + + + + + + + + + true diff --git a/src/views/ui_files/widgets/Ui_transaction_table_widget.py b/src/views/ui_files/widgets/Ui_transaction_table_widget.py index 276ba69c..424e51ad 100644 --- a/src/views/ui_files/widgets/Ui_transaction_table_widget.py +++ b/src/views/ui_files/widgets/Ui_transaction_table_widget.py @@ -126,6 +126,16 @@ def setupUi(self, TransactionTableWidget): self.actionDuplicate.setObjectName("actionDuplicate") self.actionCopy_UUIDs = QtGui.QAction(TransactionTableWidget) self.actionCopy_UUIDs.setObjectName("actionCopy_UUIDs") + self.actionAuto_Column_Mode = QtGui.QAction(TransactionTableWidget) + self.actionAuto_Column_Mode.setCheckable(True) + self.actionAuto_Column_Mode.setChecked(True) + self.actionAuto_Column_Mode.setEnabled(False) + self.actionAuto_Column_Mode.setObjectName("actionAuto_Column_Mode") + self.actionSave_Column_Order = QtGui.QAction(TransactionTableWidget) + self.actionSave_Column_Order.setEnabled(False) + self.actionSave_Column_Order.setObjectName("actionSave_Column_Order") + self.actionLoad_Column_Order = QtGui.QAction(TransactionTableWidget) + self.actionLoad_Column_Order.setObjectName("actionLoad_Column_Order") self.retranslateUi(TransactionTableWidget) QtCore.QMetaObject.connectSlotsByName(TransactionTableWidget) @@ -176,3 +186,7 @@ def retranslateUi(self, TransactionTableWidget): self.actionDuplicate.setShortcut(_translate("TransactionTableWidget", "D")) self.actionCopy_UUIDs.setText(_translate("TransactionTableWidget", "Copy UUIDs")) self.actionCopy_UUIDs.setToolTip(_translate("TransactionTableWidget", "Copy UUIDs of selected Transactions to clipboard")) + self.actionAuto_Column_Mode.setText(_translate("TransactionTableWidget", "Auto Column Visibility")) + self.actionAuto_Column_Mode.setToolTip(_translate("TransactionTableWidget", "If enabled, Transaction Table column visibility is determined automatically based on visible Transactions.")) + self.actionSave_Column_Order.setText(_translate("TransactionTableWidget", "Save Column Order")) + self.actionLoad_Column_Order.setText(_translate("TransactionTableWidget", "Load Column Order")) diff --git a/src/views/ui_files/widgets/transaction_table_widget.ui b/src/views/ui_files/widgets/transaction_table_widget.ui index 28dbe6a3..feb1e964 100644 --- a/src/views/ui_files/widgets/transaction_table_widget.ui +++ b/src/views/ui_files/widgets/transaction_table_widget.ui @@ -347,6 +347,36 @@ Copy UUIDs of selected Transactions to clipboard + + + true + + + true + + + false + + + Auto Column Visibility + + + If enabled, Transaction Table column visibility is determined automatically based on visible Transactions. + + + + + false + + + Save Column Order + + + + + Load Column Order + + diff --git a/src/views/widgets/charts/cash_flow_periodic_chart_view.py b/src/views/widgets/charts/cash_flow_periodic_chart_view.py index 0a40d121..75784c5f 100644 --- a/src/views/widgets/charts/cash_flow_periodic_chart_view.py +++ b/src/views/widgets/charts/cash_flow_periodic_chart_view.py @@ -75,7 +75,7 @@ def load_data( self._unit = "%" else: self._unit = stats_sequence[0].inflows.balance.currency.code - self._places = stats_sequence[0].inflows.balance.currency.places + self._places = stats_sequence[0].inflows.balance.currency.decimals bar_inflows = BarSet("Inflows") bar_inflows.setColor(QColor("darkgreen")) diff --git a/src/views/widgets/charts/cash_flow_total_chart_view.py b/src/views/widgets/charts/cash_flow_total_chart_view.py index 4eadd8b0..59454f78 100644 --- a/src/views/widgets/charts/cash_flow_total_chart_view.py +++ b/src/views/widgets/charts/cash_flow_total_chart_view.py @@ -40,7 +40,7 @@ def __init__(self, parent: QWidget | None) -> None: def load_data(self, stats: CashFlowStats) -> None: # noqa: PLR0915 self._currency_code = stats.incomes.balance.currency.code - self._places = stats.incomes.balance.currency.places + self._places = stats.incomes.balance.currency.decimals bar_income = QBarSet("Income") bar_income.setColor(QColor("darkgreen")) diff --git a/src/views/widgets/transaction_table_widget.py b/src/views/widgets/transaction_table_widget.py index a561b379..18413be0 100644 --- a/src/views/widgets/transaction_table_widget.py +++ b/src/views/widgets/transaction_table_widget.py @@ -1,8 +1,16 @@ import logging +from collections.abc import Sequence from PyQt6.QtCore import QEvent, QObject, Qt, pyqtSignal from PyQt6.QtGui import QAction, QContextMenuEvent, QCursor, QKeyEvent -from PyQt6.QtWidgets import QApplication, QLineEdit, QMenu, QTableView, QWidget +from PyQt6.QtWidgets import ( + QApplication, + QHeaderView, + QLineEdit, + QMenu, + QTableView, + QWidget, +) from src.views import icons from src.views.constants import TRANSACTION_TABLE_COLUMN_HEADERS, TransactionTableColumn from src.views.dialogs.busy_dialog import create_simple_busy_indicator @@ -56,6 +64,8 @@ class TransactionTableWidget(QWidget, Ui_TransactionTableWidget): signal_search_text_changed = pyqtSignal(str) signal_filter_transactions = pyqtSignal() signal_reset_columns = pyqtSignal() + signal_save_column_order = pyqtSignal() + signal_load_column_order = pyqtSignal() signal_income = pyqtSignal() signal_expense = pyqtSignal() @@ -105,6 +115,16 @@ def search_bar_text(self) -> str: def search_bar_text(self, text: str) -> None: self.searchLineEdit.setText(text) + @property + def auto_column_visibility(self) -> bool: + return self.actionAuto_Column_Mode.isChecked() + + @auto_column_visibility.setter + def auto_column_visibility(self, value: bool) -> None: + self.actionAuto_Column_Mode.setChecked(value) + if value: + self.actionAuto_Column_Mode.setEnabled(False) + def set_filter_tooltip(self, active_filters: str) -> None: text = "Filter Transactions" if active_filters: @@ -135,8 +155,17 @@ def resize_table_to_contents(self) -> None: self.tableView.horizontalHeader().setStretchLastSection(True) def set_column_visibility( - self, column: TransactionTableColumn, *, show: bool, resize: bool = False + self, + column: TransactionTableColumn, + *, + show: bool, + resize: bool = False, + turn_off_auto_column_visibility: bool = False, ) -> None: + if turn_off_auto_column_visibility: + self.actionAuto_Column_Mode.setChecked(False) + self.actionAuto_Column_Mode.setEnabled(True) + if show != self.tableView.isColumnHidden(column): return @@ -160,7 +189,9 @@ def set_column_visibility( def set_all_columns_visibility(self, *, show: bool) -> None: for column in TRANSACTION_TABLE_COLUMN_HEADERS: - self.set_column_visibility(column, show=show) + self.set_column_visibility( + column, show=show, turn_off_auto_column_visibility=True + ) self.resize_table_to_contents() def set_actions( @@ -204,15 +235,24 @@ def _create_column_actions(self) -> None: action.setCheckable(True) action.triggered.connect( lambda checked, column=column: self.set_column_visibility( - column=column, show=checked, resize=True + column=column, + show=checked, + resize=True, + turn_off_auto_column_visibility=True, ) ) self.column_actions.append(action) def _create_header_context_menu(self, _: QContextMenuEvent) -> None: self.header_menu = QMenu(self) + self.header_menu.addAction(self.actionAuto_Column_Mode) + self.header_menu.addSeparator() self.header_menu.addAction(self.actionShow_All_Columns) self.header_menu.addAction(self.actionHide_All_Columns) + self.header_menu.addSeparator() + self.header_menu.addAction(self.actionSave_Column_Order) + self.header_menu.addAction(self.actionLoad_Column_Order) + self.header_menu.addSeparator() self.header_menu.addAction(self.actionResize_Columns_to_Fit) self.header_menu.addAction(self.actionReset_Columns) self.header_menu.addSeparator() @@ -284,6 +324,15 @@ def _connect_actions(self) -> None: self.resize_table_to_contents ) self.actionReset_Columns.triggered.connect(self.signal_reset_columns) + self.actionAuto_Column_Mode.triggered.connect( + lambda checked: self._toggle_auto_column_mode(checked=checked) + ) + self.actionSave_Column_Order.triggered.connect( + self.signal_save_column_order.emit + ) + self.actionLoad_Column_Order.triggered.connect( + self.signal_load_column_order.emit + ) def _initialize_signals(self) -> None: self.tableView.doubleClicked.connect(self.signal_edit.emit) @@ -313,6 +362,10 @@ def _initialize_signals(self) -> None: self.searchLineEdit.textChanged.connect(self.signal_search_text_changed.emit) + self.tableView.horizontalHeader().sectionMoved.connect( + self._header_section_moved + ) + def _setup_header(self) -> None: header = self.tableView.horizontalHeader() header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -354,3 +407,30 @@ def _sort_indicator_changed(self, section: int, sort_order: Qt.SortOrder) -> Non raise finally: self._busy_dialog.close() + + def _toggle_auto_column_mode(self, *, checked: bool) -> None: + if checked: + self.signal_reset_columns.emit() + + def get_column_order(self) -> list[TransactionTableColumn]: + columns: list[TransactionTableColumn] = [] + header: QHeaderView = self.tableView.horizontalHeader() + for visual_index in range(header.count()): + logical_index = header.logicalIndex(visual_index) + column = TransactionTableColumn(logical_index) + columns.append(column) + return columns + + def load_column_order(self, column_order: Sequence[TransactionTableColumn]) -> None: + header: QHeaderView = self.tableView.horizontalHeader() + for target_index, logical_index in enumerate(column_order): + current_index = header.visualIndex(logical_index) + header.moveSection(current_index, target_index) + + def _header_section_moved( + self, section: int, old_index: int, new_index: int # noqa: ARG002 + ) -> None: + self.actionSave_Column_Order.setEnabled(True) + + def column_order_saved(self) -> None: + self.actionSave_Column_Order.setEnabled(False) diff --git a/tests/models/model_objects/test_cash_account.py b/tests/models/model_objects/test_cash_account.py index 00e81189..0f513133 100644 --- a/tests/models/model_objects/test_cash_account.py +++ b/tests/models/model_objects/test_cash_account.py @@ -237,11 +237,11 @@ def test_get_balance_with_date( ) latest_balance = account.get_balance(currency) - balance_3 = account.get_balance(currency, t3.datetime_.date()) - balance_2 = account.get_balance(currency, t2.datetime_.date()) - balance_1 = account.get_balance(currency, t1.datetime_.date()) - balance_0 = account.get_balance(currency, t1.datetime_.date() - timedelta(days=1)) - balance_m1 = account.get_balance(currency, t1.datetime_.date() - timedelta(days=2)) + balance_3 = account.get_balance(currency, t3.date_) + balance_2 = account.get_balance(currency, t2.date_) + balance_1 = account.get_balance(currency, t1.date_) + balance_0 = account.get_balance(currency, t1.date_ - timedelta(days=1)) + balance_m1 = account.get_balance(currency, t1.date_ - timedelta(days=2)) assert latest_balance == account.initial_balance + transaction_sum_3 assert balance_3 == latest_balance assert balance_2 == account.initial_balance + transaction_sum_2 diff --git a/tests/models/model_objects/test_cash_amount.py b/tests/models/model_objects/test_cash_amount.py index e5f46648..843f7eba 100644 --- a/tests/models/model_objects/test_cash_amount.py +++ b/tests/models/model_objects/test_cash_amount.py @@ -78,7 +78,7 @@ def test_value_invalid_str(value: str, currency: Currency) -> None: def test_value_valid_str(value: str, currency: Currency) -> None: value = str(value) amount = CashAmount(value, currency) - assert amount.value_rounded == round(Decimal(value), currency.places) + assert amount.value_rounded == round(Decimal(value), currency.decimals) @given( @@ -96,12 +96,12 @@ def test_currency_invalid_type(value: Decimal, currency: Currency) -> None: ) def test_value_rounded(value: Decimal, currency: Currency) -> None: amount = CashAmount(value, currency) - currency_places = currency.places + currency_places = currency.decimals if currency_places < 4: assert amount.value_rounded == round(value, currency_places) else: value_rounded = round(value, currency_places) - min_places = min(currency.places, 4) + min_places = min(currency.decimals, 4) if -value_rounded.as_tuple().exponent > min_places: value_rounded = value_rounded.normalize() if -value_rounded.as_tuple().exponent < min_places: @@ -146,7 +146,7 @@ def test_value_rounded_specific_values() -> None: def test_value_normalized(value: Decimal, currency: Currency) -> None: amount = CashAmount(value, currency) value_normalized = value.normalize() - places = min(currency.places, 4) + places = min(currency.decimals, 4) if not value_normalized.is_nan() and -value_normalized.as_tuple().exponent < places: value_normalized = value_normalized.quantize(Decimal(f"1e-{places}")) assert amount.value_normalized == value_normalized diff --git a/tests/models/model_objects/test_cash_transaction.py b/tests/models/model_objects/test_cash_transaction.py index 577215bb..006bdfe4 100644 --- a/tests/models/model_objects/test_cash_transaction.py +++ b/tests/models/model_objects/test_cash_transaction.py @@ -1,4 +1,3 @@ -import re from collections.abc import Collection from datetime import datetime, timedelta from decimal import Decimal @@ -655,7 +654,7 @@ def test_add_remove_tags_refunded( @given(transaction=cash_transactions(), category=categories(), total=st.booleans()) def test_get_amount_for_category_not_related( - transaction: CashTransaction, category: Category, total: bool # noqa: FBT001 + transaction: CashTransaction, category: Category, total: bool ) -> None: assume(category not in transaction.categories) assert transaction.get_amount_for_category(category, total=total) == CashAmount( diff --git a/tests/models/model_objects/test_currency.py b/tests/models/model_objects/test_currency.py index 98ff3fb7..7092fafe 100644 --- a/tests/models/model_objects/test_currency.py +++ b/tests/models/model_objects/test_currency.py @@ -22,7 +22,7 @@ def test_creation(code: str, places: int) -> None: assert currency.code == code.upper() assert currency.__repr__() == f"Currency({code.upper()})" assert currency.__str__() == code.upper() - assert currency.places == places + assert currency.decimals == places assert currency.convertible_to == set() assert currency.exchange_rates == {} @@ -65,7 +65,7 @@ def test_code_not_string(code: Any, places: int) -> None: places=everything_except(int), ) def test_places_invalid_type(code: Any, places: int) -> None: - with pytest.raises(TypeError, match="Currency.places must be an integer."): + with pytest.raises(TypeError, match="Currency.decimals must be an integer."): Currency(code, places) @@ -74,7 +74,7 @@ def test_places_invalid_type(code: Any, places: int) -> None: places=st.integers(max_value=-1), ) def test_places_invalid_value(code: Any, places: int) -> None: - with pytest.raises(ValueError, match="Currency.places must not be negative."): + with pytest.raises(ValueError, match="Currency.decimals must not be negative."): Currency(code, places) diff --git a/tests/models/model_objects/test_security_account.py b/tests/models/model_objects/test_security_account.py index c3f28f3a..1083f0ae 100644 --- a/tests/models/model_objects/test_security_account.py +++ b/tests/models/model_objects/test_security_account.py @@ -1,6 +1,7 @@ from collections import defaultdict -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from decimal import Decimal +from types import NoneType from typing import Any import pytest @@ -40,6 +41,8 @@ def test_creation(name: str, parent: AccountGroup | None) -> None: assert security_account.name == name assert security_account.parent == parent assert security_account.securities == {} + assert security_account.related_securities == set() + assert security_account.currency is None assert security_account.transactions == () assert security_account.__repr__() == f"SecurityAccount({expected_path})" @@ -88,6 +91,8 @@ def test_get_balance( # noqa: PLR0913 exchange_rate: Decimal, ) -> None: assume(currency_a != currency_b) + price_a = round(price_a, 4) + price_b = round(price_b, 4) datetime_ = datetime.now(user_settings.settings.time_zone) date_ = datetime_.date() exchange_rate_obj = ExchangeRate(currency_a, currency_b) @@ -150,9 +155,7 @@ def test_get_balance_with_date( t2._timestamp = t2._datetime.timestamp() t3._timestamp = t3._datetime.timestamp() transactions = [t1, t2, t3] - security.set_price( - t1.datetime_.date() - timedelta(days=10), CashAmount(1, currency) - ) + security.set_price(t1.date_ - timedelta(days=10), CashAmount(1, currency)) account.update_securities() transaction_sum_3 = sum( ( @@ -177,11 +180,11 @@ def test_get_balance_with_date( ) latest_balance = account.get_balance(currency) - balance_3 = account.get_balance(currency, t3.datetime_.date()) - balance_2 = account.get_balance(currency, t2.datetime_.date()) - balance_1 = account.get_balance(currency, t1.datetime_.date()) - balance_0 = account.get_balance(currency, t1.datetime_.date() - timedelta(days=10)) - balance_1x = account.get_balance(currency, t1.datetime_.date() + timedelta(days=1)) + balance_3 = account.get_balance(currency, t3.date_) + balance_2 = account.get_balance(currency, t2.date_) + balance_1 = account.get_balance(currency, t1.date_) + balance_0 = account.get_balance(currency, t1.date_ - timedelta(days=10)) + balance_1x = account.get_balance(currency, t1.date_ + timedelta(days=1)) assert latest_balance == transaction_sum_3 assert balance_3 == latest_balance @@ -190,6 +193,9 @@ def test_get_balance_with_date( assert balance_0 == currency.zero_amount assert balance_1x == transaction_sum_1 + assert account.currency == currency + assert account.related_securities == {security} + @given( currency=currencies(), @@ -377,7 +383,17 @@ def test_get_average_price_specific_date() -> None: assert avg_price == CashAmount(5, usd) -def test_get_average_price_invalid_date() -> None: +@given(date_=everything_except((date, NoneType))) +def test_get_average_price_invalid_date_type(date_: Any) -> None: + account = SecurityAccount("Test") + usd = Currency("USD", 2) + security = Security("Alphabet", "ABC", "Stock", usd, 1) + + with pytest.raises(TypeError, match="date or None"): + account.get_average_price(security, date_) + + +def test_get_average_price_invalid_date_value() -> None: account = SecurityAccount("Test") usd = Currency("USD", 2) security = Security("Alphabet", "ABC", "Stock", usd, 1) @@ -385,3 +401,14 @@ def test_get_average_price_invalid_date() -> None: with pytest.raises(ValueError, match="not in this SecurityAccount"): account.get_average_price(security, today.date() - timedelta(days=7)) + + +@given(currency=everything_except((Currency, NoneType))) +def test_get_average_price_invalid_currency_type(currency: Any) -> None: + account = SecurityAccount("Test") + usd = Currency("USD", 2) + security = Security("Alphabet", "ABC", "Stock", usd, 1) + date_ = datetime.now(user_settings.settings.time_zone) + + with pytest.raises(TypeError, match="Currency or None"): + account.get_average_price(security, date_, currency) diff --git a/tests/models/model_objects/test_security_transaction.py b/tests/models/model_objects/test_security_transaction.py index 602c6048..c8f8ece9 100644 --- a/tests/models/model_objects/test_security_transaction.py +++ b/tests/models/model_objects/test_security_transaction.py @@ -286,10 +286,10 @@ def test_invalid_shares_decimals( datetime_: datetime, data: st.DataObject, ) -> None: - cash_account = cash_accounts(currency=security.currency) - shares = data.draw(st.integers(min_value=1)) * Decimal( - 10 ** (-security.shares_decimals - 1) - ) + cash_account = data.draw(cash_accounts(currency=security.currency)) + decimal_part = Decimal(f"1e{(-security.shares_decimals - 1)}") + assume(decimal_part != 0) + shares = data.draw(st.integers(min_value=1)) + decimal_part with pytest.raises( ValueError, diff --git a/tests/models/statistics/test_attribute_stats.py b/tests/models/statistics/test_attribute_stats.py index af4a31e7..065d94f9 100644 --- a/tests/models/statistics/test_attribute_stats.py +++ b/tests/models/statistics/test_attribute_stats.py @@ -241,3 +241,31 @@ def test_calculate_periodic_totals_and_averages() -> None: assert len(attr_totals) == 1 assert attr_totals[tag_1].transactions == {t1a, t1b, t2, t3} assert attr_totals[tag_1].balance == CashAmount(9, currency) + + +def test_calculate_attribute_stats_no_base_currency() -> None: + tag_1 = Attribute("tag1", AttributeType.TAG) + tag_2 = Attribute("tag2", AttributeType.TAG) + + payee_1 = Attribute("payee1", AttributeType.PAYEE) + payee_2 = Attribute("payee2", AttributeType.PAYEE) + + tag_stats = calculate_attribute_stats([], None, [tag_1, tag_2]) + assert tag_stats[tag_1].no_of_transactions == 0 + assert tag_stats[tag_2].no_of_transactions == 0 + assert tag_stats[tag_1].balance is None + assert tag_stats[tag_2].balance is None + assert tag_stats[tag_1].transactions == set() + assert tag_stats[tag_2].transactions == set() + assert tag_stats[tag_1].attribute == tag_1 + assert tag_stats[tag_2].attribute == tag_2 + + payee_stats = calculate_attribute_stats([], None, [payee_1, payee_2]) + assert payee_stats[payee_1].no_of_transactions == 0 + assert payee_stats[payee_2].no_of_transactions == 0 + assert payee_stats[payee_1].balance is None + assert payee_stats[payee_2].balance is None + assert payee_stats[payee_1].transactions == set() + assert payee_stats[payee_2].transactions == set() + assert payee_stats[payee_1].attribute == payee_1 + assert payee_stats[payee_2].attribute == payee_2 diff --git a/tests/models/statistics/test_category_stats.py b/tests/models/statistics/test_category_stats.py index 586f9676..57c08931 100644 --- a/tests/models/statistics/test_category_stats.py +++ b/tests/models/statistics/test_category_stats.py @@ -1,6 +1,5 @@ from datetime import datetime -import pytest from dateutil.relativedelta import relativedelta from src.models.model_objects.attributes import ( Attribute, @@ -264,3 +263,28 @@ def test_calculate_periodic_totals_and_averages() -> None: assert len(category_totals) == 1 assert category_totals[category].transactions == {t1a, t1b, t2, t3} assert category_totals[category].balance == CashAmount(3, currency) + + +def test_calculate_attribute_stats_no_base_currency() -> None: + category_1 = Category("Category1", CategoryType.INCOME_AND_EXPENSE) + category_2 = Category("Category2", CategoryType.INCOME_AND_EXPENSE) + category_2a = Category("Category2a", CategoryType.INCOME_AND_EXPENSE, category_2) + + category_stats = calculate_category_stats( + [], None, [category_1, category_2, category_2a] + ) + assert category_stats[category_1].transactions_total == 0 + assert category_stats[category_2].transactions_total == 0 + assert category_stats[category_2a].transactions_total == 0 + assert category_stats[category_1].transactions_self == 0 + assert category_stats[category_2].transactions_self == 0 + assert category_stats[category_2a].transactions_self == 0 + assert category_stats[category_1].balance is None + assert category_stats[category_2].balance is None + assert category_stats[category_2a].balance is None + assert category_stats[category_1].transactions == set() + assert category_stats[category_2].transactions == set() + assert category_stats[category_2a].transactions == set() + assert category_stats[category_1].category == category_1 + assert category_stats[category_2].category == category_2 + assert category_stats[category_2a].category == category_2a diff --git a/tests/models/statistics/test_security_stats.py b/tests/models/statistics/test_security_stats.py index 7d1206f8..5046a2b8 100644 --- a/tests/models/statistics/test_security_stats.py +++ b/tests/models/statistics/test_security_stats.py @@ -10,8 +10,10 @@ SecurityAccount, SecurityTransaction, SecurityTransactionType, + SecurityTransfer, ) -from src.models.statistics.security_stats import calculate_irr +from src.models.record_keeper import RecordKeeper +from src.models.statistics.security_stats import calculate_irr, calculate_total_irr from src.models.user_settings import user_settings @@ -135,13 +137,13 @@ def test_calculate_irr_future_dates() -> None: def test_calculate_irr_positive_same_date() -> None: account = SecurityAccount("Test 1") - usd = Currency("USD", 2) - cash_account = CashAccount("Test Cash", usd, CashAmount(0, usd)) - security = Security("Alphabet", "ABC", "Stock", usd, 1) + currency = Currency("USD", 2) + cash_account = CashAccount("Test Cash", currency, CashAmount(0, currency)) + security = Security("Alphabet", "ABC", "Stock", currency, 1) today = datetime.now(user_settings.settings.time_zone) - security.set_price(today.date() - timedelta(days=365), CashAmount(1, usd)) - security.set_price(today.date(), CashAmount("1.1", usd)) + security.set_price(today.date() - timedelta(days=365), CashAmount(1, currency)) + security.set_price(today.date(), CashAmount("1.1", currency)) SecurityTransaction( "test", @@ -149,7 +151,7 @@ def test_calculate_irr_positive_same_date() -> None: SecurityTransactionType.BUY, security, 1, - CashAmount(1, usd), + CashAmount(1, currency), account, cash_account, ) @@ -160,7 +162,7 @@ def test_calculate_irr_positive_same_date() -> None: SecurityTransactionType.BUY, security, 1, - CashAmount(1, usd), + CashAmount(1, currency), account, cash_account, ) @@ -168,3 +170,146 @@ def test_calculate_irr_positive_same_date() -> None: irr = calculate_irr(security, [account]) assert not irr.is_nan() assert irr > 0 + + +def test_calculate_total_irr_positive() -> None: + account = SecurityAccount("Test 1") + currency = Currency("USD", 2) + cash_account = CashAccount("Test Cash", currency, CashAmount(0, currency)) + security = Security("Alphabet", "ABC", "Stock", currency, 1) + today = datetime.now(user_settings.settings.time_zone) + + record_keeper = RecordKeeper() + record_keeper._security_accounts = [account] + record_keeper._base_currency = currency + + security.set_price(today.date() - timedelta(days=365), CashAmount(1, currency)) + security.set_price(today.date(), CashAmount("1.1", currency)) + + SecurityTransaction( + "test", + today - timedelta(days=365), + SecurityTransactionType.BUY, + security, + 1, + CashAmount(1, currency), + account, + cash_account, + ) + + irr = calculate_total_irr(record_keeper) + assert not irr.is_nan() + assert math.isclose(irr, 0.1) + + +def test_calculate_total_irr_positive_same_date() -> None: + account = SecurityAccount("Test 1") + currency = Currency("USD", 2) + cash_account = CashAccount("Test Cash", currency, CashAmount(0, currency)) + security = Security("Alphabet", "ABC", "Stock", currency, 1) + today = datetime.now(user_settings.settings.time_zone) + + record_keeper = RecordKeeper() + record_keeper._security_accounts = [account] + record_keeper._base_currency = currency + + security.set_price(today.date() - timedelta(days=365), CashAmount(1, currency)) + security.set_price(today.date(), CashAmount("1.1", currency)) + + SecurityTransaction( + "test", + today - timedelta(days=365), + SecurityTransactionType.BUY, + security, + 1, + CashAmount(1, currency), + account, + cash_account, + ) + + SecurityTransaction( + "test", + today, + SecurityTransactionType.BUY, + security, + 1, + CashAmount(1, currency), + account, + cash_account, + ) + + irr = calculate_total_irr(record_keeper) + assert not irr.is_nan() + assert irr > 0 + + +def test_calculate_total_irr_no_shares() -> None: + account = SecurityAccount("Test 1") + currency = Currency("USD", 2) + cash_account = CashAccount("Test Cash", currency, CashAmount(0, currency)) + security = Security("Alphabet", "ABC", "Stock", currency, 1) + today = datetime.now(user_settings.settings.time_zone) + + record_keeper = RecordKeeper() + record_keeper._security_accounts = [account] + record_keeper._base_currency = currency + + SecurityTransaction( + "test", + today - timedelta(days=365), + SecurityTransactionType.BUY, + security, + 1, + CashAmount(1, currency), + account, + cash_account, + ) + SecurityTransaction( + "test", + today - timedelta(days=365), + SecurityTransactionType.SELL, + security, + 1, + CashAmount(1, currency), + account, + cash_account, + ) + + irr = calculate_total_irr(record_keeper) + assert irr.is_nan() + + +def test_calculate_total_irr_empty() -> None: + account = SecurityAccount("Test 1") + currency = Currency("USD", 2) + + record_keeper = RecordKeeper() + record_keeper._security_accounts = [account] + record_keeper._base_currency = currency + + irr = calculate_total_irr(record_keeper) + assert irr.is_nan() + + +def test_calculate_total_irr_only_transfers() -> None: + account_1 = SecurityAccount("Test 1") + account_2 = SecurityAccount("Test 2") + currency = Currency("USD", 2) + security = Security("Alphabet", "ABC", "Stock", currency, 1) + today = datetime.now(user_settings.settings.time_zone) + + record_keeper = RecordKeeper() + record_keeper._security_accounts = [account_1] + record_keeper._base_currency = currency + + SecurityTransfer( + "test", + today - timedelta(days=365), + security, + 1, + account_1, + account_2, + ) + + irr = calculate_total_irr(record_keeper) + assert irr.is_nan() diff --git a/tests/models/statistics/test_transaction_balance.py b/tests/models/statistics/test_transaction_balance.py index 090486fc..e750f69b 100644 --- a/tests/models/statistics/test_transaction_balance.py +++ b/tests/models/statistics/test_transaction_balance.py @@ -1,21 +1,10 @@ -import math -from datetime import datetime, timedelta from decimal import Decimal -import pytest from hypothesis import given from hypothesis import strategies as st from src.models.base_classes.transaction import Transaction -from src.models.model_objects.cash_objects import CashAccount -from src.models.model_objects.currency_objects import CashAmount, Currency -from src.models.model_objects.security_objects import ( - Security, - SecurityAccount, - SecurityTransaction, - SecurityTransactionType, -) +from src.models.model_objects.currency_objects import CashAmount from src.models.statistics.common_classes import TransactionBalance -from src.models.user_settings import user_settings from tests.models.test_assets.composites import ( cash_amounts, currencies, diff --git a/tests/models/test_assets/composites.py b/tests/models/test_assets/composites.py index 55c5382b..88512900 100644 --- a/tests/models/test_assets/composites.py +++ b/tests/models/test_assets/composites.py @@ -97,7 +97,9 @@ def cash_amounts( if currency is None: currency = draw(currencies()) value = draw( - valid_decimals(min_value=min_value, max_value=max_value, places=currency.places) + valid_decimals( + min_value=min_value, max_value=max_value, places=currency.decimals + ) ) return CashAmount(value, currency) diff --git a/tests/models/test_record_keeper_edit_objects.py b/tests/models/test_record_keeper_edit_objects.py index a0306e53..4f9f7cac 100644 --- a/tests/models/test_record_keeper_edit_objects.py +++ b/tests/models/test_record_keeper_edit_objects.py @@ -243,7 +243,7 @@ def test_edit_security_account_group_from_root_to_children() -> None: record_keeper = RecordKeeper() record_keeper.add_account_group("TEST") record_keeper.add_account_group("DUMMY PARENT") - assert len(record_keeper.root_account_items) == 2 # noqa: PLR2004 + assert len(record_keeper.root_account_items) == 2 record_keeper.edit_account_group("TEST", "DUMMY PARENT/TEST") assert len(record_keeper.root_account_items) == 1 @@ -254,7 +254,7 @@ def test_edit_security_account_group_from_child_to_root() -> None: record_keeper.add_account_group("DUMMY PARENT/TEST") assert len(record_keeper.root_account_items) == 1 record_keeper.edit_account_group("DUMMY PARENT/TEST", "TEST") - assert len(record_keeper.root_account_items) == 2 # noqa: PLR2004 + assert len(record_keeper.root_account_items) == 2 def test_edit_security_account_group_already_exists() -> None: diff --git a/tests/models/test_record_keeper_remove_objects.py b/tests/models/test_record_keeper_remove_objects.py index a2bc1fc8..0e16bf76 100644 --- a/tests/models/test_record_keeper_remove_objects.py +++ b/tests/models/test_record_keeper_remove_objects.py @@ -28,8 +28,8 @@ def test_remove_account() -> None: record_keeper.add_security_account("PARENT/SECURITY") record_keeper.add_cash_account("PARENT/CASH", "CZK", 0) - assert len(parent.children) == 2 # noqa: PLR2004 - assert len(record_keeper.accounts) == 2 # noqa: PLR2004 + assert len(parent.children) == 2 + assert len(record_keeper.accounts) == 2 record_keeper.remove_account("PARENT/SECURITY") record_keeper.remove_account("PARENT/CASH") diff --git a/tests/models/transaction_filters/test_payee_filter.py b/tests/models/transaction_filters/test_payee_filter.py index c2b04d71..176ec636 100644 --- a/tests/models/transaction_filters/test_payee_filter.py +++ b/tests/models/transaction_filters/test_payee_filter.py @@ -1,3 +1,4 @@ +import unicodedata from typing import Any import pytest @@ -37,7 +38,12 @@ def check_transaction(filter_: PayeeFilter, transaction: Transaction) -> bool: def test_creation(payees: list[Attribute], mode: FilterMode) -> None: filter_ = PayeeFilter(payees, mode) assert filter_.payees == frozenset(payees) - assert filter_.payee_names == tuple(sorted(payee.name for payee in payees)) + assert filter_.payee_names == tuple( + sorted( + (payee.name for payee in payees), + key=lambda name: unicodedata.normalize("NFD", name.lower()), + ) + ) assert filter_.mode == mode assert ( filter_.__repr__() @@ -50,7 +56,7 @@ def test_creation(payees: list[Attribute], mode: FilterMode) -> None: mode=st.sampled_from(FilterMode), ) def test_creation_invalid_type(payees: list[Any], mode: FilterMode) -> None: - with pytest.raises(TypeError, match="must be a Collection ofAttributes"): + with pytest.raises(TypeError, match="must be a Collection of Attributes"): PayeeFilter(payees, mode) diff --git a/tests/models/user_settings/test_user_settings_class.py b/tests/models/user_settings/test_user_settings_class.py index ed183ae5..61113427 100644 --- a/tests/models/user_settings/test_user_settings_class.py +++ b/tests/models/user_settings/test_user_settings_class.py @@ -1,11 +1,13 @@ import shutil +from collections.abc import Sequence from pathlib import Path from typing import Any import pytest -from hypothesis import given +from hypothesis import assume, given from hypothesis import strategies as st from src.models.user_settings.user_settings_class import UserSettings +from src.views.constants import TransactionTableColumn from tests.models.test_assets.composites import everything_except from tzlocal import get_localzone_name from zoneinfo import ZoneInfo, available_timezones @@ -243,3 +245,78 @@ def test_price_per_share_decimals_invalid_value(decimals: int) -> None: settings = UserSettings() with pytest.raises(ValueError, match="negative"): settings.price_per_share_decimals = decimals + + +@given(check=st.sampled_from([True, False])) +def test_check_for_updates_on_startup(check: bool) -> None: + settings = UserSettings() + assert settings.check_for_updates_on_startup is True + settings.check_for_updates_on_startup = check + assert settings.check_for_updates_on_startup == check + + +@given(check=everything_except(bool)) +def test_check_for_updates_on_startup_invalid_type(check: Any) -> None: + settings = UserSettings() + with pytest.raises(TypeError, match="bool"): + settings.check_for_updates_on_startup = check + + +@given( + columns=st.lists( + st.sampled_from(TransactionTableColumn), + unique=True, + min_size=len(TransactionTableColumn), + max_size=len(TransactionTableColumn), + ), +) +def test_transaction_table_column_order(columns: list[TransactionTableColumn]) -> None: + settings = UserSettings() + assert settings.transaction_table_column_order == () + settings.transaction_table_column_order = columns + assert settings.transaction_table_column_order == tuple(columns) + settings.transaction_table_column_order = columns + assert settings.transaction_table_column_order == tuple(columns) + + +@given(columns=everything_except(Sequence)) +def test_transaction_table_column_order_invalid_type(columns: Any) -> None: + settings = UserSettings() + with pytest.raises(TypeError, match="Sequence"): + settings.transaction_table_column_order = columns + + +def test_transaction_table_column_order_invalid_member_type() -> None: + settings = UserSettings() + columns = [True, (), {}, 1.0] + with pytest.raises(TypeError, match="TransactionTableColumn"): + settings.transaction_table_column_order = columns + + +@given( + columns=st.lists( + st.sampled_from(TransactionTableColumn), + unique=False, + min_size=len(TransactionTableColumn), + max_size=len(TransactionTableColumn), + ) +) +def test_transaction_table_column_order_duplicates( + columns: list[TransactionTableColumn], +) -> None: + assume(len(columns) != len(set(columns))) + settings = UserSettings() + with pytest.raises(ValueError, match="duplicate"): + settings.transaction_table_column_order = columns + + +@given( + columns=st.lists(st.sampled_from(TransactionTableColumn), unique=True, min_size=1) +) +def test_transaction_table_column_order_invalid_length( + columns: list[TransactionTableColumn], +) -> None: + assume(len(columns) != len(TransactionTableColumn)) + settings = UserSettings() + with pytest.raises(ValueError, match="exactly"): + settings.transaction_table_column_order = columns diff --git a/tests/models/utilities/test_calculations.py b/tests/models/utilities/test_calculations.py index d3c4287c..ce2b4a51 100644 --- a/tests/models/utilities/test_calculations.py +++ b/tests/models/utilities/test_calculations.py @@ -41,10 +41,10 @@ def test_calculate_attribute_stats() -> None: payee_stats = payee_stats[payee] tag_stats = tag_stats[tag] assert payee_stats.attribute == payee - assert payee_stats.no_of_transactions == 7 # noqa: PLR2004 + assert payee_stats.no_of_transactions == 7 assert payee_stats.balance.value_rounded == -1 - 2 + 3 + 5 + 6 + 7 + 1 assert tag_stats.attribute == tag - assert tag_stats.no_of_transactions == 7 # noqa: PLR2004 + assert tag_stats.no_of_transactions == 7 assert tag_stats.balance.value_rounded == -1 - 2 + 3 - 4 + 6 + 7 + 1 @@ -59,13 +59,13 @@ def test_calculate_category_stats() -> None: category_child_stats = category_stats_dict[category_expense_child] assert category_stats.category == category_expense - assert category_stats.transactions_self == 3 # noqa: PLR2004 - assert category_stats.transactions_total == 4 # noqa: PLR2004 + assert category_stats.transactions_self == 3 + assert category_stats.transactions_total == 4 assert category_stats.balance.value_rounded == -1 - 2 - 4 - 4 + 1 assert category_child_stats.category == category_expense_child - assert category_child_stats.transactions_self == 2 # noqa: PLR2004 - assert category_child_stats.transactions_total == 2 # noqa: PLR2004 + assert category_child_stats.transactions_self == 2 + assert category_child_stats.transactions_total == 2 assert category_child_stats.balance.value_rounded == -2 - 4 diff --git a/tests/view_models/test_owned_securities_tree_model.py b/tests/view_models/test_owned_securities_tree_model.py index c9b85c16..4eaab218 100644 --- a/tests/view_models/test_owned_securities_tree_model.py +++ b/tests/view_models/test_owned_securities_tree_model.py @@ -4,6 +4,7 @@ from PyQt6.QtWidgets import QTreeView, QWidget from pytestqt.modeltest import ModelTester from pytestqt.qtbot import QtBot +from src.models.statistics.security_stats import calculate_total_irr from src.presenters.form.security_form_presenter import SecurityFormPresenter from src.utilities import constants from src.view_models.owned_securities_tree_model import OwnedSecuritiesTreeModel @@ -27,13 +28,16 @@ def test_owned_securities_tree_model(qtbot: QtBot, qtmodeltester: ModelTester) - presenter = SecurityFormPresenter(view=form, record_keeper=record_keeper) irrs = presenter._calculate_irrs() + total_irr = calculate_total_irr(record_keeper) proxy = QSortFilterProxyModel(parent) model = OwnedSecuritiesTreeModel( tree_view=view, proxy=proxy, ) - model.load_data(record_keeper.security_accounts, irrs, record_keeper.base_currency) + model.load_data( + record_keeper.security_accounts, irrs, total_irr, record_keeper.base_currency + ) proxy.setSourceModel(model) view.setModel(proxy)