Calculating capital gains using FIFO queue

According to the first-in-first-out (FIFO) valuation method of capital gain, it’s assumed that equities are sold in the order in which they’re bought. In other words, the oldest shares are sold first. A detailed description of this method can be found in many places, e.g. here. In this post, I will focus on the implementation of the FIFO method in Python.

Fifo queue

For the implementation, I chose the minimal possible set of packages that allows me to avoid external dependencies and better shows the details of the FIFO mechanism. The implementation heavily exploits deque class, which is a list with quicker append and pop operations from the left side of the container and more intuitive addition (appendLeft) and removal (popLeft) of elements. However, the implementation is also possible with a standard list mechanism.

The balanceFifo function takes a list of transactions. The information about every transaction: date, amount and price of shares is stored in the Trans class. In our example, an investor bought two times 5 shares of stocks, then sold them in two transactions. Thus, the first transaction is Trans(“2022-01-01”,5,10) – an investor bought 5 shares of specified equity and paid 10$ for every share. In the second transaction Trans(“2022-01-02”,5,12), on the next day the investor bought the same amount of shares and paid 12$ for a share. Let stop here and see how these transactions are processed in the code. The addition of the first element to the FIFO queue represented by qTransactions deque is handled separately.

if len(qTransactions)==0:
    logging.debug('Added the first element: %s',t.getInfo())
    qTransactions.append(t)
    continue

Note, that the first element may be added to the qTransactions many times during the loop. Every time the buy and sell transactions level off qTransactions becomes empty and the algorithm needs to initialise the qTransactions with the first element again.

When the FIFO list has at least one element, we take the next earlier mentioned buy transaction Trans(“2022-01-02”,5,12) and add it to our queue

while (t.amount!=0 and len(qTransactions)>0):
    #investigate the first element from the queue
    tq=qTransactions.popleft()
    #the same type of transaction: both sell or both buy
    if tq.amount*t.amount>0:
        #return the first element back to the same place
        qTransactions.appendleft(tq)
        #add the new element to the list
        qTransactions.append(t)
        logging.debug('Added: %s',t.getInfo())
        break

Next, come sell transactions. Depending on the amount of sold shares, we have 3 cases:

  1. Case 1: The investor sold 5 shares for 12$ each and, on the next day, sold the same amount for 14$ each. In the code, the transactions are represented as Trans(“2022-01-03”,-5,12) and Trans(“2022-01-04”,-5,14)
  2. Case 2: The investor sold 2 shares for 12$ each and next day sold 8 shares for 14$ each. In the code, the transactions are represented as Trans(“2022-01-03”,-2,12) and Trans(“2022-01-04”,-8,14)
  3. Case 3: The investor sold 8 shares for 12$ each and next day sold 2 shares for 14$ each. In the code, the transactions are represented as Trans(“2022-01-03”,-8,12) and Trans(“2022-01-04”,-2,14)

As we will see, each of these cases is handled by a separate fragment of code.

Case 1

We take the next item Trans(“2022-01-03”,-5,12). The numbers of shares in buy and sell transactions are equal. In this case, the contrary pair of transactions are balanced. The previously removed buy transaction from the queue and the current sell transaction are not added to the queues. The next sell transaction Trans(“2022-01-04”,-5,14) repeats this pattern.

FIFO queue - case 1
FIFO queue – case 1
if abs(tq.amount)==abs(t.amount):
    insertTransaction(tq.datetime,t.datetime,\
            math.copysign(t.amount,tq.amount), tq.price,t.price)
    
    #update the amount in the transaction 
    t.amount=0
    logging.debug('Balanced, removed transaction: %s',t.getInfo())
    logging.debug('Balanced, removed from the queue: %s',tq.getInfo())
    #the transaction has been balanced, take a new transaction
    continue

Case 2

We take the next item Trans(“2022-01-03”,-2,12). The number of shares in the buy transaction Trans(“2022-01-01”,5,10) which matches the sell transaction is 5. We deduct shares between the buy and sell transactions and the updated buy transaction returns to the list. The next sell transaction Trans(“2022-01-04”,-8,12) is balanced by the remains of the 1st buy transaction and the whole 2nd buy transaction.

FIFO queue - case 1
FIFO queue – case 2
if abs(tq.amount)>abs(t.amount):
    insertTransaction(tq.datetime,t.datetime,\
            math.copysign(t.amount,tq.amount), tq.price,t.price)
    #update the amount of the element in the queue
    tq.amount=tq.amount+t.amount
    #return the element back to the same place
    qTransactions.appendleft(tq)
    logging.debug('Removed transaction: %s',t.getInfo())
    #the transaction has been balanced, take a new transaction
    break

Case 3

We take the next item Trans(“2022-01-03”,-8,10). The number of shares in the buy transaction Trans(“2022-01-01”,5,10) is not sufficient to matches the sell transaction. Therefore the buy transaction doesn’t return to the queue. Instead, the unbalanced part of the sell transaction and the next sell transaction Trans(“2022-01-04”,-2,10) are balanced with the next buy transaction Trans(“2022-01-02”,5,10).

FIFO queue - case 3
FIFO queue – case 1
if abs(tq.amount)<abs(t.amount):
    #update the units in transaction, (remove element from the queue)
    t.amount=t.amount+tq.amount
    insertTransaction(tq.datetime,t.datetime,tq.amount,tq.price,t.price)
    logging.debug('Removed from queue: %s',tq.getInfo())
    continue

Control and debug structures

The code contains two simple data validation structures. The first fragment of code is responsible for handling the situation, where the number of sold shares outnumber the bought ones. In this case, the sell transaction should be appended to the queue and wait for buy transactions to balance.

if (t.amount!=0 and len(qTransactions)==0):
    #Add unbalanced transaction to the queue
    #The queue changes polarisation
    qTransactions.append(t)

The last fragment of the balanceFifo function removes from the queue transactions which were not balanced. It’s mainly helpful for debugging.

while (len(qTransactions)>0):
    tq=qTransactions.popleft()
    logging.debug('Remained on list transaction: %s',tq.getInfo())

Summary

The described code is far from being optimal. Many changes can be introduced to make it faster. However, I tried to write for educational purposes and to make it comprehensible. Furthermore, You can switch the debugging option which will make the code more chatty and allows for its better understanding.
For now, the code doesn’t handle date and time indexing (dates are included only for labeling purposes), short selling (the shares are first sold the bought back) or multiple equities.
In the next post, I extended the code to handle the LIFO case.

Full source code

import logging
from collections import deque
import math

class Trans:
    datetime=None 
    amount=None
    price=None

    def __init__(self, datetime, amount, price):
        self.datetime=datetime
        self.amount=amount
        self.price=price
    
    def getInfo(self):
        return(str(self.datetime)+"; "+
                str(self.amount)+"; "+
                str(self.price))+"; "

def balanceFifo(all_trans):

    qTransactions = deque() 

    for t in all_trans:
        #Add first element to the queue
        if len(qTransactions)==0:
            logging.debug('Added the first element: %s',t.getInfo())
            qTransactions.append(t)
            continue

        while (t.amount!=0 and len(qTransactions)>0):
            #investigate the first element from the queue
            tq=qTransactions.popleft()
            #the same type of transaction: both sell or both buy
            if tq.amount*t.amount>0:
                #return the first element back to the same place
                qTransactions.appendleft(tq)
                #add the new element to the list
                qTransactions.append(t)
                logging.debug('Added: %s',t.getInfo())
                break
            
            #contrary transactions: (sell and buy) or (buy and sell) 
            if tq.amount*t.amount<0:
                logging.debug('Transaction : %s',t.getInfo())
                logging.debug('... try to balance with: %s',tq.getInfo())

                #The element in the queue have more units and takes in the current transaction
                if abs(tq.amount)>abs(t.amount):
                    insertTransaction(tq.datetime,t.datetime,\
                            math.copysign(t.amount,tq.amount), tq.price,t.price)
                    #update the amount of the element in the queue
                    tq.amount=tq.amount+t.amount
                    #return the element back to the same place
                    qTransactions.appendleft(tq)
                    logging.debug('Removed transaction: %s',t.getInfo())
                    #the transaction has been balanced, take a new transaction
                    break
                
                #The element from the queue and transaction have the same amount of units
                if abs(tq.amount)==abs(t.amount):
                    insertTransaction(tq.datetime,t.datetime,\
                            math.copysign(t.amount,tq.amount), tq.price,t.price)
                    
                    #update the amount in the transaction 
                    t.amount=0
                    logging.debug('Balanced, removed transaction: %s',t.getInfo())
                    logging.debug('Balanced, removed from the queue: %s',tq.getInfo())
                    #the transaction has been balanced, take a new transaction
                    continue
                #The transaction has more units
                if abs(tq.amount)<abs(t.amount):
                    #update the units in transaction, (remove element from the queue)
                    t.amount=t.amount+tq.amount
                    insertTransaction(tq.datetime,t.datetime,tq.amount,tq.price,t.price)
                    logging.debug('Removed from queue: %s',tq.getInfo())
                    
                    #the transaction has not been balanced, 
                    #take a new element from the queue (t.amount>0)
                    continue
                
        #We have unbalanced transaction but the queue is empty            
        if (t.amount!=0 and len(qTransactions)==0):
            #Add unbalanced transaction to the queue
            #The queue changes polarisation
            qTransactions.append(t)
            logging.debug('Left element: %s',t.getInfo())
    
    
    #If something remained in the queue, treat it as open or part-open transactions
    while (len(qTransactions)>0):
        tq=qTransactions.popleft()
        logging.debug('Remained on list transaction: %s',tq.getInfo())

def insertTransaction(dateStart,dateEnd,amount,priceStart,priceEnd):
    
    print("Bought={}, sold={},  amount={}, buy price={}, sell_price={}, gain={}".\
            format(dateStart,dateEnd,amount,priceStart,priceEnd, amount*(priceEnd-priceStart)))

#Uncomment if you want to see more information
#logging.basicConfig(level=logging.DEBUG)

trans_list=list()

#Case 1
print("Case 1")
trans=Trans("2022-01-01",5,10)
trans_list.append(trans)
trans=Trans("2022-01-02",5,12)
trans_list.append(trans)
trans=Trans("2022-01-03",-5,12)
trans_list.append(trans)
trans=Trans("2022-01-04",-5,14)
trans_list.append(trans)
balanceFifo(trans_list)
trans_list.clear()

#Case 2
print("Case 2")
trans=Trans("2022-01-01",5,10)
trans_list.append(trans)
trans=Trans("2022-01-02",5,12)
trans_list.append(trans)
trans=Trans("2022-01-03",-2,12)
trans_list.append(trans)
trans=Trans("2022-01-04",-8,14)
trans_list.append(trans)
balanceFifo(trans_list)
trans_list.clear()

#Case 3
print("Case 3")
trans=Trans("2022-01-01",5,10)
trans_list.append(trans)
trans=Trans("2022-01-02",5,12)
trans_list.append(trans)
trans=Trans("2022-01-03",-8,12)
trans_list.append(trans)
trans=Trans("2022-01-04",-2,14)
trans_list.append(trans)
balanceFifo(trans_list)

Leave a Reply

Your email address will not be published. Required fields are marked *