-
Notifications
You must be signed in to change notification settings - Fork 47
Adding Per‐Wallet Application to RP2
This is a WIP: please check back later
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.
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.
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 ofInputData
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 ofInputData
as there are wallets. The new set ofInputData
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-walletComputedData
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.
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. ThisInputData
object is used by universal application and we'll refer to it as universalInputData
; - 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. TheseInputData
objects are used by per-wallet application and we'll refer to them as per-walletInputData
;
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 toNone
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 toInTransaction(10, Coinbase)
,__per_wallet
set to: "Trezor" -> [InTransaction(2, Trezor)
] IntraTransaction(2, Kraken -> Trezor)
- artificial
- Trezor:
- artificial
InTransaction(2, Trezor), with
__parentset to
InTransaction(4, Kraken)and
__per_walletset to
None` - artificial
InTransaction(5, Trezor), with
__parentset to
InTransaction(10, Coinbase)and
__per_walletset to:
None` OutTransaction(6, Trezor)
- artificial
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 originalInTransaction
and sets the partial amount there, too. In the example above (assuming FIFO accounting is used), when processing theOutTransaction
the accounting engine sets partial amount to 6 forInTransaction(10, Coinbase)
(which is the first lot in the FIFO queue). Then it sets the partial amount to 2 forInTransaction(2, Trezor)
and to 4 forInTransaction(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 isInTransaction(4, Kraken)
, so set partial amount for it to 2, and its parent is the originalInTransaction
in which the partial amount was already set. For the second one, again, the parent is the originalInTransaction
.
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
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 universalInputData
), so eachLotCandidates
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 theLotCandidates
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, passin_transactions
from universalInputData
(as discussed above). If it uses per-wallet application pass thein_transactions
from the per-walletInputData
of the wallet that taxes are being generated for; -
acquired_lot_2_partial_amount
: similar to above, pass the same object to allLotCandidates
, 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 ofInTransaction
s: this list contains artificialInTransaction
s created by the per-wallet analysis. Each of theseInTransaction
s represents a fraction of the the originalInTransaction
that was transfered to a new wallet; - add a private
original_lot
InTransaction
backpointer: this is used by the artificialInTransaction
s created by the per-wallet analysis to refer to the originalInTransaction
that the funds originate from;
- add a private
- 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 howaccounting_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, whichInTransaction
do they come from? It is translated to an AVL tree in the code (similar to howaccounting_methods
is processed).
-
- change country to reflect which application method is valid when.
The tax engine outputs a TransactionSet
and a GainLossSet
for each wallet. These objects are easy to join before they are passed to generators.