v1.2.0 new date management and intervals + slight fixes

This commit is contained in:
tomato6966 2024-12-24 12:39:59 +01:00
parent 4c641701eb
commit 0aa0425938
14 changed files with 285 additions and 174 deletions

View file

@ -1,7 +1,7 @@
{
"name": "investment-portfolio-tracker",
"private": true,
"version": "1.1.1",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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

View file

@ -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);

View file

@ -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>

View file

@ -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(),
},
};

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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: {

View file

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