Skip to content

Commit

Permalink
Merge pull request #113 from qwhelan/balance
Browse files Browse the repository at this point in the history
Improve precision of which transaction caused balance to go negative
  • Loading branch information
eprbell authored May 17, 2024
2 parents 336e502 + d6086cb commit e341fa7
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 83 deletions.
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

0 comments on commit e341fa7

Please sign in to comment.