Skip to content

Commit

Permalink
Make FIFO accounting_method linear time
Browse files Browse the repository at this point in the history
  • Loading branch information
qwhelan committed May 18, 2024
1 parent e341fa7 commit 32689b8
Show file tree
Hide file tree
Showing 3 changed files with 48 additions and 21 deletions.
20 changes: 15 additions & 5 deletions src/rp2/abstract_accounting_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,23 @@ 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 = 0
self.__from_index = 0

def set_up_to_index(self, up_to_index: int) -> None:
self.__up_to_index = up_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.__up_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, up_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 up_to_index
self.__end_index = up_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
40 changes: 28 additions & 12 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.__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,20 @@ 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.__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 @@ -189,18 +204,19 @@ def get_acquired_lot_for_taxable_event(
if avl_result.acquired_lot != self.__acquired_lot_list[avl_result.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.__lot_candidates.find_max_value_less_than(taxable_event.timestamp.year)

if lot_candidates:
lot_candidates.set_up_to_index(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,
)

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.
# This loop avoids O(m*n) complexity by keeping track of the index of the most recently exhausted lot.
# 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

0 comments on commit 32689b8

Please sign in to comment.