Skip to content

Adding Per‐Wallet Application to RP2

eprbell edited this page Nov 23, 2024 · 42 revisions

Adding Per-Wallet Application to RP2

This is a WIP: please check back later

Introduction

This design document focuses on how to add per-wallet application support to the RP2 tax engine, which currently only supports universal application.

For a definition of per-wallet vs universal application check this article. The issue is discussed in the context of RP2 at #135.

Requirements

The following features are needed:

  • the tax engine should support both universal and per-wallet application;
  • different countries can support one or both application types. The country plugin should express this;
  • some countries (like the US) change their supported type of application at a certain time. E.g. the US supported both universal and per-wallet application up to 2024, but only per-wallet application after that. The country plugin should express this as well;
  • the user should be allowed to change the application type and/or the accounting method year over year, in any combination that is supported by the country plugin.

High-Level Design

At high level adding per-wallet semantics changes the compute_tax function as as follows:

  • the function receives in input an object called InputData, which is the result of parsing the ODS file. This instance of InputData will be used for universal application;
  • the universal InputData is processed with a transfer analysis algorithm, to understand which wallet each lot ends up at after transfers. This results in as many new instances of InputData as there are wallets. The new set of InputData objects will be used for per-wallet application;
  • the tax engine can now run using either universal application (via the initial InputData) or per-wallet application (via the new InputData objects that resulted from transfer analysis). Note that computing per-wallet taxes means running the tax computation algorithm once per per-wallet InputData;
  • at the end of the computation, if universal application was selected for the last year of taxes, return the universal ComputedData (similar to what happens in today's version). If per-wallet application was selected for the last year of taxes, normalize/unify the per-wallet ComputedData and return the result.

This approach strives to minimize change to the tax engine and report generators (which have been thoroughly tested): it just adds an extra layer between input parser and tax engine (for transfer analysis), and then another layer between tax engine and report generators (for result unification).

Note that:

  • transfer analysis isn't simply about tracking lots and where they go: transferring can split a lot into fractions. E.g. if one buys 1 BTC on Coinbase and then sends 0.5 BTC to a hardware wallet, there was one lot before the transfer, and, after transferring, the lots became two.
  • the method to select which lot to transfer can be one of the existing accounting methods (FIFO, LIFO, etc., which can be reused as-is). Some possible options:
    • always FIFO;
    • Same as the accounting method;
    • Let user select a method that may be different than the accounting method.

Detailed Design

Transfer Analysis and Per-wallet InputData Creation

The _compute_per_wallet_input_data function:

  • Receives an InputData containing in, out and intra-transactions, and reflecting what the user entered in the ODS file. This InputData object is used by universal application and we'll refer to it as universal InputData;
  • Outputs 1 or more InputData objects (one per wallet). Each of these objects contains the in, out and intra transactions that originate from that wallet. These InputData objects are used by per-wallet application and we'll refer to them as per-wallet InputData;

This function creates artificial InTransactions to capture the receive side of intra transactions in per-wallet application, which are added to their respective per-wallet InputData.

Note that two new fields need to be added to InTransaction:

  • __parent is populated in the artificial InTransactions that are created and set to the InTransaction that represents the receive side of the current IntraTransaction.
  • __per_wallet is a dictionary of wallets and artificial InTransactions that the current InTransaction is split into after per-wallet analysis.

The original InTransactions coming from ODS parsing always has __parent set to None. Artificial InTransactions always have __parent set to not-None.

For example, let's consider this data in the user ODS input file:

  • 1/1: InTransaction of 10 BTC on Coinbase
  • 2/1: IntraTransaction of 4 BTC from Coinbase to Kraken
  • 3/1: IntraTransaction of 2 BTC from Kraken to Trezor
  • 4/1: IntraTransaction of 5 BTC from Coinbase to Trezor
  • 5/1: OutTransaction of 6 BTC from Trezor

After transfer analysis this results in the following:

  • Coinbase:
    • InTransaction(10, Coinbase), with __parent set to None and __per_wallet set to: {Kraken: [InTransaction(4, Kraken)], Trezor: [InTransaction(2, Trezor), InTransaction(5, Trezor)]}
    • IntraTransaction(4, Coinbase -> Kraken)
    • IntraTransaction(5, Coinbase -> Trezor)
  • Kraken:
    • artificial InTransaction(4, Kraken), with __parent set to InTransaction(10, Coinbase), __per_wallet set to: "Trezor" -> [InTransaction(2, Trezor)]
    • IntraTransaction(2, Kraken -> Trezor)
  • Trezor:
    • artificial InTransaction(2, Trezor), with __parentset toInTransaction(4, Kraken)and__per_walletset toNone`
    • artificial InTransaction(5, Trezor), with __parentset toInTransaction(10, Coinbase)and__per_walletset to:None`
    • OutTransaction(6, Trezor)

The new fields are used during tax computation as follows:

  • with universal application: when setting a partial amount on a InTransaction the accounting engine also sets partial amounts for the __per_wallet transactions associated to the same exchange where the current taxable event occurred (using FIFO semantics). Then for each of the __per_wallet transactions for which the partial amount was set it also follows the parent link all the way to the original InTransaction and sets the partial amount there, too. In the example above (assuming FIFO accounting is used), when processing the OutTransaction the accounting engine sets partial amount to 6 for InTransaction(10, Coinbase) (which is the first lot in the FIFO queue). Then it sets the partial amount to 2 for InTransaction(2, Trezor) and to 4 for InTransaction(5, Trezor). Then it sets the partial amounts following the __parent links for each of the __per_wallet transactions. For the first one, the parent is InTransaction(4, Kraken), so set partial amount for it to 2, and its parent is the original InTransaction in which the partial amount was already set. For the second one, again, the parent is the original InTransaction.

Pseudocode:

# Private class used to build a per-wallet set of transactions.
class _PerWalletTransactions:
  def __init__(asset: str, lot_selection_method: AbstractAccountingMethod):
    self.__asset = asset
    self.__lot_selection_method = lot_selection_method # to decide which lot to pick when transferring funds.
    self.__in_transactions: AbstractAcquiredLotCandidates = lot_selection_method.create_lot_candidates([], {})
    self.__out_transactions: List[OutTransactions] = []
    self.__intra_transactions: List[IntraTransactions] = []

# Utility function to create an artificial InTransaction modeling the "to" side of an IntraTransaction
def _create_to_in_transaction(from_in_transaction: InTransaction, transfer_transaction: IntraTransaction) -> Intransaction:
  return InTransaction(
    timestamp=transfer_transaction.timestamp,
    exchange=transfer_transaction.to_exchange,
    asset=transfer_transaction.asset,
    holder=transfer_transaction.to_holder,
    transaction_type=from_in_transaction.transaction_type,
    crypto_in=transfer_transaction.crypto_received,
    spot_price=from_in_transaction.spot_price,
    crypto_fee= 0 # we leave the full fee in from_in_transaction, unless the full amount was transferred here.
    # same thing for fiat fields...
    row=get_artificial_id_from_row(from_in_transaction.row), # TODO: this doesn't work. There could be more than one of these artificial transactions per from_in_transaction.
    unique_id=transfer_transaction.unique_id, # TODO: this doesn't work. There could be more than one of these articifial transactions per transferTransaction.
    notes="Artificial transaction modeling the reception of <amount> <asset> from <from_exchange> to <to_exchange>",
)
 
def _compute_per_wallet_input_data(input_data: InputData, lot_selection_method: AbstractAccountingMethod) -> Dict[str, InputData]:
  # Transfer analysis
  all_transactions: List[AbstractTransaction] = input_data.input_transactions + input_data.out_transactions + input_data.intra_transaction
  all_transactions = sorted(all_transactions, key=lambda t: t.timestamp) # sort chronologically
  wallet_2_per_wallet_transactions: Dict[str, _PerWalletTransactions] = {}
  for transaction in all_transactions:
    if isinstance(transaction, InTransaction):
      per_wallet_transactions = wallet_2_per_wallet_transactions.set_default(transaction.exchange, _PerWalletTransactions(input_data.asset, lot_selection_method))
      per_wallet_transactions.in_transactions.add_acquired_lot(transaction)
      per_wallet_transactions.in_transactions.set_to_index() # TODO: finish this line.
    elif isinstance(transaction, OutTransaction):
      per_wallet_transactions = wallet_2_per_wallet_transactions[transaction.exchange] # The wallet transactions object must have been already created when processing a previous InTransaction.
      per_wallet_transactions.out_transactions.append(transaction)
    elif isinstance(transaction, IntraTransaction):
      # IntraTransactions are added to from_per_wallet_transactions.
      from_per_wallet_transactions = wallet_2_per_wallet_transactions[transaction.from_exchange] # The wallet transactions object must have been already created when processing a previous InTransaction.
      from_per_wallet_transactions.intra_transactions.append(transaction)
      # Add one or more artificial InTransaction to to_per_wallet_transactions.
      to_per_wallet_transactions = wallet_2_per_wallet_transactions.set_default(transaction.to_exchange, _PerWalletTransactions(input_data.asset, lot_selection_method))
      amount = transaction.crypto_sent
      while True:
        result = lot_selection_method.seek_non_exhausted_acquired_lot(from_per_wallet_transactions.in_transactions, transaction.crypto_sent)
        if result is None:
          raise RP2Error(f"Insufficient balance on {transaction.from_exchange} to send funds: {transaction}")
        if result.amount >= amount:
          to_in_transaction = _create_to_in_transaction(result.acquired_lot, transaction)
          to_per_wallet_transactions.in_transactions.add_acquired_lot(to_in_transaction)
          from_per_wallet_transactions.in_transactions.set_partial_amount(result.acquired_lot, result.amount - amount)
          break
        to_in_transaction = _create_to_in_transaction(result.acquired_lot, transaction)
        to_per_wallet_transactions.in_transactions.add_acquired_lot(to_in_transaction)
        from_per_wallet_transactions.in_transactions.clear_partial_amount(result.acquired_lot)
        amount -= result.amount
    else:
      raise RP2ValueError(f"Internal error: invalid transaction class: {transaction}")

  # Convert per-wallet transactions to input_data and call the tax engine.
  result: Dict[str, InputData] = {}
  for wallet, per_wallet_transactions in wallet_2_per_wallet_transactions:
    per_wallet_input_data = convert_per_wallet_transactions_to_input_data(wallet, per_wallet_transactions)
    result[wallet][per_wallet_input_data]
  return result

Invoking the tax engine

After getting per-wallet InputData the tax engine can be invoked. The user can select universal application or per-wallet application and they can also change application (and/or accounting method) from one year to the next. Changing application from year to year is similar to what happens when changing accounting method from year to year. The current version of the accounting engine handles accounting method changes year over year as follows:

  • create one LotCandidates object per year and add it to the AccountingEngine.__years_2_lot_candidates AVL tree;
  • each LotCandidates constructor receives two parameters:
    • acquired_lot_list: full list of all acquired lots as a list (in_transactions from universal InputData), so each LotCandidates object can see all acquired lots, regardless of which exchange they sit on (this is in line with the definition of universal application, which has only one global queue);
    • acquired_lot_2_partial_amount: the same dictionary instance is passed to all of the LotCandidates via this parameter: this way when accounting methods change from one year to the next, acquired lots that have been already used (or partially used) by previous methods are not used again, regardless of what the previous accounting methods were.

Implementing the ability to switch from one application method type to another year over year requires the following changes:

  • the parameters to LotCandidates constructor change as follows:
    • acquired_lot_list: if the current year uses universal application, pass in_transactions from universal InputData (as discussed above). If it uses per-wallet application pass the in_transactions from the per-wallet InputData of the wallet that taxes are being generated for;
    • acquired_lot_2_partial_amount: similar to above, pass the same object to all LotCandidates, regardless of accounting method and universal or per-wallet application: this way when application methods or accounting methods change from one year to the next, acquired lots that have been already used (or partially used) by previous methods are not used again.
  • Modify InTransaction as follows:
    • add a private per_wallet_split list of InTransactions: this list contains artificial InTransactions created by the per-wallet analysis. Each of these InTransactions represents a fraction of the the original InTransaction that was transfered to a new wallet;
    • add a private original_lot InTransaction backpointer: this is used by the artificial InTransactions created by the per-wallet analysis to refer to the original InTransaction that the funds originate from;
  • when setting a partial amount, set it both in the original and in the relevant per-wallet InTransaction.
  • add two new sections to config (similar to the accounting_methods one):
    • application_methods: year -> universal/per-wallet. This dictionary models changes in the application method over the years. It is translated to an AVL tree in the code (similar to how accounting_methods is processed).
    • transfer_methods: year -> accounting method. This dictionary models changes in the accounting method that is used specifically for transfers in per-wallet accounting: i.e. when funds get transferred from wallet A to wallet B, which InTransaction do they come from? It is translated to an AVL tree in the code (similar to how accounting_methods is processed).
  • change country to reflect which application method is valid when.

Unifying the output of the tax engine

The tax engine outputs a TransactionSet and a GainLossSet for each wallet. These objects are easy to join before they are passed to generators.

Clone this wiki locally