/**
* FIFO Position Calculator
*
* Implements First-In-First-Out matching of buys and sells to properly track:
* - Position lifecycles (when quantity reaches zero, position is closed)
* - Realized P&L (only from matched buy-sell pairs)
* - Unrealized P&L (from remaining open positions)
*
* This ensures that new buys after a position closes don't affect
* the realized P&L of the previous closed position.
*/
export interface Trade {
trade_date: Date;
trade_type: 'buy' | 'sell';
quantity: number;
price: number;
}
export interface PositionCycle {
startDate: Date;
endDate: Date | null; // null if still open
isClosed: boolean;
trades: Trade[];
realizedPnL: number;
realizedQuantity: number;
}
export 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;
activePositionCost: number;
// Position cycles (for debugging/analysis)
positionCycles: PositionCycle[];
// For XIRR calculation
closedPositionTrades: Trade[]; // Trades from fully closed positions
activePositionTrades: Trade[]; // Trades from current active position
}
/**
* Calculate position metrics using FIFO matching
*
* Algorithm:
* 1. Process trades chronologically
* 2. Match sells with oldest unmatched buys (FIFO)
* 3. Track position lifecycles - when quantity hits zero, position closes
* 4. Start new position cycle on next buy after closure
* 5. Calculate realized P&L only from matched pairs in closed positions
*/
export function calculateFIFOPosition(trades: Trade[]): FIFOResult {
// Sort trades chronologically, with BUYs before SELLs on the same date
// This ensures correct FIFO behavior when trades happen on the same day
const sortedTrades = [...trades].sort((a, b) => {
const dateA = new Date(a.trade_date).getTime();
const dateB = new Date(b.trade_date).getTime();
// First, sort by date
if (dateA !== dateB) {
return dateA - dateB;
}
// If same date, BUYs come before SELLs
// This ensures we process buys first on the same day (critical for FIFO)
if (a.trade_type === 'buy' && b.trade_type === 'sell') {
return -1; // a (buy) comes first
}
if (a.trade_type === 'sell' && b.trade_type === 'buy') {
return 1; // b (buy) comes first
}
// If both are same type on same date, maintain original order
return 0;
});
// Track unmatched buy lots
interface BuyLot {
date: Date;
quantity: number;
price: number;
originalQuantity: number;
}
let buyLots: BuyLot[] = [];
let positionCycles: PositionCycle[] = [];
let currentCycle: PositionCycle | null = null;
let totalBuyQuantity = 0;
let totalSellQuantity = 0;
let totalBuyValue = 0;
let totalSellValue = 0;
let totalRealizedPnL = 0;
let totalRealizedQuantity = 0;
for (const trade of sortedTrades) {
const qty = parseFloat(trade.quantity.toString());
const price = parseFloat(trade.price.toString());
if (trade.trade_type === 'buy') {
// Start new position cycle if no current cycle
if (!currentCycle) {
currentCycle = {
startDate: trade.trade_date,
endDate: null,
isClosed: false,
trades: [],
realizedPnL: 0,
realizedQuantity: 0,
};
}
// Add to current cycle
currentCycle.trades.push(trade);
// Add to buy lots
buyLots.push({
date: trade.trade_date,
quantity: qty,
price: price,
originalQuantity: qty,
});
totalBuyQuantity += qty;
totalBuyValue += qty * price;
} else if (trade.trade_type === 'sell') {
// Must have a current cycle to sell
if (!currentCycle) {
console.warn('Sell trade without open position:', trade);
continue;
}
currentCycle.trades.push(trade);
let remainingSellQty = qty;
let sellProceeds = 0;
let sellCost = 0;
// Match with oldest buy lots (FIFO)
while (remainingSellQty > 0 && buyLots.length > 0) {
const oldestLot = buyLots[0];
if (oldestLot.quantity <= remainingSellQty) {
// Consume entire lot
const matchedQty = oldestLot.quantity;
sellProceeds += matchedQty * price;
sellCost += matchedQty * oldestLot.price;
remainingSellQty -= matchedQty;
buyLots.shift(); // Remove consumed lot
} else {
// Partial consumption of lot
sellProceeds += remainingSellQty * price;
sellCost += remainingSellQty * oldestLot.price;
oldestLot.quantity -= remainingSellQty;
remainingSellQty = 0;
}
}
// Calculate realized P&L for this sale
const realizedPnLForThisSale = sellProceeds - sellCost;
currentCycle.realizedPnL += realizedPnLForThisSale;
currentCycle.realizedQuantity += qty;
totalRealizedPnL += realizedPnLForThisSale;
totalRealizedQuantity += qty;
totalSellQuantity += qty;
totalSellValue += qty * price;
// Check if position is now closed (all buy lots matched)
if (buyLots.length === 0) {
currentCycle.endDate = trade.trade_date;
currentCycle.isClosed = true;
positionCycles.push(currentCycle);
currentCycle = null; // Ready for new position cycle
}
}
}
// If there's an open position, add it to cycles
if (currentCycle) {
positionCycles.push(currentCycle);
}
// Calculate metrics for active position
const netQuantity = totalBuyQuantity - totalSellQuantity;
let avgBuyPriceForActivePosition = 0;
let activePositionCost = 0;
if (buyLots.length > 0) {
// Calculate weighted average of remaining buy lots
const totalRemainingValue = buyLots.reduce((sum, lot) =>
sum + (lot.quantity * lot.price), 0
);
const totalRemainingQty = buyLots.reduce((sum, lot) =>
sum + lot.quantity, 0
);
avgBuyPriceForActivePosition = totalRemainingQty > 0
? totalRemainingValue / totalRemainingQty
: 0;
activePositionCost = totalRemainingValue;
}
// Calculate realized P&L percentage
const realizedCost = totalBuyValue - activePositionCost;
const realizedPnLPercent = realizedCost > 0
? (totalRealizedPnL / realizedCost) * 100
: 0;
// Separate trades for XIRR calculation
const closedPositionTrades: Trade[] = [];
const activePositionTrades: Trade[] = [];
for (const cycle of positionCycles) {
if (cycle.isClosed) {
closedPositionTrades.push(...cycle.trades);
} else {
activePositionTrades.push(...cycle.trades);
}
}
return {
totalBuyQuantity,
totalSellQuantity,
totalBuyValue,
totalSellValue,
netQuantity,
realizedPnL: totalRealizedPnL,
realizedPnLPercent,
avgBuyPriceForActivePosition,
activePositionCost,
positionCycles,
closedPositionTrades,
activePositionTrades,
};
}
/**
* Calculate unrealized P&L for active position
*/
export function calculateUnrealizedPnL(
fifoResult: FIFOResult,
currentPrice: number
): { unrealizedPnL: number; unrealizedPnLPercent: number; currentValue: number } {
const currentValue = fifoResult.netQuantity * currentPrice;
const unrealizedPnL = currentValue - fifoResult.activePositionCost;
const unrealizedPnLPercent = fifoResult.activePositionCost > 0
? (unrealizedPnL / fifoResult.activePositionCost) * 100
: 0;
return {
unrealizedPnL,
unrealizedPnLPercent,
currentValue,
};
}