mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-12 05:08:42 +02:00
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
import { addMonths, differenceInYears, format } from "date-fns";
|
|
|
|
import { Asset, Investment } from "../../types";
|
|
|
|
import type {
|
|
ProjectionData, SustainabilityAnalysis, WithdrawalPlan
|
|
} from "../../components/FutureProjectionModal";
|
|
|
|
const findOptimalStartingPoint = (
|
|
currentPortfolioValue: number,
|
|
monthlyGrowth: number,
|
|
desiredWithdrawal: number,
|
|
strategy: WithdrawalPlan['autoStrategy'],
|
|
interval: 'monthly' | 'yearly'
|
|
): { startDate: string; requiredPortfolioValue: number } => {
|
|
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
|
|
let requiredPortfolioValue = 0;
|
|
|
|
// Declare variables outside switch
|
|
const months = (strategy?.targetYears || 30) * 12;
|
|
const r = monthlyGrowth;
|
|
const targetGrowth = (strategy?.targetGrowth || 2) / 100;
|
|
const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1/12) - 1;
|
|
|
|
switch (strategy?.type) {
|
|
case 'maintain':
|
|
requiredPortfolioValue = monthlyWithdrawal / monthlyGrowth;
|
|
break;
|
|
case 'deplete':
|
|
requiredPortfolioValue = (monthlyWithdrawal * (Math.pow(1 + r, months) - 1)) / (r * Math.pow(1 + r, months));
|
|
break;
|
|
case 'grow':
|
|
requiredPortfolioValue = monthlyWithdrawal / (monthlyGrowth - targetMonthlyGrowth);
|
|
break;
|
|
}
|
|
|
|
// Calculate when we'll reach the required value
|
|
const monthsToReach = Math.ceil(
|
|
Math.log(requiredPortfolioValue / currentPortfolioValue) /
|
|
Math.log(1 + monthlyGrowth)
|
|
);
|
|
|
|
const startDate = new Date();
|
|
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
|
|
|
|
return {
|
|
startDate: startDate.toISOString().split('T')[0],
|
|
requiredPortfolioValue,
|
|
};
|
|
};
|
|
|
|
export const calculateFutureProjection = async (
|
|
currentAssets: Asset[],
|
|
yearsToProject: number,
|
|
annualReturnRate: number,
|
|
withdrawalPlan?: WithdrawalPlan,
|
|
): Promise<{
|
|
projection: ProjectionData[];
|
|
sustainability: SustainabilityAnalysis;
|
|
}> => {
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
const projectionData: ProjectionData[] = [];
|
|
const maxProjectionYears = 100; // Project up to 100 years to find true sustainability
|
|
const endDateForDisplay = addMonths(new Date(), yearsToProject * 12);
|
|
const endDateForCalculation = addMonths(new Date(), maxProjectionYears * 12);
|
|
|
|
// Get all periodic investment patterns
|
|
const periodicInvestments = currentAssets.flatMap(asset => {
|
|
const patterns = new Map<string, Investment[]>();
|
|
|
|
asset.investments.forEach(inv => {
|
|
if (inv.type === 'periodic' && inv.periodicGroupId) {
|
|
if (!patterns.has(inv.periodicGroupId)) {
|
|
patterns.set(inv.periodicGroupId, []);
|
|
}
|
|
patterns.get(inv.periodicGroupId)!.push(inv);
|
|
}
|
|
});
|
|
|
|
return Array.from(patterns.values())
|
|
.map(group => ({
|
|
pattern: group.sort((a, b) =>
|
|
new Date(a.date!).getTime() - new Date(b.date!).getTime()
|
|
)
|
|
}));
|
|
});
|
|
|
|
// Project future investments
|
|
const futureInvestments = periodicInvestments.flatMap(({ pattern }) => {
|
|
if (pattern.length < 2) return [];
|
|
|
|
const lastInvestment = pattern[pattern.length - 1];
|
|
const secondLastInvestment = pattern[pattern.length - 2];
|
|
|
|
const interval = new Date(lastInvestment.date!).getTime() -
|
|
new Date(secondLastInvestment.date!).getTime();
|
|
const amountDiff = lastInvestment.amount - secondLastInvestment.amount;
|
|
|
|
const future: Investment[] = [];
|
|
let currentDate = new Date(lastInvestment.date!);
|
|
let currentAmount = lastInvestment.amount;
|
|
|
|
while (currentDate <= endDateForCalculation) {
|
|
currentDate = new Date(currentDate.getTime() + interval);
|
|
currentAmount += amountDiff;
|
|
|
|
future.push({
|
|
...lastInvestment,
|
|
date: format(currentDate, 'yyyy-MM-dd'),
|
|
amount: currentAmount,
|
|
});
|
|
}
|
|
|
|
return future;
|
|
});
|
|
|
|
// Calculate monthly values
|
|
let currentDate = new Date();
|
|
let totalInvested = currentAssets.reduce(
|
|
(sum, asset) => sum + asset.investments.reduce(
|
|
(assetSum, inv) => assetSum + inv.amount, 0
|
|
), 0
|
|
);
|
|
|
|
let totalWithdrawn = 0;
|
|
let yearsToReachTarget = 0;
|
|
let targetValue = 0;
|
|
let sustainableYears: number | 'infinite' = 'infinite';
|
|
let portfolioValue = totalInvested; // Initialize portfolio value with current investments
|
|
let withdrawalsStarted = false;
|
|
let withdrawalStartDate: Date | null = null;
|
|
let portfolioDepletionDate: Date | null = null;
|
|
|
|
// Calculate optimal withdrawal plan if auto strategy is selected
|
|
if (withdrawalPlan?.enabled && withdrawalPlan.startTrigger === 'auto') {
|
|
const { startDate, requiredPortfolioValue } = findOptimalStartingPoint(
|
|
portfolioValue,
|
|
Math.pow(1 + annualReturnRate/100, 1/12) - 1,
|
|
withdrawalPlan.amount,
|
|
withdrawalPlan.autoStrategy,
|
|
withdrawalPlan.interval
|
|
);
|
|
|
|
withdrawalPlan.startDate = startDate;
|
|
withdrawalPlan.startPortfolioValue = requiredPortfolioValue;
|
|
}
|
|
|
|
while (currentDate <= endDateForCalculation) {
|
|
// Check if withdrawals should start
|
|
if (!withdrawalsStarted && withdrawalPlan?.enabled) {
|
|
withdrawalsStarted = withdrawalPlan.startTrigger === 'date'
|
|
? new Date(currentDate) >= new Date(withdrawalPlan.startDate!)
|
|
: portfolioValue >= (withdrawalPlan.startPortfolioValue || 0);
|
|
|
|
if (withdrawalsStarted) {
|
|
withdrawalStartDate = new Date(currentDate);
|
|
}
|
|
}
|
|
|
|
// Handle monthly growth if portfolio isn't depleted
|
|
if (portfolioValue > 0) {
|
|
const monthlyReturn = Math.pow(1 + annualReturnRate/100, 1/12) - 1;
|
|
portfolioValue *= (1 + monthlyReturn);
|
|
}
|
|
|
|
// Add new investments only if withdrawals haven't started
|
|
if (!withdrawalsStarted) {
|
|
const monthInvestments = futureInvestments.filter(
|
|
inv => new Date(inv.date!).getMonth() === currentDate.getMonth() &&
|
|
new Date(inv.date!).getFullYear() === currentDate.getFullYear()
|
|
);
|
|
|
|
const monthlyInvestment = monthInvestments.reduce(
|
|
(sum, inv) => sum + inv.amount, 0
|
|
);
|
|
totalInvested += monthlyInvestment;
|
|
portfolioValue += monthlyInvestment;
|
|
}
|
|
|
|
|
|
// Handle withdrawals
|
|
let monthlyWithdrawal = 0;
|
|
if (withdrawalsStarted && portfolioValue > 0) {
|
|
monthlyWithdrawal = withdrawalPlan!.interval === 'monthly'
|
|
? withdrawalPlan!.amount
|
|
: (currentDate.getMonth() === 0 ? withdrawalPlan!.amount : 0);
|
|
|
|
portfolioValue -= monthlyWithdrawal;
|
|
if (portfolioValue < 0) {
|
|
monthlyWithdrawal += portfolioValue; // Adjust final withdrawal
|
|
portfolioValue = 0;
|
|
if (sustainableYears === 'infinite') {
|
|
sustainableYears = differenceInYears(currentDate, withdrawalStartDate!);
|
|
}
|
|
}
|
|
totalWithdrawn += monthlyWithdrawal;
|
|
}
|
|
|
|
// Update target metrics
|
|
if (withdrawalsStarted && !targetValue) {
|
|
targetValue = portfolioValue;
|
|
yearsToReachTarget = differenceInYears(currentDate, new Date());
|
|
}
|
|
|
|
if (portfolioValue <= 0 && !portfolioDepletionDate) {
|
|
portfolioDepletionDate = new Date(currentDate);
|
|
}
|
|
|
|
// Only add to projection data if within display timeframe
|
|
if (currentDate <= endDateForDisplay) {
|
|
projectionData.push({
|
|
date: format(currentDate, 'yyyy-MM-dd'),
|
|
value: Math.max(0, portfolioValue),
|
|
invested: totalInvested,
|
|
withdrawals: monthlyWithdrawal,
|
|
totalWithdrawn,
|
|
});
|
|
}
|
|
|
|
currentDate = addMonths(currentDate, 1);
|
|
}
|
|
|
|
// Calculate actual sustainability duration
|
|
let actualSustainableYears: number | 'infinite' = 'infinite';
|
|
if (portfolioDepletionDate) {
|
|
actualSustainableYears = differenceInYears(
|
|
portfolioDepletionDate,
|
|
withdrawalStartDate || new Date()
|
|
);
|
|
} else if (portfolioValue > 0) {
|
|
// If portfolio is still growing after maxProjectionYears, it's truly sustainable
|
|
actualSustainableYears = 'infinite';
|
|
}
|
|
|
|
return {
|
|
projection: projectionData,
|
|
sustainability: {
|
|
yearsToReachTarget,
|
|
targetValue,
|
|
sustainableYears: actualSustainableYears,
|
|
},
|
|
};
|
|
};
|