When calculating capital gains from equity transactions, the order in which shares are considered sold significantly impacts the reported gain or loss. Two fundamental methods dominate this space: First-In-First-Out (FIFO) and Last-In-First-Out (LIFO). While FIFO assumes the oldest shares are sold first, LIFO takes the opposite approach-the most recently purchased shares are the first to be sold. Understanding both methods is essential for investors seeking to optimize their tax strategies or comply with specific accounting requirements.
This post presents a unified Python implementation that supports both valuation methods, demonstrating how a single codebase can handle either approach with minimal modifications.
Both FIFO and LIFO operate on the same principle: matching buy transactions with sell transactions to calculate realized gains. The key difference lies in which buy transaction is matched against a given sale:
Consider an investor who makes two purchases:
Trans("2022-01-01", 5, 10) - bought 5 shares at $10 eachTrans("2022-01-02", 5, 12) - bought 5 shares at $12 eachWhen selling shares, FIFO would match against the January 1st purchase first, while LIFO would match against the January 2nd purchase first.
The implementation leverages Python's deque class, which provides efficient append and pop operations from both ends of the container. This data structure is ideal for our purpose because:
popleft() to remove the oldest element (left side) and append() to add new elements (right side)pop() to remove the newest element (right side) and append() to add new elements (right side)The balanceQueue function processes a list of transactions stored in Trans objects, each containing the date, amount, and price of shares.
Both methods handle sell transactions through three distinct scenarios, determined by the relationship between buy and sell amounts.
When the number of shares sold exactly matches the buy transaction being matched:
Sell: Trans("2022-01-03", -5, 12)
The buy and sell transactions balance perfectly. Neither returns to the queue:
if abs(tq.amount) == abs(t.amount):
insertTransaction(tq.datetime, t.datetime,
math.copysign(t.amount, tq.amount), tq.price, t.price)
t.amount = 0
continue
FIFO: The oldest buy transaction is matched first.
LIFO: The most recent buy transaction is matched first.
When the buy transaction has more shares than the sell transaction:
Sell: Trans("2022-01-03", -2, 12)
The buy transaction is partially consumed and returns to the queue with its remaining shares:
if abs(tq.amount) > abs(t.amount):
insertTransaction(tq.datetime, t.datetime,
math.copysign(t.amount, tq.amount), tq.price, t.price)
tq.amount = tq.amount + t.amount
qTransactions.appendleft(tq)
break
When the sell transaction has more shares than the matching buy transaction:
Sell: Trans("2022-01-03", -8, 12)
The buy transaction is fully consumed, and the remaining sell amount is matched against the next buy transaction:
if abs(tq.amount) < abs(t.amount):
t.amount = t.amount + tq.amount
insertTransaction(tq.datetime, t.datetime, tq.amount, tq.price, t.price)
continue
The remarkable insight is that both methods use the same logic for removing elements from the queue. The difference lies entirely in how elements are added:
def balanceQueue(all_trans, session, comp_method):
...
if tq.amount * t.amount > 0:
qTransactions.appendleft(tq)
if comp_method == 'fifo':
qTransactions.append(t) # Add to right (becomes oldest)
else:
qTransactions.appendleft(t) # Add to left (becomes newest)
...
Similarly, when handling unbalanced transactions:
if (t.amount != 0 and len(qTransactions) == 0):
if comp_method == 'fifo':
qTransactions.append(t)
else:
qTransactions.appendleft(t)
This elegant design allows a single parameter (comp_method) to switch between FIFO and LIFO behavior.
The code contains two simple data validation structures. The first fragment handles the situation where the number of sold shares outnumbers the bought ones. In this case, the sell transaction is appended to the queue and waits for buy transactions to balance:
if (t.amount != 0 and len(qTransactions) == 0):
# Add unbalanced transaction to the queue
# The queue changes polarisation
if comp_method == 'fifo':
qTransactions.append(t)
else:
qTransactions.appendleft(t)
The last fragment removes unbalanced transactions from the queue, which is mainly helpful for debugging:
while (len(qTransactions) > 0):
tq = qTransactions.popleft()
logging.debug('Remained on list transaction: %s', tq.getInfo())
| Aspect | FIFO | LIFO |
|---|---|---|
| Match Order | Oldest shares first | Newest shares first |
| Rising Markets | Higher reported gains (older, cheaper shares) | Lower reported gains (newer, expensive shares) |
| Falling Markets | Lower reported gains | Higher reported gains |
| Tax Implications | May result in higher short-term tax burden | May defer gains to later periods |
| Regulatory Acceptance | Widely accepted globally | Restricted in some jurisdictions |
Both implementations share the same validation structures for handling edge cases where sold shares outnumber bought shares, and both include debugging capabilities to trace transaction flow.
The described code is written for educational purposes to make it comprehensible. You can enable the debugging option which will make the code more verbose and allow for better understanding. For now, the code doesn't handle date and time indexing (dates are included only for labelling purpose), short selling (the shares are first sold then bought back) or multiple equities.
Complete source code for both methods is available: