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

Per wallet application #138

Open
wants to merge 74 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
7776588
Moved Account class from balance.py to in_transaction.py
eprbell Dec 22, 2024
af75c6d
Added originates_from field and improved comments
eprbell Dec 22, 2024
b22e4e0
Initial version of transfer analysis
eprbell Dec 22, 2024
e48601e
Transfer analysis unit tests
eprbell Dec 22, 2024
6d716e8
Added mypy exceptions for per_wallet_tax_engine.py
eprbell Dec 22, 2024
2a81157
Fixed mypy warinig: removed type annotation from enum values
eprbell Dec 24, 2024
059ab9f
Fixed mypy warinig: removed type annotation from enum values
eprbell Dec 24, 2024
8a5e004
Fixed mypy warinig: removed type annotation from enum values
eprbell Dec 24, 2024
fb71b35
Fixed mypy warinig: removed type annotation from enum values
eprbell Dec 24, 2024
8e770b7
Added a few utility methods (reset_partial_amounts, add_acquired_lot,…
eprbell Jan 13, 2025
61bda9e
Refactored get_acquired_lot_for_taxable_event (reduced indentation le…
eprbell Jan 13, 2025
ad932f3
Changed long term cap gain detection to use cost_basis_timestamp (ins…
eprbell Jan 13, 2025
b93aac0
Added cost_basis_timestamp
eprbell Jan 13, 2025
b4746b4
Changed sort_key to use cost_basis_timestamp (instead of timestamp)
eprbell Jan 13, 2025
b5ca1c3
Added cost_basis_timestamp
eprbell Jan 13, 2025
4f3e0e1
Added cost_basis_timestamp
eprbell Jan 13, 2025
7c93e24
Added cost_basis_timestamp
eprbell Jan 13, 2025
b7ab5e4
Removed and split into 3 files
eprbell Jan 13, 2025
78548ad
Full implementation of transfer analysis
eprbell Jan 13, 2025
0dfd933
Unit test for per-wallet transfer analysis
eprbell Jan 13, 2025
d50f228
Added top-level comment
eprbell Jan 13, 2025
2735bae
Added type check in LotCandidates for accounting method
eprbell Jan 13, 2025
33a480a
Added cast to make pylint happy
eprbell Jan 13, 2025
27033c2
Added EOF newline
eprbell Jan 13, 2025
a783d45
Reformatted with black
eprbell Jan 13, 2025
9223fb7
Added cast to make pylint happy
eprbell Jan 13, 2025
b90d525
Reformatted with black
eprbell Jan 13, 2025
efc81a1
Merge branch 'main' into per_wallet_application
eprbell Jan 13, 2025
d903e91
Renamed a few files to improve clarity
eprbell Jan 13, 2025
8786e6a
Merge branch 'main' into per_wallet_application
eprbell Jan 14, 2025
fd6aaf4
Update src/rp2/in_transaction.py
eprbell Jan 16, 2025
75a1c8d
Added actual amount dictionary (for per-wallet application) and moved…
eprbell Jan 16, 2025
3f9da35
Added parameter names for optional parameters
eprbell Jan 16, 2025
d61334d
Moved create_unfiltered_taxable_event_set to InputData
eprbell Jan 16, 2025
76eb950
Various improvements
eprbell Jan 16, 2025
751a94d
Minor format fixes
eprbell Jan 16, 2025
187082b
Updated to new transfer analyzer OO API
eprbell Jan 16, 2025
6eab3d3
Removed resolved TODO
eprbell Jan 16, 2025
f7a71fb
Updated to new transfer analyzer OO API
eprbell Jan 16, 2025
c5ed0fb
Renamed transfer_analysis_common.py
eprbell Jan 28, 2025
9ec01cb
Added get_acquired_lot_for_timestamp()
eprbell Jan 28, 2025
b285b82
Added account to balance map
eprbell Jan 28, 2025
afd7923
Added a few utility functions in preparation of global allocation
eprbell Jan 28, 2025
c2c4ae4
Minor changes: renamed a few symbols
eprbell Jan 28, 2025
6bd7485
Refactor of old transfer_analysis_test_common.py
eprbell Jan 28, 2025
4fcf1b6
Removed skip_transfer_pointers parameter
eprbell Jan 28, 2025
f8963bd
Minor change
eprbell Jan 30, 2025
11763b7
Fixed fee handling bug, added support for per-wallet model without tr…
eprbell Feb 2, 2025
b6f01a5
Added actual amount check. Fixed diff display problem.
eprbell Feb 2, 2025
ab53be4
Adjusted tests after fixing fee handling bug. Populated want amounts …
eprbell Feb 2, 2025
322a152
Added a few comments
eprbell Feb 2, 2025
252c83d
Added a few more tests
eprbell Feb 2, 2025
f3fdd80
Fixed a FIFO test
eprbell Feb 2, 2025
ce7af17
Added utility function to serialize InputData as string list (for diffs)
eprbell Feb 2, 2025
3a99d03
Refactored diff logic, added comments, renamed a few symbols.
eprbell Feb 2, 2025
c664165
Added local artificial id infrastructure
eprbell Feb 3, 2025
0f3f5e8
Fixed a comment
eprbell Feb 4, 2025
5ddbd4e
Added/renamed test fields.
eprbell Feb 4, 2025
84f5527
Renamed test fields.
eprbell Feb 4, 2025
06986ff
Small changes to a comment
eprbell Feb 4, 2025
556ced3
Added an exception
eprbell Feb 6, 2025
462d9e5
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
878e0d3
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
f42a619
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
179855d
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
1953f05
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
67fdf19
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
25f757c
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
9ae5991
Fixed mypy/pylint warnings
eprbell Feb 6, 2025
773d029
Add a TODO
eprbell Feb 12, 2025
dc3fcf3
Added actual amount parameter to _create_per_wallet_input_data_from_t…
eprbell Feb 12, 2025
884f30e
Added new test from Github conversation (see https://github.com/eprbe…
eprbell Feb 12, 2025
6707251
Adjusted type hint to be friendly to Python < 3.10
eprbell Feb 12, 2025
8855cd4
Fixed Pylint warnings
eprbell Feb 12, 2025
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
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ disallow_any_expr = False
disallow_any_explicit = False
disallow_any_expr = False

[mypy-rp2.per_wallet_tax_engine]
disallow_any_expr = False

[mypy-rp2.abstract_country]
disallow_any_explicit = False
disallow_any_expr = False
Expand Down
39 changes: 37 additions & 2 deletions src/rp2/abstract_accounting_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from enum import Enum
from heapq import heappop, heappush
from typing import Dict, List, NamedTuple, Optional, Tuple
from typing import cast, Dict, List, NamedTuple, Optional, Tuple

from rp2.in_transaction import InTransaction
from rp2.rp2_decimal import ZERO, RP2Decimal
Expand Down Expand Up @@ -100,6 +100,10 @@ def set_from_index(self, from_index: int) -> None:
def set_to_index(self, to_index: int) -> None:
self.__to_index = to_index # pylint: disable=unused-private-member

@property
def accounting_method(self) -> "AbstractAccountingMethod":
return self._accounting_method

@property
def from_index(self) -> int:
return self.__from_index
Expand Down Expand Up @@ -132,12 +136,26 @@ def set_partial_amount(self, acquired_lot: InTransaction, amount: RP2Decimal) ->
def clear_partial_amount(self, acquired_lot: InTransaction) -> None:
self.set_partial_amount(acquired_lot, ZERO)

# Reset partial amounts to their original values and from index to zero.
def reset_partial_amounts(self, accounting_method: "AbstractAccountingMethod", original_partial_amounts: Dict[InTransaction, RP2Decimal]) -> None:
for current_transaction, original_partial_amount in original_partial_amounts.items():
self.set_partial_amount(current_transaction, original_partial_amount)
self.set_from_index(0)

def __iter__(self) -> AbstractAccountingMethodIterator:
return self._accounting_method._create_accounting_method_iterator(self)


class ChronologicalAcquiredLotCandidates(AbstractAcquiredLotCandidates):
pass
def __init__(
self,
accounting_method: "AbstractAccountingMethod",
acquired_lot_list: List[InTransaction],
acquired_lot_2_partial_amount: Dict[InTransaction, RP2Decimal],
) -> None:
super().__init__(accounting_method, acquired_lot_list, acquired_lot_2_partial_amount)
if not isinstance(accounting_method, AbstractChronologicalAccountingMethod):
raise RP2TypeError(f"Internal error: accounting_method is not of type AbstractChronologicalAccountingMethod, but of type {type(accounting_method)}")


class FeatureBasedAcquiredLotCandidates(AbstractAcquiredLotCandidates):
Expand All @@ -150,6 +168,8 @@ def __init__(
acquired_lot_2_partial_amount: Dict[InTransaction, RP2Decimal],
) -> None:
super().__init__(accounting_method, acquired_lot_list, acquired_lot_2_partial_amount)
if not isinstance(accounting_method, AbstractFeatureBasedAccountingMethod):
raise RP2TypeError(f"Internal error: accounting_method is not of type AbstractFeatureBasedAccountingMethod, but of type {type(accounting_method)}")
self.__acquired_lot_heap: List[Tuple[AcquiredLotSortKey, InTransaction]] = []

def set_to_index(self, to_index: int) -> None:
Expand All @@ -163,6 +183,21 @@ def set_to_index(self, to_index: int) -> None:
def acquired_lot_heap(self) -> List[Tuple[AcquiredLotSortKey, InTransaction]]:
return self.__acquired_lot_heap

# CAUTION:
# - acquired_lot must be the last lot chronologically.
# - this operation invalidates any outstanding iterator.
def add_acquired_lot(self, acquired_lot: InTransaction) -> None:
super().add_acquired_lot(acquired_lot)
accounting_method = cast(AbstractFeatureBasedAccountingMethod, self.accounting_method)
accounting_method.add_selected_lot_to_heap(self.__acquired_lot_heap, acquired_lot)

def reset_partial_amounts(self, accounting_method: "AbstractAccountingMethod", original_partial_amounts: Dict[InTransaction, RP2Decimal]) -> None:
if not isinstance(accounting_method, AbstractFeatureBasedAccountingMethod):
raise RP2TypeError(f"Internal error: accounting_method is not of type AbstractFeatureBasedAccountingMethod, but of type {type(accounting_method)}")
super().reset_partial_amounts(accounting_method, original_partial_amounts)
for current_transaction, original_partial_amount in original_partial_amounts.items():
accounting_method.add_selected_lot_to_heap(self.__acquired_lot_heap, current_transaction)


class AbstractAccountingMethod:
def create_lot_candidates(
Expand Down
39 changes: 21 additions & 18 deletions src/rp2/accounting_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,21 +204,24 @@ def get_acquired_lot_for_taxable_event(
acquired_lot_and_index: Optional[_AcquiredLotAndIndex] = self.__acquired_lot_avl.find_max_value_less_than(
self._get_avl_node_key_with_max_disambiguator(taxable_event.timestamp)
)
if acquired_lot_and_index is not None:
if acquired_lot_and_index.acquired_lot != self.__acquired_lot_list[acquired_lot_and_index.index]:
raise RP2RuntimeError("Internal error: acquired_lot incongruence in accounting logic")
method = self._get_accounting_method(taxable_event.timestamp.year)
lot_candidates: Optional[AbstractAcquiredLotCandidates] = self.__years_2_lot_candidates.find_max_value_less_than(taxable_event.timestamp.year)
# lot_candidates is 1:1 with acquired_lot_and_index, should always be True
if lot_candidates:
lot_candidates.set_to_index(acquired_lot_and_index.index)
acquired_lot_and_amount: Optional[AcquiredLotAndAmount] = method.seek_non_exhausted_acquired_lot(lot_candidates, new_taxable_event_amount)
if acquired_lot_and_amount:
return TaxableEventAndAcquiredLot(
taxable_event=taxable_event,
acquired_lot=acquired_lot_and_amount.acquired_lot,
taxable_event_amount=new_taxable_event_amount,
acquired_lot_amount=acquired_lot_and_amount.amount,
)

raise AcquiredLotsExhaustedException()

if acquired_lot_and_index is None:
raise AcquiredLotsExhaustedException()
if acquired_lot_and_index.acquired_lot != self.__acquired_lot_list[acquired_lot_and_index.index]:
raise RP2RuntimeError("Internal error: acquired_lot incongruence in accounting logic")
method = self._get_accounting_method(taxable_event.timestamp.year)
lot_candidates: Optional[AbstractAcquiredLotCandidates] = self.__years_2_lot_candidates.find_max_value_less_than(taxable_event.timestamp.year)
# lot_candidates is 1:1 with acquired_lot_and_index, should always be True
if not lot_candidates:
raise RP2RuntimeError("Internal error: no lot candidates found for year")
lot_candidates.set_to_index(acquired_lot_and_index.index)
acquired_lot_and_amount: Optional[AcquiredLotAndAmount] = method.seek_non_exhausted_acquired_lot(lot_candidates, new_taxable_event_amount)
if not acquired_lot_and_amount:
raise AcquiredLotsExhaustedException()

return TaxableEventAndAcquiredLot(
taxable_event=taxable_event,
acquired_lot=acquired_lot_and_amount.acquired_lot,
taxable_event_amount=new_taxable_event_amount,
acquired_lot_amount=acquired_lot_and_amount.amount,
)
8 changes: 1 addition & 7 deletions src/rp2/balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from rp2.abstract_entry import AbstractEntry
from rp2.configuration import Configuration
from rp2.in_transaction import InTransaction
from rp2.in_transaction import Account, InTransaction
from rp2.input_data import InputData
from rp2.intra_transaction import IntraTransaction
from rp2.logger import LOGGER
Expand Down Expand Up @@ -84,12 +84,6 @@ def __repr__(self) -> str:
return self.to_string(indent=0, repr_format=True)


@dataclass(frozen=True, eq=True)
class Account:
exchange: str
holder: str


class BalanceSet:
@classmethod
def type_check(cls, name: str, instance: "BalanceSet") -> "BalanceSet":
Expand Down
2 changes: 1 addition & 1 deletion src/rp2/gain_loss.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,4 @@ def is_long_term_capital_gains(self) -> bool:
if not self.taxable_event.is_earning():
raise RP2RuntimeError("Internal error: acquired lot is None but taxable event is not an earning")
return False
return (self.taxable_event.timestamp - self.acquired_lot.timestamp).days >= self.configuration.country.get_long_term_capital_gain_period()
return (self.taxable_event.timestamp - self.acquired_lot.cost_basis_timestamp).days >= self.configuration.country.get_long_term_capital_gain_period()
42 changes: 40 additions & 2 deletions src/rp2/in_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from datetime import datetime
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional

from rp2.abstract_entry import AbstractEntry
Expand All @@ -23,6 +25,12 @@
from rp2.rp2_error import RP2TypeError, RP2ValueError


@dataclass(frozen=True, eq=True)
class Account:
exchange: str
holder: str


class InTransaction(AbstractTransaction):
@classmethod
def type_check(cls, name: str, instance: AbstractEntry) -> "InTransaction":
Expand All @@ -49,6 +57,7 @@ def __init__(
unique_id: Optional[str] = None,
notes: Optional[str] = None,
from_lot: Optional["InTransaction"] = None,
cost_basis_timestamp: Optional[str] = None,
) -> None:
super().__init__(configuration, timestamp, asset, transaction_type, spot_price, row, unique_id, notes)

Expand All @@ -63,8 +72,22 @@ def __init__(
self.__crypto_in = configuration.type_check_positive_decimal("crypto_in", crypto_in, non_zero=True)
self.__crypto_fee: RP2Decimal = configuration.type_check_positive_decimal("crypto_fee", crypto_fee) if crypto_fee else ZERO
self.__fiat_fee: RP2Decimal = configuration.type_check_positive_decimal("fiat_fee", fiat_fee) if fiat_fee else ZERO

# After a transfer, the per-wallet application model is populated with artificial InTransactions that model the "to" part
# of the transfer (these artificial InTransactions are not present in the universal application model). These fields are
# only populated in the artificial InTransactions of the per-wallet application model field and describes where the funds
# come from (due to the transfer) and go to (after the transfer).
self.__from_lot: Optional[InTransaction] = InTransaction.type_check("from_lot", from_lot) if from_lot is not None else None
self.__to_lots: Dict[str, List[InTransaction]] = {}
self.__to_lots: Dict[Account, List[InTransaction]] = {}

# This field is also used only in the artificial InTransactions of the per-wallet application model. If captures all the
# upstream InTransactions that the funds came from and it is used for loop detection (it's a map from
# wallet -> InTransaction).
self.__originates_from: Dict[Account, InTransaction] = {}

self.__cost_basis_timestamp: Optional[datetime] = (
configuration.type_check_timestamp_from_string("cost_basis_timestamp", cost_basis_timestamp) if cost_basis_timestamp else None
)

if spot_price == ZERO:
raise RP2ValueError(f"{self.asset} {type(self).__name__} ({self.timestamp}, id {self.internal_id}): parameter 'spot_price' cannot be 0")
Expand Down Expand Up @@ -148,6 +171,7 @@ def to_string(self, indent: int = 0, repr_format: bool = True, extra_data: Optio
f"fiat_taxable_amount={self.fiat_taxable_amount:.4f}",
f"from_lot={self.from_lot.internal_id if self.from_lot is not None else ''}",
f"to_lots={', '.join(to_lots_string_parts)}",
f"cost_basis_timestamp={stringify(self.cost_basis_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f %z'))}",
]
if extra_data:
class_specific_data.extend(extra_data)
Expand Down Expand Up @@ -216,14 +240,28 @@ def fiat_balance_change(self) -> RP2Decimal:
def is_crypto_fee_defined(self) -> bool:
return self.crypto_fee > ZERO

# This is only populated in artificial InTransactions of the per-wallet application model and describes where the funds come from
# (i.e. the from part of an IntraTransaction that moved these funds).
@property
def from_lot(self) -> Optional["InTransaction"]:
return self.__from_lot

# This is only populated in artificial InTransactions of the per-wallet application model and describes where the funds go to after
# transfers.
@property
def to_lots(self) -> Dict[str, List["InTransaction"]]:
def to_lots(self) -> Dict[Account, List["InTransaction"]]:
return self.__to_lots

# This is only populated in artificial InTransactions of the per-wallet application model and describes the upstream chain of
# transfers that ended up in these funds being moved here.
@property
def originates_from(self) -> Dict[Account, "InTransaction"]:
return self.__originates_from

@property
def cost_basis_timestamp(self) -> datetime:
return self.__cost_basis_timestamp if self.__cost_basis_timestamp else self.timestamp

def is_taxable(self) -> bool:
return self.transaction_type.is_earn_type()

Expand Down
2 changes: 1 addition & 1 deletion src/rp2/plugin/accounting_method/lifo.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@
# https://ttlc.intuit.com/community/investments-and-rental-properties/discussion/using-lifo-method-for-cryptocurrency-or-even-stock-cost-basis/00/1433542
class AccountingMethod(AbstractFeatureBasedAccountingMethod):
def sort_key(self, lot: InTransaction) -> AcquiredLotSortKey:
return AcquiredLotSortKey(ZERO, -lot.timestamp.timestamp(), -lot.row)
return AcquiredLotSortKey(ZERO, -lot.cost_basis_timestamp.timestamp(), -lot.row)
Loading
Loading