Calculate Avg Price, Realized gain & Unrealized gain via UDF using FIFO method
Asked Answered
W

1

2

This post is in continuation of my earlier post where I have already got assistance from Tom Sharpe on how to calculate the Average Price of a stock using the FIFO method as per transaction table & UDF given below. In order to add more functionality to it, I was trying hard to calculate my profit/loss by tweaking the UDF but I was unsuccessful therefore I started a new thread for this.

Profit and Loss is divided into two parts. One is the profit/loss I made by sellling few stocks which will be referred to as Realized Gain and the second one is the Gain which is available in the stock exchange for my unsold stocks which will be called as Unrealized Gain. Both can go into negative if there is a loss instead of profit.

Calculating Unrealised Gain is fairly simple because of the solution which is already provided and the answer to it is, Remaining Qty x Avg Price. Referring the table, 150 x 10 100 = 1 515 000 (I think that is the way it should be calculated - correct me if I am wrong). But calculating Realized Gain is the challenge that I am facing. As per the table, the Realized gain works out to -7 500 which is a loss that is calculated as (Sold Price - First Price) x Sold Quantity (hope mathematics behind this logic is also correct). Plus I am facing even more difficulty when the number of transaction increases.

In short, I was looking forward for having 3 things. The Invested Avg Price (which the UDF is already giving), Unrealized profit (which can be calculated based on the UDF). Need to know how to calculate Realised Profit and if all three things can be returned using the same UDF by adding a parameter in the formula.

Here is the table

Date Side Qty Price Value Holding Avg Price
1-Jul Buy 225 10000 2250000 225 10000
2-Jul Buy 75 10200 765000 300 10050
3-Jul Sell -150 9950 -1492500 150 10100

Below is the Explanation

  • 1st order: Quantity = 225 | Price = Rs. 10 000.00

  • 2nd order: Quantity = 75 | Price = Rs. 10 200.00

To calculate the average price, first calculate the value (Quantity x Price). Hence:

  • 1st trade: Rs. 2 250 000.00

  • 2nd trade: Rs. 765 000.00

  • Total quantity = 300

  • Total value for first two orders : Rs. 3 015 000.00

Now here is the catch. On 3-Jul, we placed a sell order 150 (out of 300) @ Price: Rs. 9 950.00

Now the FIFO (first in first out) method will be applied here. The method will check the first trade (on the buy-side). In this case, it is 225. 150 sold stocks will be deducted from 225 (first holding). The balance left of first holding which was 225 earlier will now be 225 - 150 = 75

After FIFO, the table gets converted like this after deducting the sell quantity. See the first Qty is changed from 225 to 75 because 150 stocks were sold and hence the Average Price is 10100 (which I am able to get it from the UDF below.

Date Side Qty Price Value Holding Avg Price
1-Jul Buy 75 10000 750000 75 10000
2-Jul Buy 75 10200 765000 150 10100

In case the sell quantity was more than 225, then it would have moved to the next trade to deduct the remaining quantity

Thanks to Tom Sharpe for this UDF which is called as =avgRate(qtyRange,rateRange)

The program uses a class BuySell so you need to create a class module, rename it to BuySell and include the lines

Public rate As Double
Public qty As Double

Here is the UDF

Function avgRate(qtyRange As Range, rateRange As Range)


    ' Create the queue

    Dim queue As Object
    Set queue = CreateObject("System.Collections.Queue") 'Create the Queue

    ' Declare some variables
    Dim bs As Object
    Dim qty As Double
    Dim rate As Double
    Dim qtySold As Double
    Dim qtyBought As Double
    Dim qtyRemaining As Double
    Dim rateBought As Double
    Dim i As Long
    Dim sumRate As Double, totQty As Double

    For i = 1 To qtyRange.Cells().Count

        qty = qtyRange.Cells(i).Value()
        rate = rateRange.Cells(i).Value()

        If qty > 0 Then

            'Buy
            Set bs = New BuySell

            bs.rate = rate
            bs.qty = qty

            queue.Enqueue bs

        Else

            'Sell
            qtyRemaining = -qty

            'Work through the 'buy' transactions in the queue starting at the oldest.

            While qtyRemaining > 0

                If qtyRemaining < queue.peek().qty Then

                'More than enough stocks in this 'buy' to cover the sale so just work out what's left

                    queue.peek().qty = queue.peek().qty - qtyRemaining
                    qtyRemaining = 0

                ElseIf qtyRemaining = queue.peek().qty Then

                'Exactly enough stocks in this 'buy' to cover the sale so remove from queue

                    Set bs = queue.dequeue()
                    qtyRemaining = 0

                Else

                'Not enough stocks in this 'buy' to cover the sale so remove from queue and reduce amount of sale remaining

                    Set bs = queue.dequeue()
                    qtyRemaining = qtyRemaining - bs.qty

                End If
            Wend
        End If
    Next i

    'Calculate average rate over remaining stocks

    sumRate = 0
    totQty = 0

    For Each bs In queue
        sumRate = sumRate + bs.qty * bs.rate
        totQty = totQty + bs.qty
    Next

    avgRate = sumRate / totQty

End Function

Algorithm:

If 'buy' transaction, just add to the queue.

If 'sell' transaction (negative quantity)

  Repeat 

    Take as much as possible from earliest transaction

    If more is required, look at next transaction

  until sell amount reduced to zero.

EDIT: Adding image of a larger sample that I tried with the provided solution enter image description here

Wristwatch answered 12/7, 2021 at 17:50 Comment(6)
Unrealized gain looks straightforward, but realized gain looks tricky to me. If you sold 150 stocks as in your example, it's straightforward because they all come out of your first purchase of 225 @ 10000. If you sold 250, I imagine 225 would come out of your first lot at 10000 and the remaining 25 out of your second lot at 10200. Then if you bought some more and sold some more, it would get even more complicated. In other words, it looks to me as if you'd have to do an average price calculation for each sale. You might have to store sales in the queue as well as purchases.Nepheline
Maybe it's not too bad, I think you just have to do an average price calculation each time you have a sale so hopefully not too much extra code.Nepheline
Thank you for your reply. What you imagined is correct. Are you trying to suggest calculation using formulas based on the udf instead of extra coding? Lets say if I do avg price calc each time then the UDF gives me 10000, 10050 & 10100 avg for first 3 transactions. Now 3rd time when I sell, the avg price shown is 10100, in this case, using what formula I can get my realised gain (which is a loss in this example)? sorry but I didn't get what you are trying to suggest...Wristwatch
No, for each sale we have to look at the buying prices of stock relevant to that sale and take the total cost of them away from selling price * quantity, but I think it just means adding a few lines of code to the original UDF.Nepheline
meanwhile I am trying to add an extra column to calculate the realised profit. Using the same example, I sold all 300 stocks instead of 150, it results in an error. What line should I add and where so that it results 0? i.e., when the buy qty in the queue becomes 0 or goes in negative. I tried on error, if bs.qty<=0 but then all cells results in error.Wristwatch
if queue.count=0 then avgrate=0 'or an error value else [existing code] because if you sell all remaining stock the queue becomes empty.Nepheline
N
3

Need to get the gain (or loss) per sell transaction by using the existing code to remove earliest bought stocks from the queue, but add additional lines to work out:

gain = sale price * sale quantity - ∑ buy price * buy quantity

where the summation is over the different 'buy' transactions that satisfy the sale quantity, in chronological order.

I have now added the additional calculations suggested by OP and added some basic error handling (e.g. that the user doesn't try to sell more stocks than are available, making the queue become empty).

The UDF only accepts single-column arguments either as ranges or arrays.

UDF

Need a BuySell class as before:

Public rate As Double
Public qty As Double

Option Explicit

Function avgRate(qtyRange As Variant, rateRange As Variant, Optional calcNumber As Integer = 1)
 
    ' Create the queue
    
    Dim queue As Object
    Set queue = CreateObject("System.Collections.Queue")
    
    ' Declare some variables
    
    Dim bs As Object
    Dim qty As Double
    Dim rate As Double
    Dim qtySold As Double
    Dim qtyBought As Double
    Dim qtyRemaining As Double
    Dim rateBought As Double
    Dim i As Long
    Dim sumRate As Double, totalQty As Double
    Dim avRate As Double
    Dim saleValue As Double
    Dim purchaseValue As Double
    Dim gainForThisSale As Double
    Dim totalGain As Double
    Dim totalCost As Double
    Dim totalProfit As Double
    Dim overallCost As Double
    Dim tempQty() As Variant, workQty() As Variant, tempRate() As Variant, workRate() As Variant
    Dim nRows As Long
    Dim argType As Integer
    
    
    
    'Copy from range or array - assuming single column or single element in both cases.
    

    If TypeOf qtyRange Is Range Then
        If IsArray(qtyRange) Then
        ' column range
            argType = 1
        Else
        ' Single element range
            argType = 2
        End If
    Else
        If UBound(qtyRange, 1) > 1 Then
        ' Column array
            argType = 3
        Else
        ' Single element array
            argType = 4
        End If
    End If
    
    Debug.Print ("Argtype=" & argType)
        
     Select Case argType
        Case 1
            tempQty = qtyRange.Value
            tempRate = rateRange.Value
        Case 2
            nRows = 1
            ReDim workQty(1 To nRows)
            ReDim workRate(1 To nRows)
            workQty(1) = qtyRange.Value
            workRate(1) = rateRange.Value
        Case 3
             tempQty = qtyRange
             tempRate = rateRange
        Case 4
            nRows = 1
            ReDim workQty(1 To nRows)
            ReDim workRate(1 To nRows)
            workQty(1) = qtyRange(1)
            workRate(1) = rateRange(1)
    End Select
        
    If argType = 1 Or argType = 3 Then
            nRows = UBound(tempQty, 1)
    
            ReDim workQty(1 To nRows)
            ReDim workRate(1 To nRows)
            For i = 1 To nRows
               workQty(i) = tempQty(i, 1)
               workRate(i) = tempRate(i, 1)
            Next i
    End If
            

      ' Loop over rows
    
    totalProfit = 0
    overallCost = 0
    
    For i = 1 To nRows
   
        qty = workQty(i)
                
        ' Do nothing if qty is zero
        
        If qty = 0 Then GoTo Continue:
        
        rate = workRate(i)
        
        overallCost = overallCost + rate * qty
        
        If qty > 0 Then
        
            'Buy
            
            Set bs = New BuySell
            
            bs.rate = rate
            bs.qty = qty
            
            queue.Enqueue bs
        
            
        Else
        
            'Sell
        
            qtyRemaining = -qty
            
            'Code for realized Gain
            
            purchaseValue = 0
            saleValue = rate * qtyRemaining
            
            totalProfit = totalProfit + saleValue
            
            'Work through the 'buy' transactions in the queue starting at the oldest.
            
            While qtyRemaining > 0
            
                If queue.Count = 0 Then
                    avgRate = CVErr(xlErrNum)
                    Exit Function
                End If
            
                If qtyRemaining < queue.peek().qty Then
                
                'More than enough stocks in this 'buy' to cover the sale so just work out what's left
                
                    queue.peek().qty = queue.peek().qty - qtyRemaining
                    
                    'Code for realized gain
                
                    purchaseValue = purchaseValue + qtyRemaining * queue.peek().rate

                    
                    qtyRemaining = 0
                    
                    
                ElseIf qtyRemaining = queue.peek().qty Then
                
                'Exactly enough stocks in this 'buy' to cover the sale so remove from queue
                
                    Set bs = queue.dequeue()
                    qtyRemaining = 0
                    
                    'Code for realized gain
                
                    purchaseValue = purchaseValue + bs.qty * bs.rate

                    
                Else
                
                'Not enough stocks in this 'buy' to cover the sale so remove from queue and reduce amount of sale remaining
                
                    Set bs = queue.dequeue()
                    qtyRemaining = qtyRemaining - bs.qty
                    
                    'Code for realized gain
                
                    purchaseValue = purchaseValue + bs.qty * bs.rate
           
                    
                End If
                
            Wend
            
            'Code for realized gain
            
            gainForThisSale = saleValue - purchaseValue

            
            totalGain = totalGain + gainForThisSale
            
        End If
        
Continue:
        
    Next i
    
    'Calculate average rate
    
    If queue.Count = 0 Then
    
        avRate = 0
        
    Else

        totalCost = 0
        totalQty = 0
        
        For Each bs In queue
            totalCost = totalCost + bs.qty * bs.rate
            totalQty = totalQty + bs.qty
        Next
        
        avRate = totalCost / totalQty
        
    End If
    

    
    Select Case calcNumber
        Case 1
        'Average rate
            avgRate = avRate
        Case 2
        'Realized gain
            avgRate = totalGain
        Case 3
        'Invested
            avgRate = totalCost
        Case 4
        'Bal qty
            avgRate = totalQty
        Case 5
        'Net worth (total quantity times most recent rate)
            avgRate = totalQty * rate
        Case 6
        'Total profit (total sale amounts)
            avgRate = totalProfit
        Case 7
        'Unrealized gain
            avgRate = totalProfit - totalGain
        Case 8
        'Overall cost
            avgRate = overallCost
        Case Else
            avgRate = CVErr(xlErrNum)
    End Select
    
     
End Function

enter image description here


I have added a new version which tests for the first argument being an array or a range (and assumes the second argument is of the same type). OP has asked me to check for the case where it is a single element array or single-cell range as well. The main point of allowing arrays etc. is that you can have a function call like:

=avgRate(FILTER($C2:$C10,C2:C10=10),FILTER($A2:$A10,C2:C10=10),8)

or

=avgrate($C$2,$A$2,8)

to select (in this case) just the first row. This makes the UDF more versatile in situations where you may have stocks from more than one company and want to filter on the company.

Update

Just for interest, it would be possible with the new Lambda functions to implement a queue by formula, at the expense of allocating one row per individual share e.g.

=AVERAGE(REDUCE("",SEQUENCE(COUNT(B:B),1,2),
LAMBDA(q,i,IF(INDEX(A:A,i)="Buy",VSTACK(q,SEQUENCE(INDEX(B:B,i),1,INDEX(C:C,i),0)),
IF(INDEX(B:B,i)=COUNT(q),"",VSTACK("",DROP(q,INDEX(B:B,i)+1)))))))

applied to this test data:

enter image description here

Plz see this answer for Google Sheets version.

Nepheline answered 14/7, 2021 at 14:4 Comment(19)
Thanks for the test draft. I tried with a little larger sample (added an image as an edit to my original post) and it gives proper results. However, your understanding of unrealized gain according to me is the amount invested in the exchange. Looks like there was a miscommunication from my side. Perhaps, I may have not explained it properly. Sorry about that. Whenever there is a sell transaction, Qty x Current Rate on that day becomes the Profit booked. This booked profit is divided into Unrealized gain (that stays in the exchange) & Realized gain (which is withdrawn).Wristwatch
Unrealized gain is the difference between Profit Booked and Realized gain. So according to the posted image, 950 becomes Invested price (and not Unrealized gain). Unrealized gain is 1175 (marked in pink) which is difference of Profit booked 2050 and Realized gain 875. Here we can even plot the Total booked profit (2050) & Networth (1250 - which is bal qty x current rate). Thank you so very much for your kind assistance. I really appreciate your time and efforts. A conversion from sub to udf (as per your convenient time) would be really nice. Thanks again...Wristwatch
I think the realized gain is OK so it wouldn't be very difficult to change the unrealized gain according to your definitions. I assume it's the bottom line in your table that you need not the results per transaction. Will have a look at it tomorrow.Nepheline
I commented before you updated the udf. I am looking at the udf now and haven't tried yet. I updated the transactionwise new sample with little large data as a colored picture because, it would have been easy to explain. Wih the vba solution, only the bottom line would have been sufficient. Results per transaction would always be nice and that was another reason for the udf request. Thanks for the udf. I willl try this solution in some time.Wristwatch
You nailed it ! Thank you so much. There were few manual calculation errors in the image i posted earlier which I have corrected now and updated the edit. I think everyone would prefer "per transaction" than "bottom line". Now the only pending points are, optional parameter 3 (Unrealized gain) should be renamed to Invested Amt and If possible for you, can we also get the correct Unrealized gain and along with it, Total Profit, Networth, Balance Qty and Total cost as new additional optional parameters 4-8? All these are highlited in pink in the updated image. ThanksWristwatch
Thanks. I have 2 things to tell you. 1. I have same excel setup that you have shown in your screenshot. If I type this formula =avgRate($C$2:$C2,$A$2:$A2,8) in cell D2 and pull it down, the first cell shows error and the remaining cells are calculated properly. It was working properly in earlier version. 2. I did not really understand the difference between 2 user inputs (as range or as arrays). When you say, "The UDF only accepts single-column arguments either as ranges or arrays." may I request you to elaborate on this inside the post? Pls advice usage and advantages of array based input.Wristwatch
Other than the above comment I sent a little while ago, I also want to tell you that I am facing a strange issue. When I tried the udf on my present desktop having xl2007 and at my work laptop on xl365, it works but when I try it on my other laptop having xl2016 version, it does not work. The UDF does not refresh even after pressing F9 or F9 used with Ctrl, Alt, Shift combinations. It shows error. I tried Application.Volatile, Application.CalculateFull, ActiveWorkbook.ForceFullCalculation = True. Other udf works properly. Its strange !Wristwatch
Yes, I get the same error if the range is a single cell (I only allowed for a range of more than one cell). It looks as if it can be adjusted fairly easily , but I won't be around to look at it till later. The idea of allowing for an array is that you could use it with the output from a filter (say if you wanted to select one company from a list) and it would be more versatile.Nepheline
Hm, don't really know what to do about the xl2016 error because I currently only have excel 365. The best way to test it is to use a driver sub containing lines like Dim result As Double result = avgRate(Range("$C$2:$C2"), Range("$A$2:$A2"), 8) Debug.Print ("Result=" & result) then it should show you the line where the error is.Nepheline
Thats okay... time is not a constraint. Do you mean to say that if there were multiple stocks of multiple companies in the list, then it can be filtered using the additional array feature? If that is the case then its great but then the usage of calling the udf will change. Right? I am still a bit confused. It would be great if you can post a sample of how to use the array whenever you get time to look at it. ThanksWristwatch
OK I'm ready to post a new version which works with single-element ranges and arrays.. With the 2016 version, I can't do much about it, but if you can find where the error occurs and what the error message is I guess you can post another question in the hope that someone has access to Excel 2016 and can reproduce it. The only issue is that this is now a fairly long program and it might be difficult to frame the question 'what is my error and how do I fix it' in a clear way.Nepheline
I was very eager to check the results. However, when I tried the two examples you gave, it gives me error. When I tried =avgRate(FILTER($C2:$C10,C2:C10=10),FILTER($A2:$A10,C2:C10=10),8) and press enter key, it gave me error "The function is not valid" after clicking OK on the error, the word "FILTER" gets highlighted in the formula bar. =avgrate($C$2,$A$2,8) works fine but when I change the row number from 2 to 3 eg. =avgrate($C$3,$A$3,8), it gives #NUM! error. Am I doing something wrong? Is it bcoz Im using XL2007?Wristwatch
The first one, Filter is only available in Excel 365 so won't work in earlier versions of Excel. The second one, it's working correctly because you are trying to sell some stocks before you have bought any so it flags up an error (tries to remove items from the queue, but the queue is already empty).Nepheline
I think you can try =avgrate(IF(ROW($C2:$C10)<9,C2:C10,0),IF(ROW($C2:$C10)<9,$A2:$A10,0),8) instead of filter (maybe with Ctrl-Shift-Enter) because it ignores rows where qty=0.Nepheline
The filter option works like a charm on v365. As per your suggestion, on v2007, I tried this formula to filter all Buy calls and it works too with the array formula using Ctrl+Shift+Enter =avgRate(IF($B$2:$B$9="Buy",$C$2:$C9,0),IF($B$2:$B$9="Buy",$A$2:$A9,0),8). You have met and exceeded the expectations. I thank you so very much for this wonderful solution you have provided. I really and greatly appreciate your time and effort. God bless !Wristwatch
I request you to bookmark this thread just in case we come across requests for additional functionalities from other users that will help the community. If I think of anything, I will definitely post a comment. At the moment, I wish this can also be done on google sheets with a script but not sure if the collections queue is available in google. Please keep on updating the code in case you feel there is scope of improvement & enhancement. Once again... thanks a lot. Cheers !Wristwatch
Thank you for asking the question in the first place and for all your feedback and encouragement. I've enjoyed doing this and learned a lot in the process. As far as Google scripts is concerned, I'm not sure what collections are available either although it looks as if a deque might be available in Javascript. That's an interesting project for another time! All the best.Nepheline
Finally I got to know why the udf was not refreshing on v2016 and v2010. Based on my workarounds, it only works on computer with Microsoft .NET framework 4 and 3.5. On the v2016 laptop, when I downgraded it from 4.8 to 3.5 it worked. Similarly on v2010 system, when I downgraded to 4.0, it worked. I hope I dont face any issues with other softwares due to this downgrade. One thing I fail to understand, why it would not work on the latest version of .NET framework. Its a mystery for me.Wristwatch
Hm, that's interesting (but also annoying). I think it has to be to do with the use of System.collections to get hold of the ready-made Queue class. According to this, it's in a file called mscorlib.dll #47120495 within Microsoft.net under the Windows folder in Windows 10. BTW on a more positive note, this shows it's easy to do a queue in javascript en.wikipedia.org/wiki/Queue_%28abstract_data_type%29Nepheline

© 2022 - 2025 — McMap. All rights reserved.