# Position Lifecycle & Realized P&L Fix
## Date
December 30, 2025
## Problem Description
### Issue
When a stock position was completely sold (quantity goes to zero) and later re-bought, the system incorrectly mixed the new buy with the previous closed position while calculating realized P&L, and XIRR would show as N/A.
### Example of Incorrect Behavior
**Scenario:**
1. Buy 30 units of HCL @ ₹100 = ₹3,000
2. Sell 30 units of HCL @ ₹120 = ₹3,600 (position closed)
3. Buy 1 unit of HCL @ ₹130 = ₹130 (new position)
**Incorrect Calculation (OLD):**
```
avgBuyPrice = (₹3,000 + ₹130) / (30 + 1) = ₹101.29
Realized P&L = Sell value - (Sold qty × avgBuyPrice)
= ₹3,600 - (30 × ₹101.29)
= ₹3,600 - ₹3,038.70
= ₹561.30 ❌ WRONG!
```
**Correct Calculation (NEW):**
```
Position 1 (Closed):
Buy 30 @ ₹100 = ₹3,000
Sell 30 @ ₹120 = ₹3,600
Realized P&L = ₹3,600 - ₹3,000 = ₹600 ✅
Position 2 (Active):
Buy 1 @ ₹130 = ₹130
Unrealized P&L = (1 × currentPrice) - ₹130
```
### Root Cause
The old implementation in `equity/app/api/tradebook/route.ts` and `equity/app/api/stats/route.ts`:
1. **Grouped all trades together** without tracking position lifecycles
2. **Calculated avgBuyPrice from ALL buys**, including buys after position closed
3. **Applied this averaged price** to calculate realized P&L for past sales
```typescript
// OLD CODE (INCORRECT)
group.avgBuyPrice = group.totalBuyQuantity > 0
? group.totalBuyValue / group.totalBuyQuantity
: 0;
const soldQuantity = group.totalSellQuantity;
const costOfSold = soldQuantity * group.avgBuyPrice; // ❌ Wrong!
group.realizedPnL = group.totalSellValue - costOfSold;
```
This violated the fundamental principle that **realized P&L should only include transactions from closed positions**, not future buys.
### XIRR Issue
When positions were fully closed and reopened, XIRR would show as "N/A" because:
1. The calculation included all trades (closed + new position) together
2. This created invalid cash flow patterns for the xirr library
3. The library would throw errors or return null
---
## Solution: FIFO-Based Position Lifecycle Tracking
### Algorithm
We implemented a **FIFO (First-In-First-Out)** matching algorithm that:
1. **Processes trades chronologically**
2. **Matches sells with oldest unmatched buys** (FIFO principle)
3. **Tracks position lifecycles** - identifies when quantity reaches zero
4. **Starts new position cycle** on next buy after closure
5. **Calculates realized P&L** only from matched buy-sell pairs in closed positions
6. **Separates trades** for closed vs active positions for accurate XIRR
### Implementation
Created new utility: `equity/lib/fifo-calculator.ts`
**Key Functions:**
1. **`calculateFIFOPosition(trades)`**
- Takes array of trades
- Returns detailed position metrics with proper lifecycle tracking
- Separates closed position trades from active position trades
2. **`calculateUnrealizedPnL(fifoResult, currentPrice)`**
- Calculates unrealized P&L for active position only
- Uses cost basis of remaining buy lots (not all-time average)
### Data Structures
```typescript
interface FIFOResult {
// Overall metrics
totalBuyQuantity: number;
totalSellQuantity: number;
totalBuyValue: number;
totalSellValue: number;
netQuantity: number;
// Realized P&L (from completed sales only)
realizedPnL: number;
realizedPnLPercent: number;
// Active position metrics
avgBuyPriceForActivePosition: number; // Only remaining lots
activePositionCost: number; // Cost of current holdings
// Position cycles
positionCycles: PositionCycle[];
// For XIRR calculation
closedPositionTrades: Trade[]; // Only closed position trades
activePositionTrades: Trade[]; // Only current position trades
}
interface PositionCycle {
startDate: Date;
endDate: Date | null; // null if still open
isClosed: boolean;
trades: Trade[];
realizedPnL: number;
realizedQuantity: number;
}
```
---
## Changes Applied
### 1. Created FIFO Calculator (`equity/lib/fifo-calculator.ts`)
New utility module that implements proper FIFO matching and position lifecycle tracking.
**Key Features:**
- Chronological trade processing
- FIFO matching of buys and sells
- Position cycle detection (when quantity hits zero)
- Separate metrics for closed vs active positions
- Trade separation for accurate XIRR calculation
### 2. Updated Tradebook API (`equity/app/api/tradebook/route.ts`)
**Changes:**
```typescript
// Import FIFO calculator
import { calculateFIFOPosition, calculateUnrealizedPnL, type Trade as FIFOTrade } from '@/lib/fifo-calculator';
// In the processing loop:
for (const group of tradeGroups.values()) {
// Convert trades to FIFO format
const fifoTrades: FIFOTrade[] = group.trades.map(t => ({
trade_date: new Date(t.trade_date),
trade_type: t.trade_type as 'buy' | 'sell',
quantity: parseFloat(t.quantity.toString()),
price: parseFloat(t.price.toString()),
}));
// Calculate position using FIFO matching
const fifoResult = calculateFIFOPosition(fifoTrades);
// Use FIFO results for accurate P&L
group.realizedPnL = fifoResult.realizedPnL; // ✅ Correct!
group.realizedPnLPercent = fifoResult.realizedPnLPercent;
// Unrealized P&L for active position only
const unrealizedResult = calculateUnrealizedPnL(fifoResult, currentPrice);
group.unrealizedPnL = unrealizedResult.unrealizedPnL;
// XIRR calculation with proper trade separation
const tradesForXIRR = group.netQuantity === 0
? fifoResult.closedPositionTrades
: fifoResult.activePositionTrades;
group.xirr = calculateStockXIRR(tradesForXIRR, currentPrice, group.netQuantity);
}
```
### 3. Updated Stats API (`equity/app/api/stats/route.ts`)
Applied same FIFO-based calculation to the stats/holdings endpoint for consistency.
**Changes:**
```typescript
// Import FIFO calculator
import { calculateFIFOPosition, calculateUnrealizedPnL, type Trade as FIFOTrade } from '@/lib/fifo-calculator';
// In the holdings processing:
const fifoResult = calculateFIFOPosition(fifoTrades);
const unrealizedResult = calculateUnrealizedPnL(fifoResult, currentPrice);
// Use FIFO-calculated values
const realizedPnL = fifoResult.realizedPnL;
const unrealizedPnL = unrealizedResult.unrealizedPnL;
const investment = fifoResult.activePositionCost; // Only current position cost
const avgBuyPrice = fifoResult.avgBuyPriceForActivePosition;
// XIRR with proper trade separation
const tradesForXIRR = currentQuantity === 0
? fifoResult.closedPositionTrades
: fifoResult.activePositionTrades;
```
---
## Technical Details
### FIFO Matching Algorithm
```typescript
// Simplified algorithm flow
for (const trade of sortedTrades) {
if (trade.trade_type === 'buy') {
// Add to buy lots queue
buyLots.push({
quantity: trade.quantity,
price: trade.price,
date: trade.trade_date
});
// Start new position cycle if needed
if (!currentCycle) {
currentCycle = new PositionCycle();
}
} else if (trade.trade_type === 'sell') {
// Match with oldest buy lots (FIFO)
while (remainingSellQty > 0 && buyLots.length > 0) {
const oldestLot = buyLots[0];
// Match and calculate realized P&L
const matchedQty = min(oldestLot.quantity, remainingSellQty);
realizedPnL += matchedQty * (sellPrice - oldestLot.price);
// Update or remove lot
if (oldestLot.quantity <= remainingSellQty) {
buyLots.shift(); // Fully consumed
} else {
oldestLot.quantity -= remainingSellQty; // Partial
}
}
// Check if position closed
if (buyLots.length === 0) {
currentCycle.isClosed = true;
positionCycles.push(currentCycle);
currentCycle = null; // Ready for new cycle
}
}
}
```
### Position Cycle Detection
A position cycle is considered **closed** when:
- All buy lots have been matched with sell transactions
- Net quantity reaches exactly zero
- No unmatched buy lots remain
When this happens:
1. Current cycle is marked as closed
2. Cycle is moved to closed positions list
3. Next buy starts a fresh position cycle
4. Previous cycle's P&L is "locked in" as realized
### XIRR Calculation Fix
**Old Approach:**
```typescript
// All trades together - causes issues
calculateStockXIRR(allTrades, currentPrice, netQuantity);
```
**New Approach:**
```typescript
// Separate trades by position lifecycle
if (netQuantity === 0) {
// Fully closed - use only closed position trades
// No current holding value to add
calculateStockXIRR(closedPositionTrades, currentPrice, 0);
} else {
// Active position - use only current position trades
// Add current holding value
calculateStockXIRR(activePositionTrades, currentPrice, netQuantity);
}
```
This ensures:
- Closed positions calculate XIRR from their complete lifecycle
- Active positions calculate XIRR from current cycle only
- No mixing of different position lifecycles
- Valid cash flow patterns for the xirr library
---
## Examples
### Example 1: Multiple Position Cycles
**Trades:**
```
2023-01-01: Buy 100 @ ₹50 = ₹5,000
2023-02-01: Sell 100 @ ₹60 = ₹6,000 (Position 1 closed)
2023-03-01: Buy 50 @ ₹55 = ₹2,750
2023-04-01: Buy 50 @ ₹58 = ₹2,900
Current Price: ₹62
```
**FIFO Calculation:**
**Position 1 (Closed):**
- Buy 100 @ ₹50
- Sell 100 @ ₹60
- Realized P&L: ₹6,000 - ₹5,000 = **₹1,000** ✅
- XIRR: Based on these 2 trades only
**Position 2 (Active):**
- Buy 50 @ ₹55 = ₹2,750
- Buy 50 @ ₹58 = ₹2,900
- Total cost: ₹5,650
- Current value: 100 × ₹62 = ₹6,200
- Unrealized P&L: ₹6,200 - ₹5,650 = **₹550** ✅
- XIRR: Based on these 2 buy trades + current value
**Total:**
- Realized P&L: ₹1,000
- Unrealized P&L: ₹550
- Total P&L: ₹1,550 ✅
### Example 2: Partial Sales (No Closure)
**Trades:**
```
2023-01-01: Buy 100 @ ₹50 = ₹5,000
2023-02-01: Sell 30 @ ₹60 = ₹1,800
Current Price: ₹55
```
**FIFO Calculation:**
**Buy Lots After FIFO Matching:**
- Lot 1: 70 remaining @ ₹50 (30 were matched and sold)
**P&L:**
- Realized P&L: ₹1,800 - (30 × ₹50) = **₹300** ✅
- Active position cost: 70 × ₹50 = ₹3,500
- Current value: 70 × ₹55 = ₹3,850
- Unrealized P&L: ₹3,850 - ₹3,500 = **₹350** ✅
- Total P&L: ₹650 ✅
### Example 3: The Original Bug Example
**Trades:**
```
Buy 30 @ ₹100 = ₹3,000
Sell 30 @ ₹120 = ₹3,600
Buy 1 @ ₹130 = ₹130
Current Price: ₹135
```
**OLD (Incorrect):**
```
avgBuyPrice = ₹3,130 / 31 = ₹101.29
Realized P&L = ₹3,600 - (30 × ₹101.29) = ₹561.30 ❌
```
**NEW (Correct with FIFO):**
**Position 1 (Closed):**
- Buy 30 @ ₹100 = ₹3,000
- Sell 30 @ ₹120 = ₹3,600
- Realized P&L: **₹600** ✅
**Position 2 (Active):**
- Buy 1 @ ₹130 = ₹130
- Current value: 1 × ₹135 = ₹135
- Unrealized P&L: **₹5** ✅
**Total P&L:** ₹605 ✅
---
## Testing
### Test Case 1: Position Fully Closed Then Reopened
```typescript
const trades = [
{ date: '2023-01-01', type: 'buy', quantity: 30, price: 100 },
{ date: '2023-02-01', type: 'sell', quantity: 30, price: 120 },
{ date: '2023-03-01', type: 'buy', quantity: 1, price: 130 },
];
const result = calculateFIFOPosition(trades);
// Expected results:
expect(result.realizedPnL).toBe(600); // Only from closed position
expect(result.activePositionCost).toBe(130); // Only current position
expect(result.positionCycles.length).toBe(2);
expect(result.positionCycles[0].isClosed).toBe(true);
expect(result.positionCycles[1].isClosed).toBe(false);
```
### Test Case 2: Multiple Partial Sales
```typescript
const trades = [
{ date: '2023-01-01', type: 'buy', quantity: 100, price: 50 },
{ date: '2023-02-01', type: 'sell', quantity: 30, price: 60 },
{ date: '2023-03-01', type: 'sell', quantity: 20, price: 58 },
];
const result = calculateFIFOPosition(trades);
// Expected:
// Sold 50 @ avg ₹50 = ₹2,500
// Received: (30 × ₹60) + (20 × ₹58) = ₹1,800 + ₹1,160 = ₹2,960
// Realized P&L: ₹2,960 - ₹2,500 = ₹460
expect(result.realizedPnL).toBe(460);
expect(result.netQuantity).toBe(50);
expect(result.activePositionCost).toBe(2500); // 50 remaining @ ₹50
```
### Test Case 3: Multiple Buy Prices (FIFO Order)
```typescript
const trades = [
{ date: '2023-01-01', type: 'buy', quantity: 50, price: 100 },
{ date: '2023-01-15', type: 'buy', quantity: 50, price: 110 },
{ date: '2023-02-01', type: 'sell', quantity: 60, price: 120 },
];
const result = calculateFIFOPosition(trades);
// FIFO: First 50 @ ₹100, then 10 @ ₹110
// Cost: (50 × ₹100) + (10 × ₹110) = ₹6,100
// Received: 60 × ₹120 = ₹7,200
// Realized P&L: ₹1,100
expect(result.realizedPnL).toBe(1100);
expect(result.netQuantity).toBe(40); // 40 remaining @ ₹110
expect(result.activePositionCost).toBe(4400);
expect(result.avgBuyPriceForActivePosition).toBe(110);
```
### Test Case 4: XIRR for Closed Position
```typescript
const trades = [
{ date: '2023-01-01', type: 'buy', quantity: 10, price: 100 },
{ date: '2023-12-31', type: 'sell', quantity: 10, price: 150 },
];
const result = calculateFIFOPosition(trades);
const xirr = calculateStockXIRR(result.closedPositionTrades, 0, 0);
// Should calculate XIRR based on:
// -1000 on 2023-01-01 (buy)
// +1500 on 2023-12-31 (sell)
// No current holding value
expect(xirr).toBeGreaterThan(40); // 50% gain over 1 year
expect(result.closedPositionTrades.length).toBe(2);
expect(result.activePositionTrades.length).toBe(0);
```
---
## Benefits
### 1. Accurate Realized P&L
- ✅ Only includes transactions from actually closed positions
- ✅ Future buys don't affect past realized P&L
- ✅ Proper FIFO matching of sells to oldest buys
### 2. Correct Active Position Metrics
- ✅ Investment shows only cost of current holdings
- ✅ Average price calculated from remaining buy lots only
- ✅ Unrealized P&L based on actual current position cost
### 3. Position Lifecycle Tracking
- ✅ Identifies when positions close (quantity = 0)
- ✅ Separates closed positions from new positions
- ✅ Maintains history of all position cycles
### 4. Valid XIRR Calculation
- ✅ Closed positions use only their lifecycle trades
- ✅ Active positions use only current cycle trades
- ✅ No mixing of different position lifecycles
- ✅ XIRR shows actual returns instead of "N/A"
### 5. Financial Accuracy
- ✅ Follows standard FIFO accounting principles
- ✅ Matches how real brokerages calculate P&L
- ✅ Suitable for tax reporting and performance analysis
---
## Migration Notes
### Backward Compatibility
The changes are **backward compatible**:
- Existing trade data requires no migration
- API response structure remains the same
- Only calculation logic has changed
- Results will be more accurate but format is identical
### Expected Changes in UI
Users may notice:
1. **Realized P&L values may change** - will be more accurate
2. **XIRR will show values** instead of "N/A" for reopened positions
3. **Investment amounts** more accurately reflect current position cost
4. **Average prices** reflect only active position, not all-time average
These changes are **corrections**, not bugs. The new values are the accurate ones.
---
## Files Changed
### New Files
- `equity/lib/fifo-calculator.ts` - FIFO position calculator utility
### Modified Files
- `equity/app/api/tradebook/route.ts` - Updated to use FIFO calculation
- `equity/app/api/stats/route.ts` - Updated to use FIFO calculation
### Documentation
- `equity/POSITION_LIFECYCLE_FIX.md` - This document
---
## Future Enhancements
### Potential Improvements
1. **Tax Lot Reporting**
- Show individual buy lots and their status (open/closed)
- Track holding periods for tax purposes (short-term vs long-term)
2. **LIFO Option**
- Add option to use Last-In-First-Out instead of FIFO
- Useful for different tax optimization strategies
3. **Specific Lot Identification**
- Allow users to specify which lots to sell
- Useful for advanced tax planning
4. **Position Cycle History UI**
- Show all historical position cycles
- Display P&L for each closed cycle
- Visualize position lifecycle timeline
5. **Performance Analytics**
- Compare XIRR across different position cycles
- Identify most profitable entry/exit patterns
- Show win rate for closed positions
---
## Conclusion
This fix implements proper FIFO-based position lifecycle tracking that:
✅ **Solves the original bug** - New buys after position closure don't affect past realized P&L
✅ **Fixes XIRR calculation** - Proper trade separation by position lifecycle
✅ **Follows accounting standards** - Uses standard FIFO matching
✅ **Maintains accuracy** - All metrics now correctly calculated
✅ **Enables future features** - Foundation for tax lot reporting and advanced analytics
The implementation is clean, well-documented, and follows existing code patterns in the project.