mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-07 11:50:36 +02:00
Added future projections, and more insights as well as responsiveness
This commit is contained in:
parent
50a9cdb073
commit
ad6f5ddf83
12 changed files with 1018 additions and 62 deletions
10
README.md
10
README.md
|
@ -14,6 +14,7 @@ Why this Project?
|
|||
|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
|
@ -26,6 +27,7 @@ Why this Project?
|
|||
- 💹 TTWOR (Time Travel Without Risk) calculations
|
||||
- 🔄 Support for one-time and periodic investments
|
||||
- 📊 Detailed performance metrics
|
||||
- 📅 Future Projection with Withdrawal Analysis and Sustainability Analysis
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
@ -47,3 +49,11 @@ Why this Project?
|
|||
### Local Development
|
||||
|
||||
1. Clone the repository
|
||||
2. Run `npm install`
|
||||
3. Run `npm run dev` -> developer preview
|
||||
- Run `npm run build` -> build for production (dist folder) (you can then launch it with dockerfile or with a static file server like nginx)
|
||||
- Run `npm run preview` -> preview the production build (dist folder)
|
||||
|
||||
### Credits:
|
||||
|
||||
> Thanks to [yahoofinance](https://finance.yahoo.com/) for the stock data.
|
||||
|
|
BIN
docs/future-projection.png
Normal file
BIN
docs/future-projection.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 144 KiB |
68
package-lock.json
generated
68
package-lock.json
generated
|
@ -13,6 +13,7 @@
|
|||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.12.1",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zustand": "^4.5.1"
|
||||
|
@ -1285,6 +1286,12 @@
|
|||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
|
@ -1968,6 +1975,15 @@
|
|||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
|
@ -3579,6 +3595,46 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.0.tgz",
|
||||
"integrity": "sha512-VcFhWqkNIcojDRYaUO8qV0Jib52s9ULpCp3nkBbmrvtoCVFRp6tmk3tJ2w9BZauVctA1YRnJlFYDn9iJRuCpGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0",
|
||||
"turbo-stream": "2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.0.tgz",
|
||||
"integrity": "sha512-F4/nYBC9e4s0/ZjxM8GkZ9a68DpX76LN1a9W9mfPl2GfbDJ9/vzJro6MThNR5qGBH6KkgcK1BziyEzXhHV46Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
|
@ -3775,6 +3831,12 @@
|
|||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
@ -4079,6 +4141,12 @@
|
|||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/turbo-stream": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
|
||||
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.12.1",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zustand": "^4.5.1"
|
||||
|
|
555
src/components/FutureProjectionModal.tsx
Normal file
555
src/components/FutureProjectionModal.tsx
Normal file
|
@ -0,0 +1,555 @@
|
|||
import { BarChart as BarChartIcon, LineChart as LineChartIcon, Loader2, X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
import { usePortfolioStore } from "../store/portfolioStore";
|
||||
import { calculateFutureProjection } from "../utils/calculations/futureProjection";
|
||||
import { formatCurrency } from "../utils/formatters";
|
||||
|
||||
interface FutureProjectionModalProps {
|
||||
onClose: () => void;
|
||||
performancePerAnno: number;
|
||||
}
|
||||
|
||||
type ChartType = 'line' | 'bar';
|
||||
|
||||
export interface WithdrawalPlan {
|
||||
amount: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
startTrigger: 'date' | 'portfolioValue' | 'auto';
|
||||
startDate?: string;
|
||||
startPortfolioValue?: number;
|
||||
enabled: boolean;
|
||||
autoStrategy?: {
|
||||
type: 'maintain' | 'deplete' | 'grow';
|
||||
targetYears?: number;
|
||||
targetGrowth?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectionData {
|
||||
date: string;
|
||||
value: number;
|
||||
invested: number;
|
||||
withdrawals: number;
|
||||
totalWithdrawn: number;
|
||||
}
|
||||
|
||||
export interface SustainabilityAnalysis {
|
||||
yearsToReachTarget: number;
|
||||
targetValue: number;
|
||||
sustainableYears: number | 'infinite';
|
||||
}
|
||||
|
||||
export const FutureProjectionModal = ({ onClose, performancePerAnno }: FutureProjectionModalProps) => {
|
||||
const [years, setYears] = useState('10');
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [chartType, setChartType] = useState<ChartType>('line');
|
||||
const [projectionData, setProjectionData] = useState<ProjectionData[]>([]);
|
||||
const [withdrawalPlan, setWithdrawalPlan] = useState<WithdrawalPlan>({
|
||||
amount: 0,
|
||||
interval: 'monthly',
|
||||
startTrigger: 'date',
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
startPortfolioValue: 0,
|
||||
enabled: false,
|
||||
autoStrategy: {
|
||||
type: 'maintain',
|
||||
targetYears: 30,
|
||||
targetGrowth: 2,
|
||||
},
|
||||
});
|
||||
const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState<SustainabilityAnalysis | null>(null);
|
||||
|
||||
const { assets } = usePortfolioStore();
|
||||
|
||||
const calculateProjection = useCallback(async () => {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
const { projection, sustainability } = await calculateFutureProjection(
|
||||
assets,
|
||||
parseInt(years),
|
||||
performancePerAnno,
|
||||
withdrawalPlan.enabled ? withdrawalPlan : undefined,
|
||||
);
|
||||
setProjectionData(projection);
|
||||
setSustainabilityAnalysis(sustainability);
|
||||
} catch (error) {
|
||||
console.error('Error calculating projection:', error);
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
}, [assets, years, withdrawalPlan, performancePerAnno]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = payload[0].value;
|
||||
const invested = payload[1].value;
|
||||
const withdrawn = payload[2]?.value || 0;
|
||||
const totalWithdrawn = payload[3]?.value || 0;
|
||||
const percentageGain = ((value - invested) / invested) * 100;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 p-4 border rounded shadow-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(label).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
<p className="font-bold text-indigo-600 dark:text-indigo-400">
|
||||
Value: {formatCurrency(value)}
|
||||
</p>
|
||||
<p className="text-purple-600 dark:text-purple-400">
|
||||
Invested: {formatCurrency(invested)}
|
||||
</p>
|
||||
{withdrawn > 0 && (
|
||||
<>
|
||||
<p className="text-orange-500">
|
||||
Monthly Withdrawal: {formatCurrency(withdrawn)}
|
||||
</p>
|
||||
<p className="text-orange-600 font-bold">
|
||||
Total Withdrawn: {formatCurrency(totalWithdrawn)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className={`font-bold ${percentageGain >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
Return: {percentageGain.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderChart = () => {
|
||||
if (isCalculating) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="animate-spin" size={48} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectionData.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center text-red-500 dark:text-red-400">
|
||||
Click calculate to see the projection
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chartType === 'line' ? (
|
||||
<LineChart data={projectionData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(date) => new Date(date).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'numeric'
|
||||
})}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#4f46e5"
|
||||
name="Portfolio Value"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="invested"
|
||||
stroke="#9333ea"
|
||||
name="Invested Amount"
|
||||
/>
|
||||
{withdrawalPlan.enabled && (
|
||||
<>
|
||||
<Line
|
||||
type="step"
|
||||
dataKey="withdrawals"
|
||||
stroke="#f97316"
|
||||
strokeDasharray="5 5"
|
||||
name="Monthly Withdrawal"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="totalWithdrawn"
|
||||
stroke="#ea580c"
|
||||
name="Total Withdrawn"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={projectionData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(date) => new Date(date).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'numeric'
|
||||
})}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#4f46e5"
|
||||
name="Portfolio Value"
|
||||
/>
|
||||
<Bar
|
||||
type="monotone"
|
||||
dataKey="invested"
|
||||
stroke="#9333ea"
|
||||
name="Invested Amount"
|
||||
/>
|
||||
{withdrawalPlan.enabled && (
|
||||
<>
|
||||
<Bar
|
||||
type="step"
|
||||
dataKey="withdrawals"
|
||||
stroke="#f97316"
|
||||
strokeDasharray="5 5"
|
||||
name="Monthly Withdrawal"
|
||||
/>
|
||||
<Bar
|
||||
type="monotone"
|
||||
dataKey="totalWithdrawn"
|
||||
stroke="#ea580c"
|
||||
name="Total Withdrawn"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-0 lg:p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-none lg:rounded-lg w-full lg:w-[80vw] max-w-4xl h-screen lg:h-[75dvh] flex flex-col">
|
||||
<div className="p-4 lg:p-6 border-b dark:border-slate-700 flex-shrink-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Future Portfolio Projection</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 dark:text-gray-200">Projection Settings</h3>
|
||||
<i className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Project for next {years} years
|
||||
</i>
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
value={years}
|
||||
onChange={(e) => setYears(e.target.value)}
|
||||
min="1"
|
||||
max="50"
|
||||
className="w-24 p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={calculateProjection}
|
||||
disabled={isCalculating}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isCalculating ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
'Calculate'
|
||||
)}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={() => setChartType('line')}
|
||||
className={`p-2 rounded ${chartType === 'line' ? 'bg-blue-100 dark:bg-blue-900' : 'hover:bg-gray-100 dark:hover:bg-slate-700'}`}
|
||||
title="Line Chart"
|
||||
>
|
||||
<LineChartIcon size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChartType('bar')}
|
||||
className={`p-2 rounded ${chartType === 'bar' ? 'bg-blue-100 dark:bg-blue-900' : 'hover:bg-gray-100 dark:hover:bg-slate-700'}`}
|
||||
title="Bar Chart"
|
||||
>
|
||||
<BarChartIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-slate-700/50 p-3 rounded">
|
||||
<p>
|
||||
Future projections are calculated with your portfolio's average annual return rate of{' '}
|
||||
<span className="font-semibold underline">{performancePerAnno.toFixed(2)}%</span>.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Strategy explanations:
|
||||
<ul className="list-disc ml-5 mt-1">
|
||||
<li><span className="font-semibold">Maintain:</span> Portfolio value stays constant, withdrawing only the returns</li>
|
||||
<li><span className="font-semibold">Deplete:</span> Portfolio depletes to zero over specified years</li>
|
||||
<li><span className="font-semibold">Grow:</span> Portfolio continues to grow at target rate while withdrawing</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold dark:text-gray-200">Withdrawal Plan</h3>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={withdrawalPlan.enabled}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
enabled: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-4 ${!withdrawalPlan.enabled && 'opacity-50 pointer-events-none'}`}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Withdrawal Amount (€)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={withdrawalPlan.amount}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
amount: parseFloat(e.target.value)
|
||||
}))}
|
||||
min="0"
|
||||
step="100"
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Withdrawal Interval
|
||||
</label>
|
||||
<select
|
||||
value={withdrawalPlan.interval}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
interval: e.target.value as 'monthly' | 'yearly'
|
||||
}))}
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Start Trigger
|
||||
</label>
|
||||
<select
|
||||
value={withdrawalPlan.startTrigger}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
startTrigger: e.target.value as 'date' | 'portfolioValue' | 'auto'
|
||||
}))}
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
>
|
||||
<option value="date">Specific Date</option>
|
||||
<option value="portfolioValue">Portfolio Value Threshold</option>
|
||||
<option value="auto">Auto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{withdrawalPlan.startTrigger === 'date' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={withdrawalPlan.startDate}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
startDate: e.target.value
|
||||
}))}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
) : withdrawalPlan.startTrigger === 'portfolioValue' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Start at Portfolio Value (€)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={withdrawalPlan.startPortfolioValue}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
startPortfolioValue: parseFloat(e.target.value)
|
||||
}))}
|
||||
min="0"
|
||||
step="1000"
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{withdrawalPlan.startTrigger === 'auto' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Desired {withdrawalPlan.interval} Withdrawal (€)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={withdrawalPlan.amount}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
amount: parseFloat(e.target.value)
|
||||
}))}
|
||||
min="0"
|
||||
step="100"
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Strategy
|
||||
</label>
|
||||
<select
|
||||
value={withdrawalPlan.autoStrategy?.type}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
autoStrategy: {
|
||||
...prev.autoStrategy!,
|
||||
type: e.target.value as 'maintain' | 'deplete' | 'grow'
|
||||
}
|
||||
}))}
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
>
|
||||
<option value="maintain">Maintain Portfolio Value</option>
|
||||
<option value="deplete">Planned Depletion</option>
|
||||
<option value="grow">Sustainable Growth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{withdrawalPlan.autoStrategy?.type === 'deplete' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Years to Deplete After Starting
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={withdrawalPlan.autoStrategy.targetYears}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
autoStrategy: {
|
||||
...prev.autoStrategy!,
|
||||
targetYears: parseInt(e.target.value)
|
||||
}
|
||||
}))}
|
||||
min="1"
|
||||
max="100"
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{withdrawalPlan.autoStrategy?.type === 'grow' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Annual Growth After Starting (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={withdrawalPlan.autoStrategy.targetGrowth}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
autoStrategy: {
|
||||
...prev.autoStrategy!,
|
||||
targetGrowth: parseFloat(e.target.value)
|
||||
}
|
||||
}))}
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/30 rounded text-sm">
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
{withdrawalPlan.autoStrategy?.type === 'maintain' && (
|
||||
"The calculator will determine when your portfolio can sustain this withdrawal amount while maintaining its value."
|
||||
)}
|
||||
{withdrawalPlan.autoStrategy?.type === 'deplete' && (
|
||||
"The calculator will determine when you can start withdrawing this amount to deplete the portfolio over your specified timeframe."
|
||||
)}
|
||||
{withdrawalPlan.autoStrategy?.type === 'grow' && (
|
||||
"The calculator will determine when you can start withdrawing this amount while maintaining the target growth rate."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sustainabilityAnalysis && withdrawalPlan.enabled && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">Withdrawal Analysis</h4>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
To withdraw {formatCurrency(withdrawalPlan.amount)} {withdrawalPlan.interval}, you need to invest for{' '}
|
||||
<span className="font-bold">{sustainabilityAnalysis.yearsToReachTarget} years</span> until your portfolio reaches{' '}
|
||||
<span className="font-bold">{formatCurrency(sustainabilityAnalysis.targetValue)}</span>.
|
||||
</p>
|
||||
<p className="text-blue-800 dark:text-blue-200 mt-2">
|
||||
With this withdrawal plan, your portfolio will{' '}
|
||||
{sustainabilityAnalysis.sustainableYears === 'infinite' ? (
|
||||
<span className="font-bold">remain sustainable indefinitely</span>
|
||||
) : (
|
||||
<>
|
||||
last for{' '}
|
||||
<span className="font-bold">
|
||||
{sustainabilityAnalysis.sustainableYears} years
|
||||
</span>{' '}
|
||||
{sustainabilityAnalysis.sustainableYears > parseInt(years) && (
|
||||
<span className="text-sm">
|
||||
(extends beyond the current chart view of {years} years)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="h-[500px]">
|
||||
{renderChart()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,3 +1,4 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { usePortfolioStore } from "../store/portfolioStore";
|
||||
|
@ -71,52 +72,57 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState('');
|
||||
const [yearInterval, setYearInterval] = useState('1');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { dateRange, addInvestment } = usePortfolioStore((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
addInvestment: state.addInvestment,
|
||||
}));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (type === "single") {
|
||||
const investment = {
|
||||
id: crypto.randomUUID(),
|
||||
assetId,
|
||||
type,
|
||||
amount: parseFloat(amount),
|
||||
date
|
||||
};
|
||||
addInvestment(assetId, investment);
|
||||
} else {
|
||||
const periodicSettings = {
|
||||
startDate: date,
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: parseInt(interval),
|
||||
amount: parseFloat(amount),
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
value: parseFloat(dynamicValue),
|
||||
yearInterval: parseInt(yearInterval),
|
||||
},
|
||||
} : undefined),
|
||||
};
|
||||
|
||||
const investments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
new Date(dateRange.endDate),
|
||||
assetId,
|
||||
);
|
||||
|
||||
for(const investment of investments) {
|
||||
try {
|
||||
if (type === "single") {
|
||||
const investment = {
|
||||
id: crypto.randomUUID(),
|
||||
assetId,
|
||||
type,
|
||||
amount: parseFloat(amount),
|
||||
date
|
||||
};
|
||||
addInvestment(assetId, investment);
|
||||
} else {
|
||||
const periodicSettings = {
|
||||
startDate: date,
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: parseInt(interval),
|
||||
amount: parseFloat(amount),
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
value: parseFloat(dynamicValue),
|
||||
yearInterval: parseInt(yearInterval),
|
||||
},
|
||||
} : undefined),
|
||||
};
|
||||
|
||||
const investments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
dateRange.endDate,
|
||||
assetId
|
||||
);
|
||||
|
||||
for (const investment of investments) {
|
||||
addInvestment(assetId, investment);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setAmount('');
|
||||
clearSelectedAsset();
|
||||
}
|
||||
// Reset form
|
||||
setAmount('');
|
||||
clearSelectedAsset();
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -257,9 +263,14 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
Add Investment
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin mx-auto" size={16} />
|
||||
) : (
|
||||
'Add Investment'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { format } from "date-fns";
|
||||
import { HelpCircle, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react";
|
||||
import { HelpCircle, LineChart, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { usePortfolioStore } from "../store/portfolioStore";
|
||||
import { Investment } from "../types";
|
||||
import { calculateInvestmentPerformance } from "../utils/calculations/performance";
|
||||
import { EditInvestmentModal } from "./EditInvestmentModal";
|
||||
import { FutureProjectionModal } from "./FutureProjectionModal";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string | JSX.Element;
|
||||
|
@ -49,7 +50,7 @@ export const PortfolioTable = () => {
|
|||
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
|
||||
|
||||
const averagePerformance = useMemo(() => {
|
||||
return (performance.investments.reduce((sum, inv) => sum + inv.performancePercentage, 0) / performance.investments.length).toFixed(2);
|
||||
return ((performance.investments.reduce((sum, inv) => sum + inv.performancePercentage, 0) / performance.investments.length) || 0).toFixed(2);
|
||||
}, [performance.investments]);
|
||||
|
||||
const handleDelete = useCallback((investmentId: string, assetId: string) => {
|
||||
|
@ -67,13 +68,14 @@ export const PortfolioTable = () => {
|
|||
const performanceTooltip = useMemo(() => (
|
||||
<div className="space-y-2">
|
||||
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</p>
|
||||
<p>The average performance of all positions is {averagePerformance}%</p>
|
||||
<p>The average (acc.) performance of all positions is {averagePerformance}%</p>
|
||||
<p>The average (p.a.) performance of every year is {performance.summary.performancePerAnnoPerformance.toFixed(2)}%</p>
|
||||
<p className="text-xs mt-2">
|
||||
Note: An average performance of positions doesn't always match your entire portfolio's average,
|
||||
especially with single investments or investments on different time ranges.
|
||||
</p>
|
||||
</div>
|
||||
), [performance.summary.performancePercentage, averagePerformance]);
|
||||
), [performance.summary.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance]);
|
||||
|
||||
const buyInTooltip = useMemo(() => (
|
||||
<div className="space-y-2">
|
||||
|
@ -95,16 +97,29 @@ export const PortfolioTable = () => {
|
|||
</div>
|
||||
), []);
|
||||
|
||||
const [showProjection, setShowProjection] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto min-h-[500px] dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold dark:text-gray-100">Portfolio's <u>Positions</u> Overview</h2>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Clear All Investments
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
disabled={performance.investments.length === 0}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Clear All Investments
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowProjection(true)}
|
||||
disabled={performance.investments.length === 0}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
|
||||
>
|
||||
<LineChart size={16} />
|
||||
Future Projection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative rounded-lg overflow-hidden">
|
||||
<table className="min-w-full bg-white dark:bg-slate-800">
|
||||
|
@ -145,7 +160,10 @@ export const PortfolioTable = () => {
|
|||
<td className="px-4 py-2"></td>
|
||||
<td className="px-4 py-2">
|
||||
{performance.summary.performancePercentage.toFixed(2)}%
|
||||
<i className="text-xs text-gray-500 dark:text-gray-400">(avg. {averagePerformance}%)</i>
|
||||
<ul>
|
||||
<li className="text-xs text-gray-500 dark:text-gray-400"> (avg. acc. {averagePerformance}%)</li>
|
||||
<li className="text-xs text-gray-500 dark:text-gray-400"> (avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td className="px-4 py-2"></td>
|
||||
</tr>
|
||||
|
@ -154,7 +172,7 @@ export const PortfolioTable = () => {
|
|||
<tr className="italic dark:text-gray-500 border-t border-gray-200 dark:border-slate-600 ">
|
||||
<td className="px-4 py-2">TTWOR</td>
|
||||
<td className="px-4 py-2"></td>
|
||||
<td className="px-4 py-2">{performance.investments[0]?.date}</td>
|
||||
<td className="px-4 py-2">{new Date(performance.investments[0]?.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</td>
|
||||
<td className="px-4 py-2">€{performance.summary.totalInvested.toFixed(2)}</td>
|
||||
<td className="px-4 py-2">€{performance.summary.ttworValue.toFixed(2)}</td>
|
||||
<td className="px-4 py-2"></td>
|
||||
|
@ -220,6 +238,9 @@ export const PortfolioTable = () => {
|
|||
onClose={() => setEditingInvestment(null)}
|
||||
/>
|
||||
)}
|
||||
{showProjection && (
|
||||
<FutureProjectionModal performancePerAnno={performance.summary.performancePerAnnoPerformance} onClose={() => setShowProjection(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ interface PortfolioState {
|
|||
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[]) => void;
|
||||
updateInvestment: (assetId: string, investmentId: string, updatedInvestment: Investment) => void;
|
||||
clearInvestments: () => void;
|
||||
setAssets: (assets: Asset[]) => void;
|
||||
}
|
||||
|
||||
export const usePortfolioStore = create<PortfolioState>((set) => ({
|
||||
|
@ -77,4 +78,5 @@ export const usePortfolioStore = create<PortfolioState>((set) => ({
|
|||
set((state) => ({
|
||||
assets: state.assets.map((asset) => ({ ...asset, investments: [] })),
|
||||
})),
|
||||
setAssets: (assets) => set({ assets }),
|
||||
}));
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
|
||||
import { isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import { Asset, Investment } from "../../types";
|
||||
|
||||
|
@ -51,13 +51,15 @@ export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice
|
|||
}
|
||||
};
|
||||
|
||||
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: Date, assetId: string): Investment[] => {
|
||||
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: string, assetId: string): Investment[] => {
|
||||
const investments: Investment[] = [];
|
||||
const periodicGroupId = crypto.randomUUID();
|
||||
let currentDate = new Date(settings.startDate);
|
||||
let currentAmount = settings.amount;
|
||||
const periodicGroupId = crypto.randomUUID();
|
||||
const end = new Date(endDate);
|
||||
|
||||
while (isBefore(currentDate, endDate)) {
|
||||
while (currentDate <= end) {
|
||||
// Only create investment if it's on the specified day of month
|
||||
if (currentDate.getDate() === settings.dayOfMonth) {
|
||||
// Handle dynamic increases if configured
|
||||
if (settings.dynamic) {
|
||||
|
@ -65,7 +67,8 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
|
|||
(currentDate.getTime() - new Date(settings.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 365);
|
||||
|
||||
if (yearsSinceStart >= settings.dynamic.yearInterval) {
|
||||
// Check if we've reached a year interval for increase
|
||||
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
|
||||
if (settings.dynamic.type === 'percentage') {
|
||||
currentAmount *= (1 + settings.dynamic.value / 100);
|
||||
} else {
|
||||
|
@ -73,7 +76,7 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
|
|||
}
|
||||
}
|
||||
}
|
||||
// Create investment for this date
|
||||
|
||||
investments.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'periodic',
|
||||
|
@ -82,13 +85,20 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
|
|||
periodicGroupId,
|
||||
assetId
|
||||
});
|
||||
|
||||
// Move to next interval
|
||||
currentDate = addDays(currentDate, settings.interval);
|
||||
} else {
|
||||
// Move to next day if not the investment day
|
||||
currentDate = addDays(currentDate, 1);
|
||||
}
|
||||
|
||||
// Move to next interval day
|
||||
const nextDate = new Date(currentDate);
|
||||
nextDate.setDate(nextDate.getDate() + settings.interval);
|
||||
|
||||
// Ensure we maintain the correct day of month
|
||||
if (nextDate.getDate() !== settings.dayOfMonth) {
|
||||
nextDate.setDate(1);
|
||||
nextDate.setMonth(nextDate.getMonth() + 1);
|
||||
nextDate.setDate(settings.dayOfMonth);
|
||||
}
|
||||
|
||||
currentDate = nextDate;
|
||||
}
|
||||
|
||||
return investments;
|
||||
|
|
244
src/utils/calculations/futureProjection.ts
Normal file
244
src/utils/calculations/futureProjection.ts
Normal file
|
@ -0,0 +1,244 @@
|
|||
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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { isAfter, isBefore } from "date-fns";
|
||||
import { differenceInDays, isAfter, isBefore } from "date-fns";
|
||||
|
||||
import { Asset } from "../../types";
|
||||
|
||||
|
@ -18,6 +18,7 @@ export interface PortfolioPerformance {
|
|||
totalInvested: number;
|
||||
currentValue: number;
|
||||
performancePercentage: number;
|
||||
performancePerAnnoPerformance: number;
|
||||
ttworValue: number;
|
||||
ttworPercentage: number;
|
||||
};
|
||||
|
@ -27,6 +28,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
const investments: InvestmentPerformance[] = [];
|
||||
let totalInvested = 0;
|
||||
let totalCurrentValue = 0;
|
||||
let earliestDate: Date | null = null;
|
||||
|
||||
// TTWOR Berechnung
|
||||
const firstDayPrices: Record<string, number> = {};
|
||||
|
@ -49,6 +51,16 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
return acc;
|
||||
}, 0);
|
||||
|
||||
// Finde das früheste Investmentdatum
|
||||
for(const asset of assets) {
|
||||
for(const investment of asset.investments) {
|
||||
const investmentDate = new Date(investment.date!);
|
||||
if (!earliestDate || isBefore(investmentDate, earliestDate)) {
|
||||
earliestDate = investmentDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normale Performance-Berechnungen...
|
||||
for(const asset of assets) {
|
||||
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
||||
|
@ -90,6 +102,21 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
? ((ttworValue - totalInvested) / totalInvested) * 100
|
||||
: 0;
|
||||
|
||||
// Berechne die jährliche Performance
|
||||
const performancePerAnnoPerformance = (() => {
|
||||
if (!earliestDate || totalInvested === 0) return 0;
|
||||
|
||||
const years = differenceInDays(new Date(), earliestDate) / 365;
|
||||
if (years < 0.01) return 0; // Verhindere Division durch sehr kleine Zahlen
|
||||
|
||||
// Formel: (1 + r)^n = FV/PV
|
||||
// r = (FV/PV)^(1/n) - 1
|
||||
const totalReturn = totalCurrentValue / totalInvested;
|
||||
const annualizedReturn = Math.pow(totalReturn, 1 / years) - 1;
|
||||
|
||||
return annualizedReturn * 100;
|
||||
})();
|
||||
|
||||
return {
|
||||
investments,
|
||||
summary: {
|
||||
|
@ -98,6 +125,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
performancePercentage: totalInvested > 0
|
||||
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
|
||||
: 0,
|
||||
performancePerAnnoPerformance,
|
||||
ttworValue,
|
||||
ttworPercentage,
|
||||
},
|
||||
|
|
6
src/utils/formatters.ts
Normal file
6
src/utils/formatters.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const formatCurrency = (value: number): string => {
|
||||
return `€${value.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})}`;
|
||||
};
|
Loading…
Add table
Reference in a new issue