Part of the Sanchayam series.
Why FIFO
FIFO (first in, first out) is the standard cost basis method for Indian equities and mutual funds for tax purposes. When you sell shares, the oldest lots you bought are assumed to be the ones sold first. The cost basis of those oldest lots determines your taxable gain.
Sanchayam implements FIFO at sell time: when a sell lot is recorded, the backend walks the buy lots in chronological order, consuming them until the sell quantity is satisfied, and computes the aggregate cost.
Lot Schema
CREATE TABLE lots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
holding_id UUID NOT NULL REFERENCES holdings(id),
transaction_type lot_transaction_type NOT NULL, -- 'buy' | 'sell'
quantity NUMERIC(38,8) NOT NULL,
remaining_quantity NUMERIC(38,8),
price_per_unit NUMERIC(38,0) NOT NULL,
cost_basis_minor NUMERIC(38,0),
transaction_date DATE NOT NULL,
...
);
remaining_quantity is only set on buy lots. It starts equal to quantity and decreases as subsequent sells consume it. A buy lot with remaining_quantity = 0 is fully consumed.
cost_basis_minor is only set on sell lots. It stores the FIFO-computed cost of the shares being sold, in minor currency units (paise for INR). Realized P&L for a sell lot is always sell_proceeds - cost_basis_minor.
price_per_unit is stored in minor units on both buy and sell lots.
FIFO Walk at Sell Time
When a sell lot is submitted, the backend walks buy lots in chronological order:
const buyLots = await sql`
SELECT id, quantity, remaining_quantity, price_per_unit
FROM lots
WHERE holding_id = ${holdingId}
AND transaction_type = 'buy'
AND remaining_quantity > 0
AND is_deleted = false
ORDER BY transaction_date ASC, created_at ASC
`
let remaining = sellQuantity
let costBasisMinor = 0
for (const lot of buyLots) {
if (remaining <= 0) break
const available = parseFloat(lot.remaining_quantity)
const consumed = Math.min(available, remaining)
costBasisMinor += consumed * parseFloat(lot.price_per_unit)
await sql`
UPDATE lots
SET remaining_quantity = remaining_quantity - ${consumed}
WHERE id = ${lot.id}
`
remaining -= consumed
}
After the walk, cost_basis_minor is set on the sell lot row. Realized P&L for that sell = sell_quantity * sell_price_per_unit - cost_basis_minor.
The walk is done inside a transaction. If the sell quantity exceeds the available buy quantity (an error in data entry), the transaction is rolled back and the user sees a validation error.
Realized P&L Summary
To compute total realized P&L for a holding, the backend sums across all sell lots:
SELECT
SUM(quantity * price_per_unit - cost_basis_minor) AS realized_pnl_minor
FROM lots
WHERE holding_id = ${holdingId}
AND transaction_type = 'sell'
AND is_deleted = false
This query is fast regardless of lot count because it scans only sell lots and all values are already computed at insert time. There is no re-walking of buy lots at query time.
trimLotsToCurrentPosition
XIRR measures the return of your current position. If you bought 100 shares in 2015, sold them all in 2018, and bought again in 2020, the 2015 lots are irrelevant to your current XIRR. Including them would dilute the return calculation with cash flows from a position you no longer hold.
trimLotsToCurrentPosition finds the last point where the running quantity reached zero - the last full exit - and returns only the lots after that point:
export function trimLotsToCurrentPosition(lots: any[]): any[] {
if (lots.length === 0) return lots
let runningQty = 0
let lastExitIdx = -1
for (let i = 0; i < lots.length; i++) {
const qty = parseFloat(lots[i].quantity)
if (lots[i].transaction_type === 'buy') runningQty += qty
else runningQty -= qty
if (runningQty <= 0 && i < lots.length - 1) {
lastExitIdx = i
runningQty = 0
}
}
return lastExitIdx >= 0 ? lots.slice(lastExitIdx + 1) : lots
}
If the running quantity never reached zero - you have held the position continuously since the first buy - all lots are returned unchanged. If it reached zero at index i, only lots from i+1 onward are returned. Those lots represent the current position and nothing before it.
This function is called at snapshot time before computing per-holding XIRR, and again at portfolio XIRR computation before flattening all lots into a single cash flow series.