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

Make FIFO accounting_method linear time #115

Merged
merged 6 commits into from
Jun 1, 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
22 changes: 16 additions & 6 deletions src/rp2/abstract_accounting_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,22 @@ def __init__(
accounting_method: "AbstractAccountingMethod",
acquired_lot_list: List[InTransaction],
acquired_lot_2_partial_amount: Dict[InTransaction, RP2Decimal],
up_to_index: int,
) -> None:
self.__accounting_method: AbstractAccountingMethod = accounting_method
self.__acquired_lot_list = acquired_lot_list
self.__acquired_lot_2_partial_amount = acquired_lot_2_partial_amount
self.__up_to_index = up_to_index
self.__to_index = 0
self.__from_index = 0

def set_to_index(self, to_index: int) -> None:
self.__to_index = to_index

def set_from_index(self, from_index: int) -> None:
self.__from_index = from_index

@property
def from_index(self) -> int:
return self.__from_index

def has_partial_amount(self, acquired_lot: InTransaction) -> bool:
return acquired_lot in self.__acquired_lot_2_partial_amount
Expand All @@ -60,14 +70,14 @@ def clear_partial_amount(self, acquired_lot: InTransaction) -> None:
self.set_partial_amount(acquired_lot, ZERO)

def __iter__(self) -> "AccountingMethodIterator":
return AccountingMethodIterator(self.__acquired_lot_list, self.__up_to_index, self.__accounting_method.lot_candidates_order())
return AccountingMethodIterator(self.__acquired_lot_list, self.__from_index, self.__to_index, self.__accounting_method.lot_candidates_order())


class AccountingMethodIterator:
def __init__(self, acquired_lot_list: List[InTransaction], up_to_index: int, order_type: AcquiredLotCandidatesOrder) -> None:
def __init__(self, acquired_lot_list: List[InTransaction], from_index: int, to_index: int, order_type: AcquiredLotCandidatesOrder) -> None:
self.__acquired_lot_list = acquired_lot_list
self.__start_index = 0 if order_type == AcquiredLotCandidatesOrder.OLDER_TO_NEWER else up_to_index
self.__end_index = up_to_index if order_type == AcquiredLotCandidatesOrder.OLDER_TO_NEWER else 0
self.__start_index = from_index if order_type == AcquiredLotCandidatesOrder.OLDER_TO_NEWER else to_index
self.__end_index = to_index if order_type == AcquiredLotCandidatesOrder.OLDER_TO_NEWER else from_index
self.__step = 1 if order_type == AcquiredLotCandidatesOrder.OLDER_TO_NEWER else -1
self.__index = self.__start_index
self.__order_type = order_type
Expand Down
48 changes: 33 additions & 15 deletions src/rp2/accounting_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def type_check(cls, name: str, instance: "AccountingEngine") -> "AccountingEngin

def __init__(self, years_2_methods: AVLTree[int, AbstractAccountingMethod]) -> None:
self.__years_2_methods: AVLTree[int, AbstractAccountingMethod] = years_2_methods
self.__years_2_lot_candidates: AVLTree[int, AcquiredLotCandidates] = AVLTree()
if not self.__years_2_methods:
raise RP2RuntimeError("Internal error: no accounting method defined")

Expand Down Expand Up @@ -114,6 +115,22 @@ def initialize(
if not self.__acquired_lot_avl.root:
raise RP2RuntimeError("Internal error: AVL tree has no root node")

to_visit = []
node = self.__years_2_methods.root
while node is not None:
self.__years_2_lot_candidates.insert_node(
node.key, AcquiredLotCandidates(node.value, self.__acquired_lot_list, self.__acquired_lot_2_partial_amount)
)
if node.left:
to_visit.append(node.left)
if node.right:
to_visit.append(node.right)

if len(to_visit) > 0:
node = to_visit.pop()
else:
break

# AVL tree node keys have this format: <timestamp>_<internal_id>. The internal_id part is needed to disambiguate transactions
# that have the same timestamp. Timestamp is in format "YYYYmmddHHMMSS.ffffff" and internal_id is padded right in a string of fixed
# length (KEY_DISAMBIGUATOR_LENGTH).
Expand Down Expand Up @@ -182,25 +199,26 @@ def get_acquired_lot_for_taxable_event(
acquired_lot_amount: RP2Decimal,
) -> TaxableEventAndAcquiredLot:
new_taxable_event_amount: RP2Decimal = taxable_event_amount - acquired_lot_amount
avl_result: Optional[_AcquiredLotAndIndex] = self.__acquired_lot_avl.find_max_value_less_than(
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 avl_result is not None:
if avl_result.acquired_lot != self.__acquired_lot_list[avl_result.index]:
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: AcquiredLotCandidates = AcquiredLotCandidates(
method, self.__acquired_lot_list, self.__acquired_lot_2_partial_amount, avl_result.index
)
acquired_lot_and_amount: Optional[AcquiredLotAndAmount] = method.seek_non_exhausted_acquired_lot(
lot_candidates, taxable_event, 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,
lot_candidates: Optional[AcquiredLotCandidates] = 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:
qwhelan marked this conversation as resolved.
Show resolved Hide resolved
lot_candidates.set_to_index(acquired_lot_and_index.index)
acquired_lot_and_amount: Optional[AcquiredLotAndAmount] = method.seek_non_exhausted_acquired_lot(
lot_candidates, taxable_event, 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()
9 changes: 5 additions & 4 deletions src/rp2/plugin/accounting_method/fifo.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from rp2.rp2_decimal import ZERO, RP2Decimal


# FIFO plugin. See https://www.investopedia.com/terms/l/fifo.asp. This plugin uses universal application, not per-wallet application:
# FIFO (First In, First Out) plugin. See https://www.investopedia.com/terms/l/fifo.asp. This plugin uses universal application, not per-wallet application:
# this means there is one queue for each coin across every wallet and exchange and the accounting method is applied to each such queue.
# More on this at https://www.forbes.com/sites/shehanchandrasekera/2020/09/17/what-crypto-taxpayers-need-to-know-about-fifo-lifo-hifo-specific-id/
class AccountingMethod(AbstractAccountingMethod):
Expand All @@ -38,8 +38,8 @@ def seek_non_exhausted_acquired_lot(
selected_acquired_lot_amount: RP2Decimal = ZERO
selected_acquired_lot: Optional[InTransaction] = None
acquired_lot: InTransaction
# This loop causes O(m*n) complexity, where m is the number of acquired lots and n in the number of taxable events. The taxable
# event loop is in the caller. Non-trivial optimizations are possible using different data structures but they need to be researched.
# The FIFO plugin features linear complexity by setting lot_candidates from_index to the first non-exhausted lot (to_index is set in the caller).
# As FIFO ensures no non-exhausted lots can exist to the left of this index, this approach is O(n).
for acquired_lot in lot_candidates:
acquired_lot_amount: RP2Decimal = ZERO

Expand All @@ -48,7 +48,8 @@ def seek_non_exhausted_acquired_lot(
elif lot_candidates.get_partial_amount(acquired_lot) > ZERO:
acquired_lot_amount = lot_candidates.get_partial_amount(acquired_lot)
else:
# The acquired lot has zero partial amount
# The acquired lot has zero partial amount, so we can advance our start offset
lot_candidates.set_from_index(lot_candidates.from_index + 1)
continue

selected_acquired_lot_amount = acquired_lot_amount
Expand Down
Loading