Added future projections, and more insights as well as responsiveness

This commit is contained in:
tomato6966 2024-12-22 15:38:24 +01:00
parent 50a9cdb073
commit ad6f5ddf83
12 changed files with 1018 additions and 62 deletions

View file

@ -14,6 +14,7 @@ Why this Project?
![Dark Mode Preview](./docs/dark-mode.png) ![Dark Mode Preview](./docs/dark-mode.png)
![Light Mode Preview](./docs/light-mode.png) ![Light Mode Preview](./docs/light-mode.png)
![Future Projection Modal](./docs/future-projection.png)
## Features ## Features
@ -26,6 +27,7 @@ Why this Project?
- 💹 TTWOR (Time Travel Without Risk) calculations - 💹 TTWOR (Time Travel Without Risk) calculations
- 🔄 Support for one-time and periodic investments - 🔄 Support for one-time and periodic investments
- 📊 Detailed performance metrics - 📊 Detailed performance metrics
- 📅 Future Projection with Withdrawal Analysis and Sustainability Analysis
## Tech Stack ## Tech Stack
@ -47,3 +49,11 @@ Why this Project?
### Local Development ### Local Development
1. Clone the repository 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

68
package-lock.json generated
View file

@ -13,6 +13,7 @@
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.1.0",
"recharts": "^2.12.1", "recharts": "^2.12.1",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zustand": "^4.5.1" "zustand": "^4.5.1"
@ -1285,6 +1286,12 @@
"@babel/types": "^7.20.7" "@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": { "node_modules/@types/d3-array": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@ -1968,6 +1975,15 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true "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": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -3579,6 +3595,46 @@
"node": ">=0.10.0" "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": { "node_modules/react-smooth": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -3775,6 +3831,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4079,6 +4141,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -15,6 +15,7 @@
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.1.0",
"recharts": "^2.12.1", "recharts": "^2.12.1",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zustand": "^4.5.1" "zustand": "^4.5.1"

View 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>
);
};

View file

@ -1,3 +1,4 @@
import { Loader2 } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore"; import { usePortfolioStore } from "../store/portfolioStore";
@ -71,52 +72,57 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage'); const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
const [dynamicValue, setDynamicValue] = useState(''); const [dynamicValue, setDynamicValue] = useState('');
const [yearInterval, setYearInterval] = useState('1'); const [yearInterval, setYearInterval] = useState('1');
const [isSubmitting, setIsSubmitting] = useState(false);
const { dateRange, addInvestment } = usePortfolioStore((state) => ({ const { dateRange, addInvestment } = usePortfolioStore((state) => ({
dateRange: state.dateRange, dateRange: state.dateRange,
addInvestment: state.addInvestment, addInvestment: state.addInvestment,
})); }));
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true);
if (type === "single") { try {
const investment = { if (type === "single") {
id: crypto.randomUUID(), const investment = {
assetId, id: crypto.randomUUID(),
type, assetId,
amount: parseFloat(amount), type,
date 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) {
addInvestment(assetId, investment); 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 ( return (
@ -257,9 +263,14 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
<button <button
type="submit" 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> </button>
</form> </form>
); );

View file

@ -1,11 +1,12 @@
import { format } from "date-fns"; 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 { useCallback, useMemo, useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore"; import { usePortfolioStore } from "../store/portfolioStore";
import { Investment } from "../types"; import { Investment } from "../types";
import { calculateInvestmentPerformance } from "../utils/calculations/performance"; import { calculateInvestmentPerformance } from "../utils/calculations/performance";
import { EditInvestmentModal } from "./EditInvestmentModal"; import { EditInvestmentModal } from "./EditInvestmentModal";
import { FutureProjectionModal } from "./FutureProjectionModal";
interface TooltipProps { interface TooltipProps {
content: string | JSX.Element; content: string | JSX.Element;
@ -49,7 +50,7 @@ export const PortfolioTable = () => {
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]); const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
const averagePerformance = useMemo(() => { 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]); }, [performance.investments]);
const handleDelete = useCallback((investmentId: string, assetId: string) => { const handleDelete = useCallback((investmentId: string, assetId: string) => {
@ -67,13 +68,14 @@ export const PortfolioTable = () => {
const performanceTooltip = useMemo(() => ( const performanceTooltip = useMemo(() => (
<div className="space-y-2"> <div className="space-y-2">
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</p> <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"> <p className="text-xs mt-2">
Note: An average performance of positions doesn't always match your entire portfolio's average, 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. especially with single investments or investments on different time ranges.
</p> </p>
</div> </div>
), [performance.summary.performancePercentage, averagePerformance]); ), [performance.summary.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance]);
const buyInTooltip = useMemo(() => ( const buyInTooltip = useMemo(() => (
<div className="space-y-2"> <div className="space-y-2">
@ -95,16 +97,29 @@ export const PortfolioTable = () => {
</div> </div>
), []); ), []);
const [showProjection, setShowProjection] = useState(false);
return ( 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="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"> <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> <h2 className="text-xl font-bold dark:text-gray-100">Portfolio's <u>Positions</u> Overview</h2>
<button <div className="flex gap-2">
onClick={handleClearAll} <button
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600" onClick={handleClearAll}
> disabled={performance.investments.length === 0}
Clear All Investments className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
</button> >
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>
<div className="relative rounded-lg overflow-hidden"> <div className="relative rounded-lg overflow-hidden">
<table className="min-w-full bg-white dark:bg-slate-800"> <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"></td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{performance.summary.performancePercentage.toFixed(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>
<td className="px-4 py-2"></td> <td className="px-4 py-2"></td>
</tr> </tr>
@ -154,7 +172,7 @@ export const PortfolioTable = () => {
<tr className="italic dark:text-gray-500 border-t border-gray-200 dark:border-slate-600 "> <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">TTWOR</td>
<td className="px-4 py-2"></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.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{performance.summary.ttworValue.toFixed(2)}</td> <td className="px-4 py-2">{performance.summary.ttworValue.toFixed(2)}</td>
<td className="px-4 py-2"></td> <td className="px-4 py-2"></td>
@ -220,6 +238,9 @@ export const PortfolioTable = () => {
onClose={() => setEditingInvestment(null)} onClose={() => setEditingInvestment(null)}
/> />
)} )}
{showProjection && (
<FutureProjectionModal performancePerAnno={performance.summary.performancePerAnnoPerformance} onClose={() => setShowProjection(false)} />
)}
</div> </div>
); );
}; };

View file

@ -15,6 +15,7 @@ interface PortfolioState {
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[]) => void; updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[]) => void;
updateInvestment: (assetId: string, investmentId: string, updatedInvestment: Investment) => void; updateInvestment: (assetId: string, investmentId: string, updatedInvestment: Investment) => void;
clearInvestments: () => void; clearInvestments: () => void;
setAssets: (assets: Asset[]) => void;
} }
export const usePortfolioStore = create<PortfolioState>((set) => ({ export const usePortfolioStore = create<PortfolioState>((set) => ({
@ -77,4 +78,5 @@ export const usePortfolioStore = create<PortfolioState>((set) => ({
set((state) => ({ set((state) => ({
assets: state.assets.map((asset) => ({ ...asset, investments: [] })), assets: state.assets.map((asset) => ({ ...asset, investments: [] })),
})), })),
setAssets: (assets) => set({ assets }),
})); }));

View file

@ -1,4 +1,4 @@
import { addDays, isAfter, isBefore, isSameDay } from "date-fns"; import { isAfter, isBefore, isSameDay } from "date-fns";
import { Asset, Investment } from "../../types"; 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 investments: Investment[] = [];
const periodicGroupId = crypto.randomUUID();
let currentDate = new Date(settings.startDate); let currentDate = new Date(settings.startDate);
let currentAmount = settings.amount; 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) { if (currentDate.getDate() === settings.dayOfMonth) {
// Handle dynamic increases if configured // Handle dynamic increases if configured
if (settings.dynamic) { if (settings.dynamic) {
@ -65,7 +67,8 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
(currentDate.getTime() - new Date(settings.startDate).getTime()) / (currentDate.getTime() - new Date(settings.startDate).getTime()) /
(1000 * 60 * 60 * 24 * 365); (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') { if (settings.dynamic.type === 'percentage') {
currentAmount *= (1 + settings.dynamic.value / 100); currentAmount *= (1 + settings.dynamic.value / 100);
} else { } else {
@ -73,7 +76,7 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
} }
} }
} }
// Create investment for this date
investments.push({ investments.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
type: 'periodic', type: 'periodic',
@ -82,13 +85,20 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
periodicGroupId, periodicGroupId,
assetId 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; return investments;

View 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,
},
};
};

View file

@ -1,4 +1,4 @@
import { isAfter, isBefore } from "date-fns"; import { differenceInDays, isAfter, isBefore } from "date-fns";
import { Asset } from "../../types"; import { Asset } from "../../types";
@ -18,6 +18,7 @@ export interface PortfolioPerformance {
totalInvested: number; totalInvested: number;
currentValue: number; currentValue: number;
performancePercentage: number; performancePercentage: number;
performancePerAnnoPerformance: number;
ttworValue: number; ttworValue: number;
ttworPercentage: number; ttworPercentage: number;
}; };
@ -27,6 +28,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
const investments: InvestmentPerformance[] = []; const investments: InvestmentPerformance[] = [];
let totalInvested = 0; let totalInvested = 0;
let totalCurrentValue = 0; let totalCurrentValue = 0;
let earliestDate: Date | null = null;
// TTWOR Berechnung // TTWOR Berechnung
const firstDayPrices: Record<string, number> = {}; const firstDayPrices: Record<string, number> = {};
@ -49,6 +51,16 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
return acc; return acc;
}, 0); }, 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... // Normale Performance-Berechnungen...
for(const asset of assets) { for(const asset of assets) {
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0; const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
@ -90,6 +102,21 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
? ((ttworValue - totalInvested) / totalInvested) * 100 ? ((ttworValue - totalInvested) / totalInvested) * 100
: 0; : 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 { return {
investments, investments,
summary: { summary: {
@ -98,6 +125,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
performancePercentage: totalInvested > 0 performancePercentage: totalInvested > 0
? ((totalCurrentValue - totalInvested) / totalInvested) * 100 ? ((totalCurrentValue - totalInvested) / totalInvested) * 100
: 0, : 0,
performancePerAnnoPerformance,
ttworValue, ttworValue,
ttworPercentage, ttworPercentage,
}, },

6
src/utils/formatters.ts Normal file
View file

@ -0,0 +1,6 @@
export const formatCurrency = (value: number): string => {
return `${value.toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`;
};