mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-09 09:30:35 +02:00
v1.2.0 new date management and intervals + slight fixes
This commit is contained in:
parent
4c641701eb
commit
0aa0425938
14 changed files with 285 additions and 174 deletions
package.json
src
components
providers
services
types
utils/calculations
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "investment-portfolio-tracker",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
@ -63,17 +63,25 @@ export default function InvestmentFormWrapper() {
|
|||
);
|
||||
}
|
||||
|
||||
interface IntervalConfig {
|
||||
value: number;
|
||||
unit: 'days' | 'months' | 'years';
|
||||
}
|
||||
|
||||
const InvestmentForm = ({ assetId }: { assetId: string }) => {
|
||||
const [type, setType] = useState<'single' | 'periodic'>('single');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [dayOfMonth, setDayOfMonth] = useState('1');
|
||||
const [interval, setInterval] = useState('30');
|
||||
const [isDynamic, setIsDynamic] = useState(false);
|
||||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState('');
|
||||
const [yearInterval, setYearInterval] = useState('1');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [intervalConfig, setIntervalConfig] = useState<IntervalConfig>({
|
||||
value: 1,
|
||||
unit: 'months'
|
||||
});
|
||||
|
||||
const { dateRange, addInvestment } = usePortfolioSelector((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
|
@ -84,14 +92,9 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log("submitting")
|
||||
console.time('generatePeriodicInvestments');
|
||||
console.timeLog('generatePeriodicInvestments', "1");
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("timeout")
|
||||
try {
|
||||
if (type === "single") {
|
||||
const investment = {
|
||||
|
@ -99,16 +102,17 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
|
|||
assetId,
|
||||
type,
|
||||
amount: parseFloat(amount),
|
||||
date
|
||||
date: new Date(date),
|
||||
};
|
||||
addInvestment(assetId, investment);
|
||||
toast.success('Investment added successfully');
|
||||
} else {
|
||||
const periodicSettings = {
|
||||
startDate: date,
|
||||
startDate: new Date(date),
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: parseInt(interval),
|
||||
interval: intervalConfig.value,
|
||||
amount: parseFloat(amount),
|
||||
intervalUnit: intervalConfig.unit,
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
|
@ -117,23 +121,19 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
|
|||
},
|
||||
} : undefined),
|
||||
};
|
||||
console.timeLog('generatePeriodicInvestments', "2");
|
||||
|
||||
const investments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
dateRange.endDate,
|
||||
new Date(dateRange.endDate),
|
||||
assetId
|
||||
);
|
||||
console.timeLog('generatePeriodicInvestments', "3");
|
||||
addInvestment(assetId, investments);
|
||||
|
||||
toast.success('Periodic investment plan created successfully');
|
||||
toast.success('Sparplan erfolgreich erstellt');
|
||||
}
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to add investment. Please try again.' + String(error?.message || error));
|
||||
toast.error('Fehler beim Erstellen des Investments: ' + String(error?.message || error));
|
||||
} finally {
|
||||
console.timeLog('generatePeriodicInvestments', "4");
|
||||
console.timeEnd('generatePeriodicInvestments');
|
||||
setIsSubmitting(false);
|
||||
setAmount('');
|
||||
}
|
||||
|
@ -193,27 +193,51 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
|
|||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="block text-sm font-medium mb-1">SavingsPlan-Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
// the "dayOf the month should not be change able, due to the day of the"
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Interval (days)
|
||||
Interval
|
||||
<span className="ml-1 text-gray-400 hover:text-gray-600 cursor-help" title="Wählen Sie das Intervall für Ihre regelmäßigen Investitionen. Bei monatlichen Zahlungen am 1. eines Monats werden die Investments automatisch am 1. jeden Monats ausgeführt.">
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={intervalConfig.value}
|
||||
onChange={(e) => setIntervalConfig(prev => ({
|
||||
...prev,
|
||||
value: parseInt(e.target.value)
|
||||
}))}
|
||||
className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={intervalConfig.unit}
|
||||
onChange={(e) => setIntervalConfig(prev => ({
|
||||
...prev,
|
||||
unit: e.target.value as IntervalConfig['unit']
|
||||
}))}
|
||||
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
<option value="quarters">Quarters</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SavingsPlan-Start Datum</label>
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="14"
|
||||
max="365"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
required
|
||||
lang="de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import { useState } from "react";
|
|||
import toast from "react-hot-toast";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { PeriodicSettings } from "../../types";
|
||||
import { generatePeriodicInvestments } from "../../utils/calculations/assetValue";
|
||||
|
||||
interface EditSavingsPlanModalProps {
|
||||
|
@ -31,6 +32,7 @@ export const EditSavingsPlanModal = ({
|
|||
const [amount, setAmount] = useState(initialAmount.toString());
|
||||
const [dayOfMonth, setDayOfMonth] = useState(initialDayOfMonth.toString());
|
||||
const [interval, setInterval] = useState(initialInterval.toString());
|
||||
const [intervalUnit, setIntervalUnit] = useState<'days' | 'weeks' | 'months' | 'quarters' | 'years'>('months');
|
||||
const [isDynamic, setIsDynamic] = useState(!!initialDynamic);
|
||||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>(initialDynamic?.type || 'percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState(initialDynamic?.value.toString() || '');
|
||||
|
@ -61,10 +63,11 @@ export const EditSavingsPlanModal = ({
|
|||
});
|
||||
|
||||
// Generate and add new investments
|
||||
const periodicSettings = {
|
||||
startDate,
|
||||
const periodicSettings: PeriodicSettings = {
|
||||
startDate: new Date(startDate),
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: parseInt(interval),
|
||||
intervalUnit: intervalUnit,
|
||||
amount: parseFloat(amount),
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
|
@ -134,17 +137,28 @@ export const EditSavingsPlanModal = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Interval (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">Interval</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={intervalUnit}
|
||||
onChange={(e) => setIntervalUnit(e.target.value as 'days' | 'weeks' | 'months' | 'quarters' | 'years')}
|
||||
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
<option value="quarters">Quarters</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { isSameDay } from "date-fns";
|
||||
import { BarChart as BarChartIcon, LineChart as LineChartIcon, Loader2, X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
|
@ -9,7 +10,6 @@ import { calculateFutureProjection } from "../../utils/calculations/futureProjec
|
|||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
import type { ProjectionData, SustainabilityAnalysis, WithdrawalPlan } from "../../types";
|
||||
|
||||
interface FutureProjectionModalProps {
|
||||
performancePerAnno: number;
|
||||
bestPerformancePerAnno: { percentage: number, year: number }[];
|
||||
|
@ -39,7 +39,7 @@ export const FutureProjectionModal = ({
|
|||
amount: 0,
|
||||
interval: 'monthly',
|
||||
startTrigger: 'auto',
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
startDate: new Date(),
|
||||
startPortfolioValue: 0,
|
||||
enabled: false,
|
||||
autoStrategy: {
|
||||
|
@ -65,8 +65,8 @@ export const FutureProjectionModal = ({
|
|||
);
|
||||
setProjectionData(projection);
|
||||
setSustainabilityAnalysis(sustainability);
|
||||
const slicedBestCase = bestPerformancePerAnno.slice(0, Math.floor(bestPerformancePerAnno.length / 2));
|
||||
const slicedWorstCase = worstPerformancePerAnno.slice(0, Math.floor(worstPerformancePerAnno.length / 2));
|
||||
const slicedBestCase = bestPerformancePerAnno.slice(0, bestPerformancePerAnno.length > 1 ? Math.floor(bestPerformancePerAnno.length / 2) : 1);
|
||||
const slicedWorstCase = worstPerformancePerAnno.slice(0, worstPerformancePerAnno.length > 1 ? Math.floor(worstPerformancePerAnno.length / 2) : 1);
|
||||
const bestCase = slicedBestCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedBestCase.length || 0;
|
||||
const worstCase = slicedWorstCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedWorstCase.length || 0;
|
||||
|
||||
|
@ -335,8 +335,8 @@ export const FutureProjectionModal = ({
|
|||
// Create a merged and sorted dataset for consistent x-axis
|
||||
const mergedData = projectionData.map(basePoint => {
|
||||
const date = basePoint.date;
|
||||
const bestPoint = scenarios.best.projection.find(p => p.date === date);
|
||||
const worstPoint = scenarios.worst.projection.find(p => p.date === date);
|
||||
const bestPoint = scenarios.best.projection.find(p => isSameDay(p.date, date));
|
||||
const worstPoint = scenarios.worst.projection.find(p => isSameDay(p.date, date));
|
||||
|
||||
return {
|
||||
date,
|
||||
|
@ -549,10 +549,10 @@ export const FutureProjectionModal = ({
|
|||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={withdrawalPlan.startDate}
|
||||
value={withdrawalPlan.startDate?.toISOString().split('T')[0]}
|
||||
onChange={(e) => setWithdrawalPlan(prev => ({
|
||||
...prev,
|
||||
startDate: e.target.value
|
||||
startDate: new Date(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"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { format } from "date-fns";
|
||||
import { BarChart2, Eye, EyeOff, Maximize2, X } from "lucide-react";
|
||||
import { BarChart2, Eye, EyeOff, Maximize2, RefreshCcw, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
|
@ -27,7 +27,7 @@ export default function PortfolioChart() {
|
|||
}));
|
||||
|
||||
const fetchHistoricalData = useCallback(
|
||||
async (startDate: string, endDate: string) => {
|
||||
async (startDate: Date, endDate: Date) => {
|
||||
for (const asset of assets) {
|
||||
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
|
||||
updateAssetHistoricalData(asset.id, historicalData, longName);
|
||||
|
@ -66,14 +66,15 @@ export default function PortfolioChart() {
|
|||
|
||||
// Calculate percentage changes for each asset
|
||||
const processedData = useMemo(() => data.map(point => {
|
||||
const processed: { [key: string]: number | string } = {
|
||||
date: point.date,
|
||||
const processed: { date: string, total: number, invested: number, percentageChange: number, ttwor: number, ttwor_percent: number, [key: string]: number | string } = {
|
||||
date: format(point.date, 'yyyy-MM-dd'),
|
||||
total: point.total,
|
||||
invested: point.invested,
|
||||
percentageChange: point.percentageChange,
|
||||
ttwor: 0,
|
||||
ttwor_percent: 0,
|
||||
};
|
||||
|
||||
processed["ttwor"] = 0;
|
||||
for (const asset of assets) {
|
||||
const initialPrice = data[0].assets[asset.id];
|
||||
const currentPrice = point.assets[asset.id];
|
||||
|
@ -81,11 +82,11 @@ export default function PortfolioChart() {
|
|||
processed[`${asset.id}_price`] = currentPrice;
|
||||
const percentDecimal = ((currentPrice - initialPrice) / initialPrice);
|
||||
processed[`${asset.id}_percent`] = percentDecimal * 100;
|
||||
processed["ttwor"] += allAssetsInvestedKapitals[asset.id] + allAssetsInvestedKapitals[asset.id] * percentDecimal;
|
||||
processed.ttwor += allAssetsInvestedKapitals[asset.id] + allAssetsInvestedKapitals[asset.id] * percentDecimal;
|
||||
}
|
||||
}
|
||||
|
||||
processed["ttwor_percent"] = (processed["ttwor"] - Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0)) / Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0) * 100;
|
||||
processed.ttwor_percent = (processed.ttwor - Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0)) / Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0) * 100;
|
||||
|
||||
|
||||
// add a processed["ttwor"] ttwor is what if you invested all of the kapital of all assets at the start of the period
|
||||
|
@ -170,6 +171,12 @@ export default function PortfolioChart() {
|
|||
debouncedFetchHistoricalData(newRange.startDate, newRange.endDate);
|
||||
}, [updateDateRange, debouncedFetchHistoricalData]);
|
||||
|
||||
const [renderKey, setRenderKey] = useState(0);
|
||||
|
||||
const handleReRender = useCallback(() => {
|
||||
setRenderKey(prevKey => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const ChartContent = useCallback(() => (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
|
@ -179,26 +186,34 @@ export default function PortfolioChart() {
|
|||
onStartDateChange={(date) => handleUpdateDateRange({ ...dateRange, startDate: date })}
|
||||
onEndDateChange={(date) => handleUpdateDateRange({ ...dateRange, endDate: date })}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleReRender}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded ml-2 hover:text-blue-500"
|
||||
>
|
||||
<RefreshCcw className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded hover:text-blue-500"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"}>
|
||||
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"} key={renderKey}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={processedData}>
|
||||
<LineChart data={processedData} className="p-3">
|
||||
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||
<XAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
dataKey="date"
|
||||
tickFormatter={(date) => format(new Date(date), 'MMM dd')}
|
||||
tickFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
yAxisId="left"
|
||||
tickFormatter={(value) => `${value.toLocaleString()}€`}
|
||||
tickFormatter={(value) => `${value.toFixed(2)}€`}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
|
@ -231,8 +246,9 @@ export default function PortfolioChart() {
|
|||
|
||||
return [`${value.toLocaleString()}€ (${((value - Number(assets[assetKey])) / Number(assets[assetKey]) * 100).toFixed(2)}%)`, name];
|
||||
}}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM dd, yyyy')}
|
||||
/>
|
||||
labelFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
||||
>
|
||||
</Tooltip>
|
||||
<Legend content={<CustomLegend />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
|
@ -292,16 +308,19 @@ export default function PortfolioChart() {
|
|||
</ResponsiveContainer>
|
||||
</div>
|
||||
<i className="text-xs text-gray-500">
|
||||
Note: The YAxis on the left shows the value of your portfolio (black line) and invested capital (dotted line),
|
||||
*Note: The YAxis on the left shows the value of your portfolio (black line) and invested capital (dotted line),
|
||||
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
|
||||
</i>
|
||||
<p className="text-xs mt-2 text-gray-500 italic">
|
||||
**Note: The % is based on daily weighted average data, thus the percentages might alter slightly.
|
||||
</p>
|
||||
</>
|
||||
), [assets, isDarkMode, assetColors, handleUpdateDateRange, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, isFullscreen]);
|
||||
), [assets, handleReRender, isDarkMode, assetColors, handleUpdateDateRange, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, isFullscreen, renderKey]);
|
||||
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 p-6">
|
||||
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Portfolio Chart</h2>
|
||||
<button
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { format } from "date-fns";
|
||||
import { format, isBefore } from "date-fns";
|
||||
import {
|
||||
Download, FileDown, LineChart, Loader2, Pencil, RefreshCw, ShoppingBag, Trash2
|
||||
} from "lucide-react";
|
||||
|
@ -306,7 +306,7 @@ export default function PortfolioTable() {
|
|||
assetId: asset.id,
|
||||
groupId,
|
||||
amount: firstInvestment.amount,
|
||||
dayOfMonth: parseInt(firstInvestment.date!.split('-')[2]),
|
||||
dayOfMonth: firstInvestment.date?.getDate() || 0,
|
||||
interval: 30, // You might want to store this in the investment object
|
||||
// Add dynamic settings if available
|
||||
})}
|
||||
|
@ -432,7 +432,7 @@ export default function PortfolioTable() {
|
|||
</tr>
|
||||
</>
|
||||
)}
|
||||
{performance.investments.sort((a, b) => a.date.localeCompare(b.date)).map((inv, index) => {
|
||||
{performance.investments.sort((a, b) => isBefore(a.date, b.date) ? -1 : 1).map((inv, index) => {
|
||||
const asset = assets.find(a => a.name === inv.assetName)!;
|
||||
const investment = asset.investments.find(i => i.id === inv.id)! || inv;
|
||||
const filtered = performance.investments.filter(v => v.assetName === inv.assetName);
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { format, isValid, parseISO } from "date-fns";
|
||||
import { useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onStartDateChange: (date: string) => void;
|
||||
onEndDateChange: (date: string) => void;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onStartDateChange: (date: Date) => void;
|
||||
onEndDateChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
export const DateRangePicker = ({
|
||||
|
@ -17,24 +18,44 @@ export const DateRangePicker = ({
|
|||
const startDateRef = useRef<HTMLInputElement>(null);
|
||||
const endDateRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const formatDateToISO = (date: Date) => {
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
};
|
||||
|
||||
const isValidDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date instanceof Date && !isNaN(date.getTime()) && dateString.length === 10;
|
||||
const parsed = parseISO(dateString);
|
||||
return isValid(parsed);
|
||||
};
|
||||
|
||||
const debouncedStartDateChange = useDebouncedCallback(
|
||||
(newDate: string) => {
|
||||
if (newDate !== startDate && isValidDate(newDate)) {
|
||||
onStartDateChange(newDate);
|
||||
(dateString: string) => {
|
||||
if (isValidDate(dateString)) {
|
||||
const newDate = new Date(Date.UTC(
|
||||
parseISO(dateString).getUTCFullYear(),
|
||||
parseISO(dateString).getUTCMonth(),
|
||||
parseISO(dateString).getUTCDate()
|
||||
));
|
||||
|
||||
if (newDate.getTime() !== startDate.getTime()) {
|
||||
onStartDateChange(newDate);
|
||||
}
|
||||
}
|
||||
},
|
||||
750
|
||||
);
|
||||
|
||||
const debouncedEndDateChange = useDebouncedCallback(
|
||||
(newDate: string) => {
|
||||
if (newDate !== endDate && isValidDate(newDate)) {
|
||||
onEndDateChange(newDate);
|
||||
(dateString: string) => {
|
||||
if (isValidDate(dateString)) {
|
||||
const newDate = new Date(Date.UTC(
|
||||
parseISO(dateString).getUTCFullYear(),
|
||||
parseISO(dateString).getUTCMonth(),
|
||||
parseISO(dateString).getUTCDate()
|
||||
));
|
||||
|
||||
if (newDate.getTime() !== endDate.getTime()) {
|
||||
onEndDateChange(newDate);
|
||||
}
|
||||
}
|
||||
},
|
||||
750
|
||||
|
@ -59,10 +80,11 @@ export const DateRangePicker = ({
|
|||
<input
|
||||
ref={startDateRef}
|
||||
type="date"
|
||||
defaultValue={startDate}
|
||||
defaultValue={formatDateToISO(startDate)}
|
||||
onChange={handleStartDateChange}
|
||||
max={endDate}
|
||||
max={formatDateToISO(endDate)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
lang="de"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -70,11 +92,12 @@ export const DateRangePicker = ({
|
|||
<input
|
||||
ref={endDateRef}
|
||||
type="date"
|
||||
defaultValue={endDate}
|
||||
defaultValue={formatDateToISO(endDate)}
|
||||
onChange={handleEndDateChange}
|
||||
min={startDate}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
min={formatDateToISO(startDate)}
|
||||
max={formatDateToISO(new Date())}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
lang="de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { format, startOfYear } from "date-fns";
|
||||
import { startOfYear } from "date-fns";
|
||||
import { createContext, useMemo, useReducer } from "react";
|
||||
|
||||
import { Asset, DateRange, HistoricalData, Investment } from "../types";
|
||||
|
@ -29,8 +29,8 @@ const initialState: PortfolioState = {
|
|||
assets: [],
|
||||
isLoading: false,
|
||||
dateRange: {
|
||||
startDate: format(startOfYear(new Date()), 'yyyy-MM-dd'),
|
||||
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
startDate: startOfYear(new Date()),
|
||||
endDate: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -55,10 +55,10 @@ export const searchAssets = async (query: string): Promise<Asset[]> => {
|
|||
}
|
||||
};
|
||||
|
||||
export const getHistoricalData = async (symbol: string, startDate: string, endDate: string) => {
|
||||
export const getHistoricalData = async (symbol: string, startDate: Date, endDate: Date) => {
|
||||
try {
|
||||
const start = Math.floor(new Date(startDate).getTime() / 1000);
|
||||
const end = Math.floor(new Date(endDate).getTime() / 1000);
|
||||
const start = Math.floor(startDate.getTime() / 1000);
|
||||
const end = Math.floor(endDate.getTime() / 1000);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
period1: start.toString(),
|
||||
|
@ -76,7 +76,7 @@ export const getHistoricalData = async (symbol: string, startDate: string, endDa
|
|||
|
||||
return {
|
||||
historicalData: timestamp.map((time: number, index: number) => ({
|
||||
date: new Date(time * 1000).toISOString().split('T')[0],
|
||||
date: new Date(time * 1000),
|
||||
price: quotes.close[index],
|
||||
})),
|
||||
longName: meta.longName
|
||||
|
|
|
@ -11,7 +11,7 @@ export interface Asset {
|
|||
}
|
||||
|
||||
export interface HistoricalData {
|
||||
date: string;
|
||||
date: Date;
|
||||
price: number;
|
||||
}
|
||||
|
||||
|
@ -20,13 +20,15 @@ export interface Investment {
|
|||
assetId: string;
|
||||
type: 'single' | 'periodic';
|
||||
amount: number;
|
||||
date?: string;
|
||||
date?: Date;
|
||||
periodicGroupId?: string;
|
||||
}
|
||||
|
||||
export interface PeriodicSettings {
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
intervalUnit: 'days' | 'weeks' | 'months' | 'quarters' | 'years';
|
||||
startDate: Date;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
|
@ -37,7 +39,7 @@ export interface PeriodicSettings {
|
|||
export interface InvestmentPerformance {
|
||||
id: string;
|
||||
assetName: string;
|
||||
date: string;
|
||||
date: Date;
|
||||
investedAmount: number;
|
||||
investedAtPrice: number;
|
||||
currentValue: number;
|
||||
|
@ -46,14 +48,14 @@ export interface InvestmentPerformance {
|
|||
}
|
||||
|
||||
export interface DateRange {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export interface InvestmentPerformance {
|
||||
id: string;
|
||||
assetName: string;
|
||||
date: string;
|
||||
date: Date;
|
||||
investedAmount: number;
|
||||
investedAtPrice: number;
|
||||
currentValue: number;
|
||||
|
@ -75,7 +77,7 @@ export interface PortfolioPerformance {
|
|||
}
|
||||
|
||||
export type DayData = {
|
||||
date: string;
|
||||
date: Date;
|
||||
total: number;
|
||||
invested: number;
|
||||
percentageChange: number;
|
||||
|
@ -87,7 +89,7 @@ export interface WithdrawalPlan {
|
|||
amount: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
startTrigger: 'date' | 'portfolioValue' | 'auto';
|
||||
startDate?: string;
|
||||
startDate?: Date;
|
||||
startPortfolioValue?: number;
|
||||
enabled: boolean;
|
||||
autoStrategy?: {
|
||||
|
@ -98,7 +100,7 @@ export interface WithdrawalPlan {
|
|||
}
|
||||
|
||||
export interface ProjectionData {
|
||||
date: string;
|
||||
date: Date;
|
||||
value: number;
|
||||
invested: number;
|
||||
withdrawals: number;
|
||||
|
@ -112,7 +114,7 @@ export interface SustainabilityAnalysis {
|
|||
}
|
||||
|
||||
export interface PeriodicSettings {
|
||||
startDate: string;
|
||||
startDate: Date;
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
amount: number;
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { isAfter, isBefore, isSameDay } from "date-fns";
|
||||
import {
|
||||
addDays, addMonths, addWeeks, addYears, isAfter, isBefore, isSameDay, setDate
|
||||
} from "date-fns";
|
||||
|
||||
import type { Asset, Investment, PeriodicSettings } from "../../types";
|
||||
|
||||
|
@ -13,7 +15,7 @@ export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice
|
|||
|
||||
// Find price at investment date
|
||||
const investmentPrice = asset.historicalData.find(
|
||||
(data) => data.date === investment.date
|
||||
(data) => isSameDay(data.date, invDate)
|
||||
)?.price || 0;
|
||||
|
||||
// if no investment price found, use the previous price
|
||||
|
@ -39,26 +41,42 @@ export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice
|
|||
}
|
||||
};
|
||||
|
||||
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: string, assetId: string): Investment[] => {
|
||||
|
||||
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: Date, assetId: string): Investment[] => {
|
||||
const investments: Investment[] = [];
|
||||
const periodicGroupId = crypto.randomUUID();
|
||||
let currentDate = new Date(settings.startDate);
|
||||
|
||||
// Create UTC dates
|
||||
let currentDate = new Date(Date.UTC(
|
||||
settings.startDate.getUTCFullYear(),
|
||||
settings.startDate.getUTCMonth(),
|
||||
settings.startDate.getUTCDate()
|
||||
));
|
||||
|
||||
const end = new Date(Date.UTC(
|
||||
endDate.getUTCFullYear(),
|
||||
endDate.getUTCMonth(),
|
||||
endDate.getUTCDate()
|
||||
));
|
||||
|
||||
let currentAmount = settings.amount;
|
||||
const end = new Date(endDate);
|
||||
|
||||
while (currentDate <= end) {
|
||||
// Only create investment if it's on the specified day of month
|
||||
if (currentDate.getDate() === settings.dayOfMonth) {
|
||||
// For monthly/yearly intervals, ensure we're on the correct day of month
|
||||
if (settings.intervalUnit !== 'days') {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
|
||||
// Only add investment if we haven't passed the end date
|
||||
if (currentDate <= end) {
|
||||
// Handle dynamic increases if configured
|
||||
if (settings.dynamic) {
|
||||
const yearsSinceStart =
|
||||
(currentDate.getTime() - new Date(settings.startDate).getTime()) /
|
||||
(currentDate.getTime() - settings.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 365);
|
||||
|
||||
// Check if we've reached a year interval for increase
|
||||
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
|
||||
if (settings.dynamic.type === 'percentage') {
|
||||
console.log('percentage', settings.dynamic.value, (1 + (settings.dynamic.value / 100)));
|
||||
currentAmount *= (1 + (settings.dynamic.value / 100));
|
||||
} else {
|
||||
currentAmount += settings.dynamic.value;
|
||||
|
@ -70,24 +88,38 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
|
|||
id: crypto.randomUUID(),
|
||||
type: 'periodic',
|
||||
amount: currentAmount,
|
||||
date: currentDate.toISOString().split('T')[0],
|
||||
date: currentDate,
|
||||
periodicGroupId,
|
||||
assetId
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Calculate next date based on interval unit
|
||||
switch (settings.intervalUnit) {
|
||||
case 'days':
|
||||
currentDate = addDays(currentDate, settings.interval);
|
||||
break;
|
||||
case 'weeks':
|
||||
currentDate = addWeeks(currentDate, settings.interval);
|
||||
break;
|
||||
case 'months':
|
||||
currentDate = addMonths(currentDate, settings.interval);
|
||||
// Ensure we maintain the correct day of month using UTC
|
||||
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
break;
|
||||
case 'quarters':
|
||||
currentDate = addMonths(currentDate, settings.interval * 3);
|
||||
break;
|
||||
case 'years':
|
||||
currentDate = addYears(currentDate, settings.interval);
|
||||
// Ensure we maintain the correct day of month using UTC
|
||||
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
currentDate = nextDate;
|
||||
}
|
||||
|
||||
return investments;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { addMonths, differenceInYears, format } from "date-fns";
|
||||
import { addMonths, differenceInYears } from "date-fns";
|
||||
|
||||
import type {
|
||||
ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment
|
||||
|
@ -10,7 +10,7 @@ const findOptimalStartingPoint = (
|
|||
desiredWithdrawal: number,
|
||||
strategy: WithdrawalPlan['autoStrategy'],
|
||||
interval: 'monthly' | 'yearly'
|
||||
): { startDate: string; requiredPortfolioValue: number } => {
|
||||
): { startDate: Date; requiredPortfolioValue: number } => {
|
||||
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
|
||||
let requiredPortfolioValue = 0;
|
||||
|
||||
|
@ -42,7 +42,7 @@ const findOptimalStartingPoint = (
|
|||
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
|
||||
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
startDate,
|
||||
requiredPortfolioValue,
|
||||
};
|
||||
};
|
||||
|
@ -105,7 +105,7 @@ export const calculateFutureProjection = async (
|
|||
|
||||
future.push({
|
||||
...lastInvestment,
|
||||
date: format(currentDate, 'yyyy-MM-dd'),
|
||||
date: currentDate,
|
||||
amount: currentAmount,
|
||||
});
|
||||
}
|
||||
|
@ -208,7 +208,7 @@ export const calculateFutureProjection = async (
|
|||
// Only add to projection data if within display timeframe
|
||||
if (currentDate <= endDateForDisplay) {
|
||||
projectionData.push({
|
||||
date: format(currentDate, 'yyyy-MM-dd'),
|
||||
date: currentDate,
|
||||
value: Math.max(0, portfolioValue),
|
||||
invested: totalInvested,
|
||||
withdrawals: monthlyWithdrawal,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isAfter, isBefore } from "date-fns";
|
||||
import { isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import type { Asset, InvestmentPerformance, PortfolioPerformance } from "../../types";
|
||||
|
||||
|
@ -76,16 +76,18 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
);
|
||||
|
||||
for (const investment of relevantInvestments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
|
||||
const investmentPrice = asset.historicalData.find(
|
||||
(data) => data.date === investment.date
|
||||
(data) => isSameDay(data.date, invDate)
|
||||
)?.price || 0;
|
||||
|
||||
const previousPrice = investmentPrice || asset.historicalData.filter(
|
||||
(data) => isBefore(new Date(data.date), new Date(investment.date!))
|
||||
(data) => isBefore(new Date(data.date), invDate)
|
||||
).reverse().find((v) => v.price !== 0)?.price || 0;
|
||||
|
||||
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
|
||||
(data) => isAfter(new Date(data.date), new Date(investment.date!))
|
||||
(data) => isAfter(new Date(data.date), invDate)
|
||||
).find((v) => v.price !== 0)?.price || 0;
|
||||
|
||||
if (buyInPrice > 0) {
|
||||
|
@ -128,16 +130,17 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
||||
|
||||
for (const investment of asset.investments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
const investmentPrice = asset.historicalData.find(
|
||||
(data) => data.date === investment.date
|
||||
(data) => isSameDay(data.date, invDate)
|
||||
)?.price || 0;
|
||||
|
||||
const previousPrice = investmentPrice || asset.historicalData.filter(
|
||||
(data) => isBefore(new Date(data.date), new Date(investment.date!))
|
||||
(data) => isBefore(new Date(data.date), invDate)
|
||||
).reverse().find((v) => v.price !== 0)?.price || 0;
|
||||
|
||||
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
|
||||
(data) => isAfter(new Date(data.date), new Date(investment.date!))
|
||||
(data) => isAfter(new Date(data.date), invDate)
|
||||
).find((v) => v.price !== 0)?.price || 0;
|
||||
|
||||
const shares = buyInPrice > 0 ? investment.amount / buyInPrice : 0;
|
||||
|
@ -165,24 +168,9 @@ 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;
|
||||
// })();
|
||||
|
||||
const performancePerAnnoPerformance = annualPerformances.reduce((acc, curr) => acc + curr.percentage, 0) / annualPerformances.length;
|
||||
|
||||
console.log(performancePerAnnoPerformance, annualPerformances);
|
||||
return {
|
||||
investments,
|
||||
summary: {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { addDays, isAfter, isBefore } from "date-fns";
|
||||
import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import { calculateAssetValueAtDate } from "./assetValue";
|
||||
|
||||
|
@ -8,14 +8,14 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
|||
const { startDate, endDate } = dateRange;
|
||||
const data: DayData[] = [];
|
||||
|
||||
let currentDate = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
let currentDate = startDate;
|
||||
const end = endDate;
|
||||
|
||||
const beforeValue: { [assetId: string]: number } = {};
|
||||
|
||||
while (isBefore(currentDate, end)) {
|
||||
const dayData: DayData = {
|
||||
date: currentDate.toISOString().split('T')[0],
|
||||
date: currentDate,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
percentageChange: 0,
|
||||
|
@ -38,7 +38,7 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
|||
|
||||
// Get historical price for the asset
|
||||
const currentValueOfAsset = asset.historicalData.find(
|
||||
(data) => data.date === dayData.date
|
||||
(data) => isSameDay(data.date, dayData.date)
|
||||
)?.price || beforeValue[asset.id];
|
||||
beforeValue[asset.id] = currentValueOfAsset;
|
||||
|
||||
|
@ -52,13 +52,14 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
|||
dayData.total += investedValue || 0;
|
||||
dayData.assets[asset.id] = currentValueOfAsset;
|
||||
|
||||
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
|
||||
if (!Number.isNaN(percent) && investedValue && investedValue > 0) {
|
||||
weightedPercents.push({
|
||||
percent,
|
||||
weight: investedValue
|
||||
});
|
||||
}
|
||||
const performancePercentage = investedValue > 0
|
||||
? ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100
|
||||
: 0;
|
||||
|
||||
weightedPercents.push({
|
||||
percent: performancePercentage,
|
||||
weight: investedValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,6 +72,14 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
|||
dayData.percentageChange = 0;
|
||||
}
|
||||
|
||||
const totalInvested = dayData.invested; // Total invested amount for the day
|
||||
const totalCurrentValue = dayData.total; // Total current value for the day
|
||||
|
||||
dayData.percentageChange = totalInvested > 0
|
||||
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
|
||||
: 0;
|
||||
|
||||
|
||||
currentDate = addDays(currentDate, 1);
|
||||
data.push(dayData);
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue