Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve precision of which transaction caused balance to go negative #113

Merged
merged 6 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/rp2/abstract_entry_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from copy import copy
from datetime import date, datetime
from typing import Dict, List, Optional, Set
from typing import Dict, Iterable, Iterator, List, Optional, Set, TypeVar

from rp2.abstract_entry import AbstractEntry
from rp2.configuration import MAX_DATE, MIN_DATE, Configuration
Expand All @@ -24,8 +24,10 @@
from rp2.out_transaction import OutTransaction
from rp2.rp2_error import RP2TypeError, RP2ValueError

AbstractEntrySetSubclass = TypeVar("AbstractEntrySetSubclass", bound="AbstractEntrySet")

class AbstractEntrySet:

class AbstractEntrySet(Iterable[AbstractEntry]):
def __init__(
self,
configuration: Configuration,
Expand All @@ -49,9 +51,9 @@ def __init__(
self._entry_to_parent: Dict[AbstractEntry, Optional[AbstractEntry]] = {}
self.__is_sorted: bool = False

def duplicate(self, from_date: date = MIN_DATE, to_date: date = MAX_DATE) -> "AbstractEntrySet":
def duplicate(self: AbstractEntrySetSubclass, from_date: date = MIN_DATE, to_date: date = MAX_DATE) -> AbstractEntrySetSubclass:
# pylint: disable=protected-access
result: AbstractEntrySet = copy(self)
result: AbstractEntrySetSubclass = copy(self)
result._from_date = from_date
result._to_date = to_date
# Force sort to recompute fields that are affected by time filter
Expand Down Expand Up @@ -167,7 +169,7 @@ def __iter__(self) -> "EntrySetIterator":
return EntrySetIterator(self)


class EntrySetIterator:
class EntrySetIterator(Iterator[AbstractEntry]):
def __init__(self, entry_set: AbstractEntrySet) -> None:
self.__entry_set: AbstractEntrySet = entry_set
self.__entry_set_size: int = self.__entry_set.count
Expand Down
105 changes: 58 additions & 47 deletions src/rp2/balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
# limitations under the License.

from dataclasses import dataclass
from datetime import date
from datetime import date, datetime
from decimal import Decimal
from typing import Callable, Dict, List, Optional, cast
from typing import Callable, Dict, List, Optional

from prezzemolo.utility import to_string

from rp2.abstract_entry import AbstractEntry
from rp2.configuration import Configuration
from rp2.in_transaction import InTransaction
from rp2.input_data import InputData
Expand All @@ -28,7 +29,6 @@
from rp2.rp2_decimal import ZERO, RP2Decimal
from rp2.rp2_error import RP2TypeError, RP2ValueError


CRYPTO_BALANCE_DECIMAL_MASK: Decimal = Decimal("1." + "0" * 10)


Expand Down Expand Up @@ -119,53 +119,60 @@ def __init__(
from_account: Account
to_account: Account

# Balances for bought and earned currency
for transaction in self.__input_data.unfiltered_in_transaction_set:
if transaction.timestamp.date() > to_date:
break
in_transaction: InTransaction = cast(InTransaction, transaction)
to_account = Account(in_transaction.exchange, in_transaction.holder)
acquired_balances[to_account] = acquired_balances.get(to_account, ZERO) + in_transaction.crypto_in
final_balances[to_account] = final_balances.get(to_account, ZERO) + in_transaction.crypto_in
in_transactions = list(self.__input_data.unfiltered_in_transaction_set)
intra_transactions = list(self.__input_data.unfiltered_intra_transaction_set)
out_transactions = list(self.__input_data.unfiltered_out_transaction_set)

# Balances for currency that is moved across accounts
for transaction in self.__input_data.unfiltered_intra_transaction_set:
if transaction.timestamp.date() > to_date:
break
intra_transaction: IntraTransaction = cast(IntraTransaction, transaction)
from_account = Account(intra_transaction.from_exchange, intra_transaction.from_holder)
to_account = Account(intra_transaction.to_exchange, intra_transaction.to_holder)
sent_balances[from_account] = sent_balances.get(from_account, ZERO) + intra_transaction.crypto_sent
received_balances[to_account] = received_balances.get(to_account, ZERO) + intra_transaction.crypto_received
final_balances[from_account] = final_balances.get(from_account, ZERO) - intra_transaction.crypto_sent
final_balances[to_account] = final_balances.get(to_account, ZERO) + intra_transaction.crypto_received
if (
not RP2Decimal.is_equal_within_precision(final_balances[from_account], ZERO, CRYPTO_BALANCE_DECIMAL_MASK)
and final_balances[from_account] < ZERO
and not configuration.allow_negative_balances
):
raise RP2ValueError(
f'{intra_transaction.asset} balance of account "{from_account.exchange}" (holder "{from_account.holder}") went negative '
f'({final_balances[from_account]}) on the following transaction: {intra_transaction}'
)

# Balances for sold and gifted currency
for transaction in self.__input_data.unfiltered_out_transaction_set:
transactions = in_transactions + intra_transactions + out_transactions
transactions = sorted(
transactions,
key=_transaction_time_sort_key,
)

# Balances for bought and earned currency
for transaction in transactions:
if transaction.timestamp.date() > to_date:
break
out_transaction: OutTransaction = cast(OutTransaction, transaction)
from_account = Account(out_transaction.exchange, out_transaction.holder)
sent_balances[from_account] = sent_balances.get(from_account, ZERO) + out_transaction.crypto_out_no_fee + out_transaction.crypto_fee
final_balances[from_account] = final_balances.get(from_account, ZERO) - out_transaction.crypto_out_no_fee - out_transaction.crypto_fee
if (
not RP2Decimal.is_equal_within_precision(final_balances[from_account], ZERO, CRYPTO_BALANCE_DECIMAL_MASK)
and final_balances[from_account] < ZERO
and not configuration.allow_negative_balances
):
raise RP2ValueError(
f'{out_transaction.asset} balance of account "{from_account.exchange}" (holder "{from_account.holder}") went negative '
f'({final_balances[from_account]}) on the following transaction: {out_transaction}'
)
if isinstance(transaction, InTransaction):
in_transaction: InTransaction = transaction
to_account = Account(in_transaction.exchange, in_transaction.holder)
acquired_balances[to_account] = acquired_balances.get(to_account, ZERO) + in_transaction.crypto_in
final_balances[to_account] = final_balances.get(to_account, ZERO) + in_transaction.crypto_in

# Balances for currency that is moved across accounts
if isinstance(transaction, IntraTransaction):
intra_transaction: IntraTransaction = transaction
from_account = Account(intra_transaction.from_exchange, intra_transaction.from_holder)
to_account = Account(intra_transaction.to_exchange, intra_transaction.to_holder)
sent_balances[from_account] = sent_balances.get(from_account, ZERO) + intra_transaction.crypto_sent
received_balances[to_account] = received_balances.get(to_account, ZERO) + intra_transaction.crypto_received
final_balances[from_account] = final_balances.get(from_account, ZERO) - intra_transaction.crypto_sent
final_balances[to_account] = final_balances.get(to_account, ZERO) + intra_transaction.crypto_received
if (
not RP2Decimal.is_equal_within_precision(final_balances[from_account], ZERO, CRYPTO_BALANCE_DECIMAL_MASK)
and final_balances[from_account] < ZERO
and not configuration.allow_negative_balances
):
raise RP2ValueError(
f'{intra_transaction.asset} balance of account "{from_account.exchange}" (holder "{from_account.holder}") went negative '
f"({final_balances[from_account]}) on the following transaction: {intra_transaction}"
)

# Balances for sold and gifted currency
if isinstance(transaction, OutTransaction):
out_transaction: OutTransaction = transaction
from_account = Account(out_transaction.exchange, out_transaction.holder)
sent_balances[from_account] = sent_balances.get(from_account, ZERO) + out_transaction.crypto_out_no_fee + out_transaction.crypto_fee
final_balances[from_account] = final_balances.get(from_account, ZERO) - out_transaction.crypto_out_no_fee - out_transaction.crypto_fee
if (
not RP2Decimal.is_equal_within_precision(final_balances[from_account], ZERO, CRYPTO_BALANCE_DECIMAL_MASK)
and final_balances[from_account] < ZERO
and not configuration.allow_negative_balances
):
raise RP2ValueError(
f'{out_transaction.asset} balance of account "{from_account.exchange}" (holder "{from_account.holder}") went negative '
f"({final_balances[from_account]}) on the following transaction: {out_transaction}"
)

for account, final_balance in final_balances.items():
balance = Balance(
Expand Down Expand Up @@ -236,3 +243,7 @@ def __next__(self) -> Balance:

def _balance_sort_key(balance: Balance) -> str:
return f"{balance.exchange}_{balance.holder}"


def _transaction_time_sort_key(transaction: AbstractEntry) -> datetime:
return transaction.timestamp
4 changes: 2 additions & 2 deletions src/rp2/computed_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ def __init__(
TransactionSet.type_check("taxable_event_set", unfiltered_taxable_event_set, EntrySetType.MIXED, asset, True)
GainLossSet.type_check("gain_loss_set", unfiltered_gain_loss_set)

self.__filtered_taxable_event_set: TransactionSet = cast(TransactionSet, unfiltered_taxable_event_set.duplicate(from_date=from_date, to_date=to_date))
self.__filtered_gain_loss_set: GainLossSet = cast(GainLossSet, unfiltered_gain_loss_set.duplicate(from_date=from_date, to_date=to_date))
self.__filtered_taxable_event_set: TransactionSet = unfiltered_taxable_event_set.duplicate(from_date=from_date, to_date=to_date)
self.__filtered_gain_loss_set: GainLossSet = unfiltered_gain_loss_set.duplicate(from_date=from_date, to_date=to_date)

yearly_gain_loss_list: List[YearlyGainLoss] = self._create_yearly_gain_loss_list(unfiltered_gain_loss_set, to_date)
LOGGER.debug("%s: Created yearly gain-loss list", input_data.asset)
Expand Down
13 changes: 3 additions & 10 deletions src/rp2/input_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
# limitations under the License.

from datetime import date
from typing import cast

from rp2.configuration import MAX_DATE, MIN_DATE, Configuration
from rp2.entry_types import EntrySetType
Expand Down Expand Up @@ -53,15 +52,9 @@ def __init__(
if not isinstance(to_date, date):
raise RP2TypeError("Parameter 'to_date' is not of type date")

self.__filtered_in_transaction_set: TransactionSet = cast(
TransactionSet, self.__unfiltered_in_transaction_set.duplicate(from_date=from_date, to_date=to_date)
)
self.__filtered_out_transaction_set: TransactionSet = cast(
TransactionSet, self.__unfiltered_out_transaction_set.duplicate(from_date=from_date, to_date=to_date)
)
self.__filtered_intra_transaction_set: TransactionSet = cast(
TransactionSet, self.__unfiltered_intra_transaction_set.duplicate(from_date=from_date, to_date=to_date)
)
self.__filtered_in_transaction_set: TransactionSet = self.__unfiltered_in_transaction_set.duplicate(from_date=from_date, to_date=to_date)
self.__filtered_out_transaction_set: TransactionSet = self.__unfiltered_out_transaction_set.duplicate(from_date=from_date, to_date=to_date)
self.__filtered_intra_transaction_set: TransactionSet = self.__unfiltered_intra_transaction_set.duplicate(from_date=from_date, to_date=to_date)

@property
def asset(self) -> str:
Expand Down
8 changes: 3 additions & 5 deletions src/rp2/plugin/report/jp/tax_report_jp.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@
from enum import Enum
from itertools import chain
from pathlib import Path
from typing import Any, Dict, List, NamedTuple, Optional, Set, cast
from typing import Any, Dict, List, NamedTuple, Optional, Set

from rp2.abstract_country import AbstractCountry
from rp2.abstract_entry import AbstractEntry
from rp2.abstract_transaction import AbstractTransaction
from rp2.computed_data import ComputedData
from rp2.configuration import MAX_DATE, MIN_DATE
Expand Down Expand Up @@ -169,15 +168,14 @@ def __generate_asset(self, computed_data: ComputedData, output_file: Any) -> Non
in_transaction_set: TransactionSet = computed_data.in_transaction_set
out_transaction_set: TransactionSet = computed_data.out_transaction_set
intra_transaction_set: TransactionSet = computed_data.intra_transaction_set
entry: AbstractEntry
entry: AbstractTransaction
year: int
years_2_transaction_sets: Dict[int, List[AbstractTransaction]] = {}
previous_year_row_offset: int = 0

# Sort all in and out transactions by year, the fee from intra transactions must be reported
for entry in chain(in_transaction_set, out_transaction_set, intra_transaction_set): # type: ignore
transaction: AbstractTransaction = cast(AbstractTransaction, entry)
years_2_transaction_sets.setdefault(transaction.timestamp.year, []).append(entry)
years_2_transaction_sets.setdefault(entry.timestamp.year, []).append(entry)

for year, transaction_set in years_2_transaction_sets.items():
# Sort the transactions by timestamp and generate sheet by year
Expand Down
Loading
Loading