mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-18 23:51:16 +02:00
Enhance Stock Explorer with advanced features and URL state management
This commit is contained in:
parent
751b89d90d
commit
1562d3a120
8 changed files with 1295 additions and 253 deletions
|
@ -41,7 +41,7 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/explore"
|
to="/explore"
|
||||||
className="flex items-center gap-1 md:gap-2 px-3 py-2 md:px-4 md:py-2 text-sm md:text-base text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
className="flex items-center gap-1 bg-red-500/50 md:gap-2 px-3 py-2 md:px-4 md:py-2 text-sm md:text-base text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
>
|
>
|
||||||
<BarChart2 className="w-4 h-4 md:w-5 md:h-5" />
|
<BarChart2 className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
Stock Explorer
|
Stock Explorer
|
||||||
|
@ -89,7 +89,7 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/explore"
|
to="/explore"
|
||||||
className="flex items-center justify-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded w-full border border-gray-200 dark:border-gray-700"
|
className="flex items-center justify-center bg-red-500/50 gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded w-full border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<BarChart2 className="w-5 h-5" />
|
<BarChart2 className="w-5 h-5" />
|
||||||
Stock Explorer
|
Stock Explorer
|
||||||
|
|
502
src/components/SavingsPlanSimulator.tsx
Normal file
502
src/components/SavingsPlanSimulator.tsx
Normal file
|
@ -0,0 +1,502 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { format, addMonths } from 'date-fns';
|
||||||
|
import { Asset } from '../types';
|
||||||
|
import {
|
||||||
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
interface SavingsPlanSimulatorProps {
|
||||||
|
stocks: Asset[];
|
||||||
|
stockColors: Record<string, string>;
|
||||||
|
initialParams?: {
|
||||||
|
monthlyAmount: number;
|
||||||
|
years: number;
|
||||||
|
allocations: Record<string, number>;
|
||||||
|
};
|
||||||
|
onParamsChange?: (params: {
|
||||||
|
monthlyAmount: number;
|
||||||
|
years: number;
|
||||||
|
allocations: Record<string, number>;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SavingsPlanSimulator = ({
|
||||||
|
stocks,
|
||||||
|
stockColors,
|
||||||
|
initialParams,
|
||||||
|
onParamsChange
|
||||||
|
}: SavingsPlanSimulatorProps) => {
|
||||||
|
// Add a ref to track initial load
|
||||||
|
const initialLoadComplete = useRef(false);
|
||||||
|
const prevParamsRef = useRef<{
|
||||||
|
monthlyAmount: number;
|
||||||
|
years: number;
|
||||||
|
allocations: string;
|
||||||
|
}>({
|
||||||
|
monthlyAmount: 0,
|
||||||
|
years: 0,
|
||||||
|
allocations: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize state with props if provided
|
||||||
|
const [totalAmount, setTotalAmount] = useState<number>(initialParams?.monthlyAmount || 1000);
|
||||||
|
const [years, setYears] = useState<number>(initialParams?.years || 5); // Default projection for 5 years
|
||||||
|
const [allocations, setAllocations] = useState<Record<string, number>>(
|
||||||
|
initialParams?.allocations ||
|
||||||
|
stocks.reduce((acc, stock) => {
|
||||||
|
acc[stock.id] = 100 / stocks.length; // Equal distribution by default
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call the onParamsChange callback when parameters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (onParamsChange) {
|
||||||
|
// Convert allocations to a comparable string
|
||||||
|
const allocationsString = JSON.stringify(allocations);
|
||||||
|
|
||||||
|
// Check if anything has actually changed
|
||||||
|
const prevParams = prevParamsRef.current;
|
||||||
|
const hasChanged =
|
||||||
|
totalAmount !== prevParams.monthlyAmount ||
|
||||||
|
years !== prevParams.years ||
|
||||||
|
allocationsString !== prevParams.allocations;
|
||||||
|
|
||||||
|
// Only call onParamsChange if values actually changed
|
||||||
|
if (hasChanged) {
|
||||||
|
// Update the ref with current values
|
||||||
|
prevParamsRef.current = {
|
||||||
|
monthlyAmount: totalAmount,
|
||||||
|
years,
|
||||||
|
allocations: allocationsString
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notify parent of changes
|
||||||
|
onParamsChange({
|
||||||
|
monthlyAmount: totalAmount,
|
||||||
|
years,
|
||||||
|
allocations
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [totalAmount, years, allocations, onParamsChange]);
|
||||||
|
|
||||||
|
// Run simulation automatically on initial load with URL params
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run on first render when we have initialParams
|
||||||
|
if (!initialLoadComplete.current && stocks.filter(stock => stock.historicalData && stock.historicalData.size >= 2).length > 0) {
|
||||||
|
initialLoadComplete.current = true;
|
||||||
|
|
||||||
|
// Small delay to ensure all stock data is loaded
|
||||||
|
setTimeout(() => document.getElementById('runSimulationButton')?.click(), 1000);
|
||||||
|
}
|
||||||
|
}, [stocks]);
|
||||||
|
|
||||||
|
const [simulationResults, setSimulationResults] = useState<any>(null);
|
||||||
|
const [simulationParams, setSimulationParams] = useState<{
|
||||||
|
monthlyAmount: number;
|
||||||
|
years: number;
|
||||||
|
allocations: Record<string, number>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Calculate the total allocation percentage
|
||||||
|
const totalAllocation = Object.values(allocations).reduce((sum, value) => sum + value, 0);
|
||||||
|
|
||||||
|
// Handle allocation change for a stock
|
||||||
|
const handleAllocationChange = (stockId: string, value: number) => {
|
||||||
|
const newValue = Math.max(0, Math.min(100, value)); // Clamp between 0 and 100
|
||||||
|
setAllocations(prev => ({
|
||||||
|
...prev,
|
||||||
|
[stockId]: newValue
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recalculate all allocations to sum to 100%
|
||||||
|
const normalizeAllocations = () => {
|
||||||
|
if (totalAllocation === 0) return;
|
||||||
|
|
||||||
|
const factor = 100 / totalAllocation;
|
||||||
|
const normalized = Object.entries(allocations).reduce((acc, [id, value]) => {
|
||||||
|
acc[id] = Math.round((value * factor) * 10) / 10; // Round to 1 decimal place
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
setAllocations(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the simulation
|
||||||
|
const runSimulation = () => {
|
||||||
|
// Normalize allocations to ensure they sum to 100%
|
||||||
|
const normalizedAllocations = { ...allocations };
|
||||||
|
if (totalAllocation !== 100) {
|
||||||
|
const factor = 100 / totalAllocation;
|
||||||
|
Object.keys(normalizedAllocations).forEach(id => {
|
||||||
|
normalizedAllocations[id] = normalizedAllocations[id] * factor;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the monetary amount for each stock
|
||||||
|
const stockAmounts = Object.entries(normalizedAllocations).reduce((acc, [id, percentage]) => {
|
||||||
|
acc[id] = (percentage / 100) * totalAmount;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
// Calculate performance metrics
|
||||||
|
const performanceMetrics = calculatePerformanceMetrics(stocks, stockAmounts, years);
|
||||||
|
|
||||||
|
// Save the parameters used for this simulation
|
||||||
|
setSimulationParams({
|
||||||
|
monthlyAmount: totalAmount,
|
||||||
|
years,
|
||||||
|
allocations: normalizedAllocations
|
||||||
|
});
|
||||||
|
|
||||||
|
setSimulationResults(performanceMetrics);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to calculate performance metrics
|
||||||
|
const calculatePerformanceMetrics = (stocks: Asset[], amounts: Record<string, number>, projectionYears: number) => {
|
||||||
|
// Calculate expected annual return based on historical performance
|
||||||
|
let totalWeight = 0;
|
||||||
|
let weightedReturn = 0;
|
||||||
|
|
||||||
|
const stockReturns: Record<string, number> = {};
|
||||||
|
|
||||||
|
stocks.forEach(stock => {
|
||||||
|
// Check if the stock ID exists in the amounts object
|
||||||
|
if (!amounts[stock.id]) return;
|
||||||
|
|
||||||
|
const weight = amounts[stock.id] / totalAmount;
|
||||||
|
if (weight > 0) {
|
||||||
|
totalWeight += weight;
|
||||||
|
|
||||||
|
if (stock.historicalData && stock.historicalData.size >= 2) {
|
||||||
|
// Calculate annualized return the same way as in StockExplorer.tsx
|
||||||
|
const historicalData = Array.from(stock.historicalData.entries());
|
||||||
|
|
||||||
|
// Sort by date
|
||||||
|
historicalData.sort((a, b) =>
|
||||||
|
new Date(a[0]).getTime() - new Date(b[0]).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstValue = historicalData[0][1];
|
||||||
|
const lastValue = historicalData[historicalData.length - 1][1];
|
||||||
|
|
||||||
|
// Calculate annualized return using a more precise year duration and standard CAGR
|
||||||
|
const firstDate = new Date(historicalData[0][0]);
|
||||||
|
const lastDate = new Date(historicalData[historicalData.length - 1][0]);
|
||||||
|
const yearsDiff = (lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
|
||||||
|
|
||||||
|
// Use CAGR formula: (Final Value / Initial Value)^(1/Years) - 1
|
||||||
|
const annualReturn = (Math.pow(lastValue / firstValue, 1 / yearsDiff) - 1);
|
||||||
|
|
||||||
|
stockReturns[stock.id] = annualReturn;
|
||||||
|
weightedReturn += annualReturn * weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert the decimal to percentage for display
|
||||||
|
const expectedAnnualReturn = weightedReturn * 100;
|
||||||
|
|
||||||
|
// Generate projection data for chart
|
||||||
|
const projectionData = [];
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
// Monthly compounding for regular investments
|
||||||
|
let totalPortfolioValue = totalAmount; // Initial investment
|
||||||
|
const stockValues: Record<string, number> = {};
|
||||||
|
|
||||||
|
// Initialize stock values with initial investment according to allocations
|
||||||
|
stocks.forEach(stock => {
|
||||||
|
if (amounts[stock.id]) {
|
||||||
|
const initialAmount = (amounts[stock.id] / totalAmount) * totalAmount;
|
||||||
|
stockValues[stock.id] = initialAmount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize variables for total investment tracking
|
||||||
|
let totalInvestment = totalAmount; // Initial investment
|
||||||
|
|
||||||
|
// First data point is the initial investment
|
||||||
|
projectionData.push({
|
||||||
|
date: format(today, 'MMM yyyy'),
|
||||||
|
month: 0,
|
||||||
|
portfolioValue: totalPortfolioValue,
|
||||||
|
totalInvestment,
|
||||||
|
...stockValues
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create monthly data points for the chart (starting from month 1)
|
||||||
|
for (let month = 1; month <= projectionYears * 12; month++) {
|
||||||
|
const date = addMonths(today, month);
|
||||||
|
|
||||||
|
// Apply compound returns for each stock based on its expected return
|
||||||
|
stocks.forEach(stock => {
|
||||||
|
if (stockValues[stock.id] > 0) {
|
||||||
|
const baseReturn = stockReturns[stock.id] || weightedReturn;
|
||||||
|
const baseMonthlyReturn = baseReturn / 12;
|
||||||
|
|
||||||
|
// Add some randomness to make the returns vary month to month
|
||||||
|
const monthFactor = month % 12; // To create some seasonal variation
|
||||||
|
const randomFactor = 0.5 + Math.random(); // Between 0.5 and 1.5
|
||||||
|
const seasonalFactor = 1 + (Math.sin(monthFactor / 12 * Math.PI * 2) * 0.2); // +/- 20% seasonal effect
|
||||||
|
|
||||||
|
const monthlyReturn = baseMonthlyReturn * randomFactor * seasonalFactor;
|
||||||
|
|
||||||
|
// Apply the monthly return to the current stock value (compound interest)
|
||||||
|
stockValues[stock.id] *= (1 + monthlyReturn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new monthly investment according to allocation percentages
|
||||||
|
Object.entries(amounts).forEach(([id, amount]) => {
|
||||||
|
if (stockValues[id] !== undefined) {
|
||||||
|
const investmentAmount = (amount / totalAmount) * totalAmount;
|
||||||
|
stockValues[id] += investmentAmount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total portfolio value after this month
|
||||||
|
totalPortfolioValue = Object.values(stockValues).reduce((sum, val) => sum + val, 0);
|
||||||
|
|
||||||
|
// Add the monthly contribution to the total investment amount
|
||||||
|
totalInvestment += totalAmount;
|
||||||
|
|
||||||
|
// Create data point for this month
|
||||||
|
const dataPoint: any = {
|
||||||
|
date: format(date, 'MMM yyyy'),
|
||||||
|
month,
|
||||||
|
portfolioValue: totalPortfolioValue,
|
||||||
|
totalInvestment,
|
||||||
|
...stockValues
|
||||||
|
};
|
||||||
|
|
||||||
|
projectionData.push(dataPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expectedAnnualReturn,
|
||||||
|
portfolioValue: totalPortfolioValue,
|
||||||
|
totalInvestment,
|
||||||
|
stockValues,
|
||||||
|
projectionData
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function: map a return to a color.
|
||||||
|
// Negative returns will be red, positive green, with yellows in between.
|
||||||
|
const getReturnColor = (ret: number) => {
|
||||||
|
const clamp = (num: number, min: number, max: number) => Math.max(min, Math.min(num, max));
|
||||||
|
// Normalize so that -10% maps to 0, 0% to 0.5, and +10% to 1. (Adjust these as needed)
|
||||||
|
const normalized = clamp((ret + 0.1) / 0.2, 0, 1);
|
||||||
|
const interpolateColor = (color1: string, color2: string, factor: number): string => {
|
||||||
|
const c1 = color1.slice(1).match(/.{2}/g)!.map(hex => parseInt(hex, 16));
|
||||||
|
const c2 = color2.slice(1).match(/.{2}/g)!.map(hex => parseInt(hex, 16));
|
||||||
|
const r = Math.round(c1[0] + factor * (c2[0] - c1[0]));
|
||||||
|
const g = Math.round(c1[1] + factor * (c2[1] - c1[1]));
|
||||||
|
const b = Math.round(c1[2] + factor * (c2[2] - c1[2]));
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interpolate from red (#ff0000) to yellow (#ffff00) then yellow to green (#00ff00)
|
||||||
|
if (normalized <= 0.5) {
|
||||||
|
const factor = normalized / 0.5;
|
||||||
|
return interpolateColor("#ff0000", "#ffff00", factor);
|
||||||
|
} else {
|
||||||
|
const factor = (normalized - 0.5) / 0.5;
|
||||||
|
return interpolateColor("#ffff00", "#00ff00", factor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a TIME_PERIODS constant based on StockExplorer's implementation
|
||||||
|
const TIME_PERIODS = {
|
||||||
|
"MTD": "Month to Date",
|
||||||
|
"1M": "1 Month",
|
||||||
|
"3M": "3 Months",
|
||||||
|
"6M": "6 Months",
|
||||||
|
"YTD": "Year to Date",
|
||||||
|
"1Y": "1 Year",
|
||||||
|
"3Y": "3 Years",
|
||||||
|
"5Y": "5 Years",
|
||||||
|
"10Y": "10 Years",
|
||||||
|
"MAX": "Max"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 mb-6 dark:border dark:border-slate-700">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 dark:text-gray-200">Savings Plan Simulator</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="grid grid-cols-2 gap-12">
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Monthly Investment Amount
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={totalAmount}
|
||||||
|
onChange={(e) => setTotalAmount(Math.max(0, Number(e.target.value)))}
|
||||||
|
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 dark:text-gray-400">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Projection Years
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={years}
|
||||||
|
onChange={(e) => setYears(Math.max(0, Number(e.target.value)))}
|
||||||
|
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-md font-semibold mb-2 dark:text-gray-300">Allocation Percentages</h3>
|
||||||
|
<div className="grid grid-cols-8 gap-4">
|
||||||
|
<div className="flex flex-wrap gap-2 overflow-y-auto pr-2 col-span-7">
|
||||||
|
{stocks.map(stock => (
|
||||||
|
<div key={stock.id} className="flex items-center border border-gray-200 dark:border-slate-700 rounded-md p-2 bg-gray-100 dark:bg-slate-700/50">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full mr-2"
|
||||||
|
style={{ backgroundColor: stockColors[stock.id] }}
|
||||||
|
></div>
|
||||||
|
<span className="text-sm dark:text-gray-300 mr-2 truncate flex-1">{stock.name}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={allocations[stock.id] || 0}
|
||||||
|
onChange={(e) => handleAllocationChange(stock.id, Number(e.target.value))}
|
||||||
|
className="border p-1 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-16 text-right"
|
||||||
|
/>
|
||||||
|
<span className="ml-1 dark:text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-4">
|
||||||
|
<div className={`text-sm ${totalAllocation === 100 ? 'text-green-500' : 'text-red-500'}`}>
|
||||||
|
Total Allocation: {totalAllocation.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={normalizeAllocations}
|
||||||
|
disabled={totalAllocation === 100}
|
||||||
|
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Normalize to 100%
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="runSimulationButton"
|
||||||
|
onClick={runSimulation}
|
||||||
|
disabled={stocks.length === 0}
|
||||||
|
className="w-full py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Run Simulation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{simulationResults && simulationParams && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* Modified Information Boxes - Now 5 boxes in total */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||||
|
<h4 className="font-semibold text-lg dark:text-white">
|
||||||
|
Avg. Yearly Return
|
||||||
|
</h4>
|
||||||
|
<p className="text-2xl font-bold dark:text-white" style={{
|
||||||
|
color: simulationResults.expectedAnnualReturn >= 0 ?
|
||||||
|
'var(--color-success, #10b981)' :
|
||||||
|
'var(--color-danger, #ef4444)'
|
||||||
|
}}>
|
||||||
|
{simulationResults.expectedAnnualReturn.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||||
|
<h4 className="font-semibold text-lg dark:text-white">
|
||||||
|
Monthly Investment
|
||||||
|
</h4>
|
||||||
|
<p className="text-2xl font-bold dark:text-white">
|
||||||
|
€{simulationParams.monthlyAmount.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||||
|
<h4 className="font-semibold text-lg dark:text-white">
|
||||||
|
Total Invested <span className="text-sm text-gray-500 dark:text-gray-400">({simulationParams.years} years)</span>
|
||||||
|
</h4>
|
||||||
|
<p className="text-2xl font-bold dark:text-white">
|
||||||
|
€{(simulationParams.monthlyAmount * simulationParams.years * 12).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||||
|
<h4 className="font-semibold text-lg dark:text-white">
|
||||||
|
Projected Portfolio Value
|
||||||
|
</h4>
|
||||||
|
<p className="text-2xl font-bold dark:text-white">
|
||||||
|
€{Math.round(simulationResults.portfolioValue).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||||
|
<h4 className="font-semibold text-lg dark:text-white">
|
||||||
|
Total Gain
|
||||||
|
</h4>
|
||||||
|
<p className="text-2xl font-bold dark:text-white">
|
||||||
|
€{Math.round(simulationResults.portfolioValue - (simulationParams.monthlyAmount * simulationParams.years * 12)).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="dark:text-white">
|
||||||
|
{(((simulationResults.portfolioValue / (simulationParams.monthlyAmount * simulationParams.years * 12)) - 1) * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-Width Chart */}
|
||||||
|
<div className="w-full h-[300px] mt-6">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={simulationResults.projectionData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(date) => format(new Date(date), 'MMM yyyy')}
|
||||||
|
tick={{ fill: '#4E4E4E' }}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fill: '#4E4E4E' }} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`€${Math.round(value).toLocaleString()}`, 'Value']}
|
||||||
|
labelFormatter={(label) => label}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="portfolioValue"
|
||||||
|
name="Portfolio Value"
|
||||||
|
stroke="#8884d8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="totalInvestment"
|
||||||
|
name="Total Invested"
|
||||||
|
stroke="#82ca9d"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
97
src/components/SortableTable.tsx
Normal file
97
src/components/SortableTable.tsx
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SortableTableProps {
|
||||||
|
data: any[];
|
||||||
|
columns: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
render?: (value: any, row: any) => React.ReactNode;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SortableTable = ({ data, columns }: SortableTableProps) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState<{
|
||||||
|
key: string;
|
||||||
|
direction: 'ascending' | 'descending';
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const sortedData = React.useMemo(() => {
|
||||||
|
// Create a copy of the data to avoid mutating the original
|
||||||
|
let sortableData = [...data];
|
||||||
|
if (sortConfig !== null) {
|
||||||
|
sortableData.sort((a, b) => {
|
||||||
|
// Handle null and undefined values
|
||||||
|
if (a[sortConfig.key] == null) return sortConfig.direction === 'ascending' ? -1 : 1;
|
||||||
|
if (b[sortConfig.key] == null) return sortConfig.direction === 'ascending' ? 1 : -1;
|
||||||
|
|
||||||
|
// Handle numeric values
|
||||||
|
if (typeof a[sortConfig.key] === 'number' && typeof b[sortConfig.key] === 'number') {
|
||||||
|
return sortConfig.direction === 'ascending'
|
||||||
|
? a[sortConfig.key] - b[sortConfig.key]
|
||||||
|
: b[sortConfig.key] - a[sortConfig.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string values
|
||||||
|
if (typeof a[sortConfig.key] === 'string' && typeof b[sortConfig.key] === 'string') {
|
||||||
|
return sortConfig.direction === 'ascending'
|
||||||
|
? a[sortConfig.key].localeCompare(b[sortConfig.key])
|
||||||
|
: b[sortConfig.key].localeCompare(a[sortConfig.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for other types
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sortableData;
|
||||||
|
}, [data, sortConfig]);
|
||||||
|
|
||||||
|
const requestSort = (key: string) => {
|
||||||
|
let direction: 'ascending' | 'descending' = 'ascending';
|
||||||
|
if (sortConfig?.key === key && sortConfig.direction === 'ascending') {
|
||||||
|
direction = 'descending';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 dark:bg-slate-700">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
className={`p-2 text-left dark:text-gray-200 ${column.sortable !== false ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-slate-600' : ''}`}
|
||||||
|
onClick={() => column.sortable !== false && requestSort(column.key)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{column.label}
|
||||||
|
{sortConfig?.key === column.key && (
|
||||||
|
<span className="ml-1">
|
||||||
|
{sortConfig.direction === 'ascending' ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedData.map((row, index) => (
|
||||||
|
<tr key={index} className="border-b dark:border-slate-600">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.key} className="p-2 dark:text-gray-200">
|
||||||
|
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
86
src/hooks/useLivePrice.ts
Normal file
86
src/hooks/useLivePrice.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { getHistoricalData } from '../services/yahooFinanceService';
|
||||||
|
|
||||||
|
interface LivePriceOptions {
|
||||||
|
symbol: string;
|
||||||
|
refreshInterval?: number; // in milliseconds, default 60000 (1 minute)
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLivePrice({ symbol, refreshInterval = 60000, enabled = true }: LivePriceOptions) {
|
||||||
|
const [livePrice, setLivePrice] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [lastPrice, setLastPrice] = useState<number | null>(null);
|
||||||
|
const [usedCurrency, setUsedCurrency] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const fetchLivePrice = async () => {
|
||||||
|
if (!symbol || !enabled) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculate time range for the last 10 minutes
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(endDate.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||||
|
|
||||||
|
const { historicalData, lastPrice, currency } = await getHistoricalData(
|
||||||
|
symbol,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
"1m" // 1-minute interval
|
||||||
|
);
|
||||||
|
|
||||||
|
if(!usedCurrency) {
|
||||||
|
setUsedCurrency(currency ?? null);
|
||||||
|
}
|
||||||
|
// Get the most recent price
|
||||||
|
if (historicalData.size > 0) {
|
||||||
|
const entries = Array.from(historicalData.entries());
|
||||||
|
const latestEntry = entries[entries.length - 1];
|
||||||
|
setLivePrice(latestEntry[1]);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
setLastPrice(null);
|
||||||
|
} else {
|
||||||
|
setLastPrice(lastPrice ?? null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching live price for ${symbol}:`, err);
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to fetch live price'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial fetch
|
||||||
|
if (enabled) {
|
||||||
|
fetchLivePrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up interval for periodic updates
|
||||||
|
if (enabled && refreshInterval > 0) {
|
||||||
|
timerRef.current = window.setInterval(fetchLivePrice, lastPrice ? refreshInterval : refreshInterval * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [symbol, refreshInterval, enabled]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
livePrice,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
lastUpdated,
|
||||||
|
lastPrice,
|
||||||
|
currency: usedCurrency,
|
||||||
|
refetch: fetchLivePrice
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,28 +1,36 @@
|
||||||
import { format, subYears } from "date-fns";
|
import { format, subYears, subMonths } from "date-fns";
|
||||||
import { ChevronDown, ChevronLeft, Filter, Plus, RefreshCw, Search, X } from "lucide-react";
|
import { ChevronDown, ChevronLeft, Circle, Filter, Heart, Plus, RefreshCw, Search, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import { useDarkMode } from "../hooks/useDarkMode";
|
import { useDarkMode } from "../hooks/useDarkMode";
|
||||||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
||||||
import { Asset } from "../types";
|
import { Asset } from "../types";
|
||||||
import { getHexColor } from "../utils/formatters";
|
import { formatCurrency, getHexColor } from "../utils/formatters";
|
||||||
import { intervalBasedOnDateRange } from "../utils/calculations/intervalBasedOnDateRange";
|
import { intervalBasedOnDateRange } from "../utils/calculations/intervalBasedOnDateRange";
|
||||||
// Time period options
|
import { useLivePrice } from '../hooks/useLivePrice';
|
||||||
const TIME_PERIODS = {
|
import { SortableTable } from '../components/SortableTable';
|
||||||
YTD: "Year to Date",
|
import { SavingsPlanSimulator } from '../components/SavingsPlanSimulator';
|
||||||
|
|
||||||
|
// Extended time period options
|
||||||
|
const TIME_PERIODS: { [key: string]: string } = {
|
||||||
|
"MTD": "Month To Date",
|
||||||
|
"1M": "1 Month",
|
||||||
|
"3M": "3 Months",
|
||||||
|
"6M": "6 Months",
|
||||||
|
"YTD": "Year To Date",
|
||||||
"1Y": "1 Year",
|
"1Y": "1 Year",
|
||||||
"3Y": "3 Years",
|
"3Y": "3 Years",
|
||||||
"5Y": "5 Years",
|
"5Y": "5 Years",
|
||||||
"10Y": "10 Years",
|
"10Y": "10 Years",
|
||||||
"15Y": "15 Years",
|
"15Y": "15 Years",
|
||||||
"20Y": "20 Years",
|
"20Y": "20 Years",
|
||||||
MAX: "Maximum",
|
"MAX": "Max",
|
||||||
CUSTOM: "Custom Range"
|
"CUSTOM": "Custom",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Equity type options
|
// Equity type options
|
||||||
|
@ -39,16 +47,18 @@ const EQUITY_TYPESMAP: Record<keyof typeof EQUITY_TYPES, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const StockExplorer = () => {
|
const StockExplorer = () => {
|
||||||
|
const initialFetch = useRef(false);
|
||||||
|
const parsedAllocationsRef = useRef<Record<string, number>>({});
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<Asset[]>([]);
|
const [searchResults, setSearchResults] = useState<Asset[]>([]);
|
||||||
const [selectedStocks, setSelectedStocks] = useState<Asset[]>([]);
|
const [selectedStocks, setSelectedStocks] = useState<(Asset & { currency?: string | null })[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [timePeriod, setTimePeriod] = useState<keyof typeof TIME_PERIODS>("1Y");
|
const [timePeriod, setTimePeriod] = useState<keyof typeof TIME_PERIODS>("YTD");
|
||||||
const [equityType, setEquityType] = useState<keyof typeof EQUITY_TYPESMAP>("all");
|
const [equityType, setEquityType] = useState<keyof typeof EQUITY_TYPESMAP>("all");
|
||||||
const [showEquityTypeDropdown, setShowEquityTypeDropdown] = useState(false);
|
const [showEquityTypeDropdown, setShowEquityTypeDropdown] = useState(false);
|
||||||
const [dateRange, setDateRange] = useState({
|
const [dateRange, setDateRange] = useState<{ startDate: Date; endDate: Date }>({
|
||||||
startDate: subYears(new Date(), 1),
|
startDate: new Date(new Date().getFullYear(), 0, 1),
|
||||||
endDate: new Date()
|
endDate: new Date()
|
||||||
});
|
});
|
||||||
const [customDateRange, setCustomDateRange] = useState({
|
const [customDateRange, setCustomDateRange] = useState({
|
||||||
|
@ -58,6 +68,233 @@ const StockExplorer = () => {
|
||||||
const [stockData, setStockData] = useState<any[]>([]);
|
const [stockData, setStockData] = useState<any[]>([]);
|
||||||
const [stockColors, setStockColors] = useState<Record<string, string>>({});
|
const [stockColors, setStockColors] = useState<Record<string, string>>({});
|
||||||
const { isDarkMode } = useDarkMode();
|
const { isDarkMode } = useDarkMode();
|
||||||
|
const [showSearchBar, setShowSearchBar] = useState(true);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const previousParamsRef = useRef<{
|
||||||
|
stocks: string[];
|
||||||
|
period: string;
|
||||||
|
monthlyAmount: number;
|
||||||
|
projectionYears: number;
|
||||||
|
allocations: string;
|
||||||
|
}>({
|
||||||
|
stocks: [],
|
||||||
|
period: '',
|
||||||
|
monthlyAmount: 0,
|
||||||
|
projectionYears: 0,
|
||||||
|
allocations: ''
|
||||||
|
});
|
||||||
|
const [savingsPlanParams, setSavingsPlanParams] = useState({
|
||||||
|
monthlyAmount: 1000,
|
||||||
|
years: 5,
|
||||||
|
allocations: {} as Record<string, number>
|
||||||
|
});
|
||||||
|
|
||||||
|
// On mount: Read URL query params and update states if found.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialFetch.current) return;
|
||||||
|
initialFetch.current = true;
|
||||||
|
|
||||||
|
const periodParam = searchParams.get("period");
|
||||||
|
if (periodParam && Object.keys(TIME_PERIODS).includes(periodParam)) {
|
||||||
|
setTimePeriod(periodParam as keyof typeof TIME_PERIODS);
|
||||||
|
updateTimePeriod(periodParam as keyof typeof TIME_PERIODS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get savings plan params first so they're ready when stocks load
|
||||||
|
const monthlyAmountParam = searchParams.get("monthlyAmount");
|
||||||
|
const yearsParam = searchParams.get("projectionYears");
|
||||||
|
const allocationsParam = searchParams.get("allocations");
|
||||||
|
|
||||||
|
let newSavingsPlanParams = {...savingsPlanParams};
|
||||||
|
|
||||||
|
if (monthlyAmountParam) {
|
||||||
|
newSavingsPlanParams.monthlyAmount = Number(monthlyAmountParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yearsParam) {
|
||||||
|
newSavingsPlanParams.years = Number(yearsParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse allocations but don't apply them yet - we'll do that after stocks load
|
||||||
|
let parsedAllocations: Record<string, number> = {};
|
||||||
|
if (allocationsParam) {
|
||||||
|
try {
|
||||||
|
const allocationPairs = allocationsParam.split(',');
|
||||||
|
allocationPairs.forEach(pair => {
|
||||||
|
const [id, percentage] = pair.split(':');
|
||||||
|
if (id && percentage) {
|
||||||
|
parsedAllocations[id] = Number(percentage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse allocations param:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the ref value instead of creating a new ref
|
||||||
|
parsedAllocationsRef.current = parsedAllocations;
|
||||||
|
|
||||||
|
// Handle loading stocks from URL
|
||||||
|
const stocksParam = searchParams.get("stocks");
|
||||||
|
if (stocksParam) {
|
||||||
|
const symbols = stocksParam.split(",");
|
||||||
|
if (symbols.length > 0) {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Load all stocks first, then apply allocations
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Create an array to hold all loaded stocks
|
||||||
|
const loadedStocks: Asset[] = [];
|
||||||
|
|
||||||
|
// Process in batches to avoid rate limiting
|
||||||
|
const batchSize = 3;
|
||||||
|
const batches = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < symbols.length; i += batchSize) {
|
||||||
|
batches.push(symbols.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
// Process each batch sequentially
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batch.map(async (symbol) => {
|
||||||
|
const results = await searchAssets(symbol, EQUITY_TYPES[equityType]);
|
||||||
|
if (results.length > 0) {
|
||||||
|
// Get historical data for the stock
|
||||||
|
const stock = results[0];
|
||||||
|
const histData = await getHistoricalData(
|
||||||
|
stock.symbol,
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the complete stock object
|
||||||
|
return {
|
||||||
|
...stock,
|
||||||
|
...histData
|
||||||
|
} as unknown as Asset;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add valid results to loadedStocks
|
||||||
|
batchResults.filter(Boolean).forEach(stock => {
|
||||||
|
if (stock) loadedStocks.push(stock);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that all stocks are loaded, set them all at once
|
||||||
|
if (loadedStocks.length > 0) {
|
||||||
|
setSelectedStocks(loadedStocks);
|
||||||
|
|
||||||
|
// Assign colors to the stocks
|
||||||
|
const colors: Record<string, string> = {};
|
||||||
|
loadedStocks.forEach((stock) => {
|
||||||
|
colors[stock.id] = getHexColor(new Set(Object.values(colors)), isDarkMode);
|
||||||
|
});
|
||||||
|
setStockColors(colors);
|
||||||
|
|
||||||
|
// Now apply the allocations
|
||||||
|
if (Object.keys(parsedAllocationsRef.current).length > 0) {
|
||||||
|
// Check if we have allocations for all stocks, otherwise use equal distribution
|
||||||
|
const hasAllAllocations = loadedStocks.every(
|
||||||
|
stock => parsedAllocationsRef.current[stock.id] !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAllAllocations) {
|
||||||
|
newSavingsPlanParams.allocations = parsedAllocationsRef.current;
|
||||||
|
} else {
|
||||||
|
// Fallback to equal distribution
|
||||||
|
loadedStocks.forEach(stock => {
|
||||||
|
newSavingsPlanParams.allocations[stock.id] = 100 / loadedStocks.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No allocations in URL, use equal distribution
|
||||||
|
loadedStocks.forEach(stock => {
|
||||||
|
newSavingsPlanParams.allocations[stock.id] = 100 / loadedStocks.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the final savings plan params
|
||||||
|
setSavingsPlanParams(newSavingsPlanParams);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading stocks from URL:", error);
|
||||||
|
toast.error("Failed to load some stocks from URL");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
// No stocks to load, just set the savings plan params
|
||||||
|
setSavingsPlanParams(newSavingsPlanParams);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No stocks to load, just set the savings plan params
|
||||||
|
setSavingsPlanParams(newSavingsPlanParams);
|
||||||
|
}
|
||||||
|
}, []); // Empty dependency array for mount only
|
||||||
|
|
||||||
|
// Update URL query params when selectedStocks or timePeriod changes.
|
||||||
|
useEffect(() => {
|
||||||
|
// Create the new params object
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
const stockSymbols = selectedStocks.map(stock => stock.symbol);
|
||||||
|
const stocksParam = stockSymbols.join(",");
|
||||||
|
|
||||||
|
// Only add parameters if they have values
|
||||||
|
if (selectedStocks.length > 0) {
|
||||||
|
params.stocks = stocksParam;
|
||||||
|
}
|
||||||
|
if (timePeriod) {
|
||||||
|
params.period = timePeriod.toString();
|
||||||
|
}
|
||||||
|
if (savingsPlanParams.monthlyAmount > 0) {
|
||||||
|
params.monthlyAmount = savingsPlanParams.monthlyAmount.toString();
|
||||||
|
}
|
||||||
|
if (savingsPlanParams.years > 0) {
|
||||||
|
params.projectionYears = savingsPlanParams.years.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process allocations
|
||||||
|
const allocationEntries = Object.entries(savingsPlanParams.allocations)
|
||||||
|
.filter(([id, percentage]) => {
|
||||||
|
return selectedStocks.some(stock => stock.id === id) && percentage > 0;
|
||||||
|
})
|
||||||
|
.map(([id, percentage]) => `${id}:${Math.round(percentage * 10) / 10}`);
|
||||||
|
|
||||||
|
const allocationsParam = allocationEntries.join(',');
|
||||||
|
if (allocationEntries.length > 0) {
|
||||||
|
params.allocations = allocationsParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if anything actually changed before updating URL
|
||||||
|
const prevParams = previousParamsRef.current;
|
||||||
|
const hasChanged =
|
||||||
|
stocksParam !== prevParams.stocks.join(",") ||
|
||||||
|
timePeriod.toString() !== prevParams.period ||
|
||||||
|
savingsPlanParams.monthlyAmount !== prevParams.monthlyAmount ||
|
||||||
|
savingsPlanParams.years !== prevParams.projectionYears ||
|
||||||
|
allocationsParam !== prevParams.allocations;
|
||||||
|
|
||||||
|
// Only update URL if something changed
|
||||||
|
if (hasChanged) {
|
||||||
|
// Update the ref with new values
|
||||||
|
previousParamsRef.current = {
|
||||||
|
stocks: stockSymbols,
|
||||||
|
period: timePeriod.toString(),
|
||||||
|
monthlyAmount: savingsPlanParams.monthlyAmount,
|
||||||
|
projectionYears: savingsPlanParams.years,
|
||||||
|
allocations: allocationsParam
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the URL params
|
||||||
|
setSearchParams(params);
|
||||||
|
}
|
||||||
|
}, [selectedStocks, timePeriod, savingsPlanParams, setSearchParams]);
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
const handleSearch = useCallback(async () => {
|
const handleSearch = useCallback(async () => {
|
||||||
|
@ -72,12 +309,8 @@ const StockExplorer = () => {
|
||||||
// Convert the equity type to a comma-separated string for the API
|
// Convert the equity type to a comma-separated string for the API
|
||||||
const typeParam = EQUITY_TYPES[equityType];
|
const typeParam = EQUITY_TYPES[equityType];
|
||||||
|
|
||||||
console.log(`Searching for "${searchQuery}" with type "${typeParam}"`);
|
|
||||||
|
|
||||||
const results = await searchAssets(searchQuery, typeParam);
|
const results = await searchAssets(searchQuery, typeParam);
|
||||||
|
|
||||||
console.log("Search results:", results);
|
|
||||||
|
|
||||||
// Filter out stocks already in the selected list
|
// Filter out stocks already in the selected list
|
||||||
const filteredResults = results.filter(
|
const filteredResults = results.filter(
|
||||||
result => !selectedStocks.some(stock => stock.symbol === result.symbol)
|
result => !selectedStocks.some(stock => stock.symbol === result.symbol)
|
||||||
|
@ -124,7 +357,7 @@ const StockExplorer = () => {
|
||||||
? new Date(dateRange.startDate.getTime() + (365 * 24 * 60 * 60 * 1000)) // Skip first year for very long ranges
|
? new Date(dateRange.startDate.getTime() + (365 * 24 * 60 * 60 * 1000)) // Skip first year for very long ranges
|
||||||
: dateRange.startDate;
|
: dateRange.startDate;
|
||||||
|
|
||||||
const { historicalData, longName } = await getHistoricalData(
|
const { historicalData, longName, currency } = await getHistoricalData(
|
||||||
stock.symbol,
|
stock.symbol,
|
||||||
optimizedStartDate,
|
optimizedStartDate,
|
||||||
dateRange.endDate,
|
dateRange.endDate,
|
||||||
|
@ -138,6 +371,7 @@ const StockExplorer = () => {
|
||||||
|
|
||||||
const stockWithHistory = {
|
const stockWithHistory = {
|
||||||
...stock,
|
...stock,
|
||||||
|
currency: currency,
|
||||||
name: longName || stock.name,
|
name: longName || stock.name,
|
||||||
historicalData,
|
historicalData,
|
||||||
investments: [] // Empty as we're just exploring
|
investments: [] // Empty as we're just exploring
|
||||||
|
@ -154,7 +388,8 @@ const StockExplorer = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't clear results, so users can add multiple stocks
|
// Don't clear results, so users can add multiple stocks
|
||||||
setSearchResults(prev => prev.filter(result => result.symbol !== stock.symbol));
|
//setSearchResults(prev => prev.filter(result => result.symbol !== stock.symbol));
|
||||||
|
setSearchResults([]);
|
||||||
|
|
||||||
toast.success(`Added ${stockWithHistory.name} to comparison`);
|
toast.success(`Added ${stockWithHistory.name} to comparison`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -173,13 +408,23 @@ const StockExplorer = () => {
|
||||||
// Update time period and date range
|
// Update time period and date range
|
||||||
const updateTimePeriod = useCallback((period: keyof typeof TIME_PERIODS) => {
|
const updateTimePeriod = useCallback((period: keyof typeof TIME_PERIODS) => {
|
||||||
setTimePeriod(period);
|
setTimePeriod(period);
|
||||||
|
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
let startDate;
|
let startDate: Date;
|
||||||
|
|
||||||
switch (period) {
|
switch (period) {
|
||||||
|
case "MTD":
|
||||||
|
startDate = new Date(endDate.getFullYear(), endDate.getMonth(), 1);
|
||||||
|
break;
|
||||||
|
case "1M":
|
||||||
|
startDate = subMonths(endDate, 1);
|
||||||
|
break;
|
||||||
|
case "3M":
|
||||||
|
startDate = subMonths(endDate, 3);
|
||||||
|
break;
|
||||||
|
case "6M":
|
||||||
|
startDate = subMonths(endDate, 6);
|
||||||
|
break;
|
||||||
case "YTD":
|
case "YTD":
|
||||||
startDate = new Date(endDate.getFullYear(), 0, 1); // Jan 1 of current year
|
startDate = new Date(endDate.getFullYear(), 0, 1);
|
||||||
break;
|
break;
|
||||||
case "1Y":
|
case "1Y":
|
||||||
startDate = subYears(endDate, 1);
|
startDate = subYears(endDate, 1);
|
||||||
|
@ -209,7 +454,6 @@ const StockExplorer = () => {
|
||||||
default:
|
default:
|
||||||
startDate = subYears(endDate, 1);
|
startDate = subYears(endDate, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (period !== "CUSTOM") {
|
if (period !== "CUSTOM") {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
} else {
|
} else {
|
||||||
|
@ -217,6 +461,49 @@ const StockExplorer = () => {
|
||||||
}
|
}
|
||||||
}, [customDateRange]);
|
}, [customDateRange]);
|
||||||
|
|
||||||
|
// Helper: Interpolate missing daily data so chart lines look connected
|
||||||
|
// Assumes each data point is an object with a "date" property and one or more stock keys (e.g. "stockId_percent").
|
||||||
|
const interpolateStockSeries = (data: any[]): any[] => {
|
||||||
|
if (data.length === 0) return data;
|
||||||
|
const interpolated = [...data];
|
||||||
|
// Get all keys other than "date"
|
||||||
|
const keys = Object.keys(data[0]).filter(key => key !== "date");
|
||||||
|
keys.forEach(key => {
|
||||||
|
// Loop over data points and fill missing values
|
||||||
|
for (let i = 0; i < interpolated.length; i++) {
|
||||||
|
if (interpolated[i][key] === undefined || interpolated[i][key] === null) {
|
||||||
|
// Find the previous non-null value
|
||||||
|
let prevIndex = i - 1;
|
||||||
|
while (prevIndex >= 0 && (interpolated[prevIndex][key] === undefined || interpolated[prevIndex][key] === null)) {
|
||||||
|
prevIndex--;
|
||||||
|
}
|
||||||
|
// Find the next non-null value
|
||||||
|
let nextIndex = i + 1;
|
||||||
|
while (nextIndex < interpolated.length && (interpolated[nextIndex][key] === undefined || interpolated[nextIndex][key] === null)) {
|
||||||
|
nextIndex++;
|
||||||
|
}
|
||||||
|
if (prevIndex >= 0 && nextIndex < interpolated.length) {
|
||||||
|
const prevVal = interpolated[prevIndex][key];
|
||||||
|
const nextVal = interpolated[nextIndex][key];
|
||||||
|
const fraction = (i - prevIndex) / (nextIndex - prevIndex);
|
||||||
|
interpolated[i][key] = prevVal + (nextVal - prevVal) * fraction;
|
||||||
|
} else if (prevIndex >= 0) {
|
||||||
|
interpolated[i][key] = interpolated[prevIndex][key];
|
||||||
|
} else if (nextIndex < interpolated.length) {
|
||||||
|
interpolated[i][key] = interpolated[nextIndex][key];
|
||||||
|
} else {
|
||||||
|
interpolated[i][key] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return interpolated;
|
||||||
|
};
|
||||||
|
|
||||||
|
// When stockData is ready, create an interpolated version for the chart.
|
||||||
|
// (Assume stockData is computed elsewhere in this component.)
|
||||||
|
const interpolatedStockData = interpolateStockSeries(stockData);
|
||||||
|
|
||||||
// Process the stock data for display
|
// Process the stock data for display
|
||||||
const processStockData = useCallback((stocks: Asset[]) => {
|
const processStockData = useCallback((stocks: Asset[]) => {
|
||||||
// Create a combined dataset with data points for all dates
|
// Create a combined dataset with data points for all dates
|
||||||
|
@ -278,7 +565,7 @@ const StockExplorer = () => {
|
||||||
const updatedStocks = [...selectedStocks];
|
const updatedStocks = [...selectedStocks];
|
||||||
|
|
||||||
for (const batch of batches) {
|
for (const batch of batches) {
|
||||||
await Promise.all(batch.map(async (stock, index) => {
|
await Promise.all(batch.map(async (stock) => {
|
||||||
try {
|
try {
|
||||||
const { historicalData, longName } = await getHistoricalData(
|
const { historicalData, longName } = await getHistoricalData(
|
||||||
stock.symbol,
|
stock.symbol,
|
||||||
|
@ -338,8 +625,8 @@ const StockExplorer = () => {
|
||||||
const annualizedReturn = (Math.pow(lastValue / firstValue, 1 / yearsDiff) - 1) * 100;
|
const annualizedReturn = (Math.pow(lastValue / firstValue, 1 / yearsDiff) - 1) * 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: `${totalPercentChange.toFixed(2)}%`,
|
total: `${totalPercentChange.toFixed(2)} %`,
|
||||||
annualized: `${annualizedReturn.toFixed(2)}%/year`,
|
annualized: `${annualizedReturn.toFixed(2)} %/y`,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -359,14 +646,6 @@ const StockExplorer = () => {
|
||||||
}
|
}
|
||||||
}, [timePeriod]);
|
}, [timePeriod]);
|
||||||
|
|
||||||
// Add debugging for chart display
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedStocks.length > 0) {
|
|
||||||
console.log("Selected stocks:", selectedStocks);
|
|
||||||
console.log("Stock data for chart:", stockData);
|
|
||||||
}
|
|
||||||
}, [selectedStocks, stockData]);
|
|
||||||
|
|
||||||
// Ensure processStockData is called immediately when selectedStocks changes
|
// Ensure processStockData is called immediately when selectedStocks changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedStocks.length > 0) {
|
if (selectedStocks.length > 0) {
|
||||||
|
@ -375,6 +654,94 @@ const StockExplorer = () => {
|
||||||
}
|
}
|
||||||
}, [selectedStocks, processStockData]);
|
}, [selectedStocks, processStockData]);
|
||||||
|
|
||||||
|
const LivePriceCell = ({ symbol }: { symbol: string }) => {
|
||||||
|
const { livePrice, isLoading, lastUpdated, lastPrice, currency } = useLivePrice({
|
||||||
|
symbol,
|
||||||
|
refreshInterval: 60000, // 1 minute
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="inline-block w-4 h-4 border-2 border-t-transparent border-blue-500 rounded-full animate-spin"></span>
|
||||||
|
) : livePrice ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex p-2 text-right justify-end items-center gap-1">
|
||||||
|
<Circle size={16} className="text-green-500" />
|
||||||
|
<div>{formatCurrency(livePrice, currency)}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{lastUpdated ? `Updated: ${format(lastUpdated, 'HH:mm:ss')}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex p-2 text-right justify-end items-center gap-1">
|
||||||
|
<Circle size={16} className="text-red-500" /> Market Closed, last Price: {formatCurrency(lastPrice, currency)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define column configurations for the sortable table
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Stock',
|
||||||
|
render: (value: string, row: any) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: stockColors[row.id] }}
|
||||||
|
></div>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total',
|
||||||
|
label: 'Total Return',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'annualized',
|
||||||
|
label: 'Annualized Return',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'currentPrice',
|
||||||
|
label: 'Current Price (last day)',
|
||||||
|
sortable: true,
|
||||||
|
render: (value: number, row: any) => formatCurrency(value, row.currency)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'symbol',
|
||||||
|
label: 'Live Price',
|
||||||
|
sortable: false,
|
||||||
|
render: (value: string) => <LivePriceCell symbol={value} />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Prepare data for the table
|
||||||
|
const tableData = selectedStocks.map(stock => {
|
||||||
|
const metrics = calculatePerformanceMetrics(stock);
|
||||||
|
const historicalData = Array.from(stock.historicalData.entries());
|
||||||
|
const currentPrice = historicalData.length > 0
|
||||||
|
? historicalData[historicalData.length - 1][1]
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: stock.id,
|
||||||
|
name: stock.name,
|
||||||
|
symbol: stock.symbol,
|
||||||
|
total: metrics.total,
|
||||||
|
annualized: metrics.annualized,
|
||||||
|
currentPrice,
|
||||||
|
currency: stock.currency
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark:bg-slate-900 min-h-screen w-full">
|
<div className="dark:bg-slate-900 min-h-screen w-full">
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
|
@ -389,139 +756,152 @@ const StockExplorer = () => {
|
||||||
<h1 className="text-2xl font-bold dark:text-white">Stock Explorer</h1>
|
<h1 className="text-2xl font-bold dark:text-white">Stock Explorer</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle button to show/hide the search bar */}
|
||||||
|
|
||||||
|
{/* Conditionally render the search bar */}
|
||||||
{/* Search and add stocks */}
|
{/* Search and add stocks */}
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 mb-6 dark:border dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 mb-6 dark:border dark:border-slate-700">
|
||||||
<h2 className="text-lg font-semibold mb-4 dark:text-gray-200">Add Assets to Compare</h2>
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 dark:text-gray-200">Search & Add Assets to Compare</h2>
|
||||||
<div className="flex flex-col md:flex-row gap-2 mb-4">
|
<button onClick={() => setShowSearchBar(!showSearchBar)} className="text-blue-500 hover:underline">
|
||||||
<div className="flex-grow relative">
|
{showSearchBar ? "Collapse Search" : "Expand Search"}
|
||||||
<input
|
</button>
|
||||||
type="text"
|
</div>
|
||||||
value={searchQuery}
|
{showSearchBar && (
|
||||||
disabled={searchLoading}
|
<>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<div className="flex flex-col md:flex-row gap-2 mb-4">
|
||||||
onKeyDown={handleKeyDown}
|
<div className="flex-grow relative">
|
||||||
placeholder="Search for stocks, ETFs, indices..."
|
<input
|
||||||
className="w-full p-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
type="text"
|
||||||
/>
|
value={searchQuery}
|
||||||
{searchLoading && (
|
disabled={searchLoading}
|
||||||
<div className="absolute right-3 top-2.5">
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<div className="animate-spin h-5 w-5 border-2 border-blue-500 rounded-full border-t-transparent"></div>
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search for stocks, ETFs, indices..."
|
||||||
|
className="w-full p-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
||||||
|
/>
|
||||||
|
{searchLoading && (
|
||||||
|
<div className="absolute right-3 top-2.5">
|
||||||
|
<div className="animate-spin h-5 w-5 border-2 border-blue-500 rounded-full border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Equity Type Dropdown */}
|
{/* Equity Type Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEquityTypeDropdown(!showEquityTypeDropdown)}
|
onClick={() => setShowEquityTypeDropdown(!showEquityTypeDropdown)}
|
||||||
className="flex items-center gap-2 border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 min-w-[140px]"
|
className="flex items-center gap-2 border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 min-w-[140px]"
|
||||||
>
|
>
|
||||||
<Filter size={16} />
|
<Filter size={16} />
|
||||||
{EQUITY_TYPESMAP[equityType]}
|
{EQUITY_TYPESMAP[equityType]}
|
||||||
<ChevronDown size={16} className="ml-auto" />
|
<ChevronDown size={16} className="ml-auto" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showEquityTypeDropdown && (
|
{showEquityTypeDropdown && (
|
||||||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-slate-700 border dark:border-slate-600 rounded shadow-lg z-10 w-full">
|
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-slate-700 border dark:border-slate-600 rounded shadow-lg z-10 w-full">
|
||||||
{Object.entries(EQUITY_TYPESMAP).map(([key, label]) => (
|
{Object.entries(EQUITY_TYPESMAP).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEquityType(key as keyof typeof EQUITY_TYPESMAP);
|
setEquityType(key as keyof typeof EQUITY_TYPESMAP);
|
||||||
setShowEquityTypeDropdown(false);
|
setShowEquityTypeDropdown(false);
|
||||||
}}
|
}}
|
||||||
className={`block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-600 dark:text-white ${equityType === key ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
className={`block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-600 dark:text-white ${equityType === key ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={searchLoading}
|
||||||
|
>
|
||||||
|
<Search size={16} />
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="border rounded mb-4 max-h-[500px] overflow-y-auto dark:border-slate-600">
|
||||||
|
<div className="sticky top-0 bg-gray-100 dark:bg-slate-700 p-2 border-b dark:border-slate-600 flex items-center gap-2">
|
||||||
|
<button onClick={() => setSearchResults([])}>
|
||||||
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-300">
|
||||||
|
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found for "{searchQuery}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{searchResults.map(result => (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className="p-3 border-b flex justify-between items-center hover:bg-gray-50 dark:hover:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{result.name}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{result.symbol} | {result.quoteType?.toUpperCase() || "Unknown"}
|
||||||
|
{result.isin && ` | ${result.isin}`}
|
||||||
|
{result.price && ` | ${result.price}`}
|
||||||
|
{result.priceChangePercent && ` | ${result.priceChangePercent}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => addStock(result)}
|
||||||
|
className="bg-green-500 text-white p-1 rounded hover:bg-green-600"
|
||||||
|
title="Add to comparison"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
{/* Selected stocks */}
|
||||||
onClick={handleSearch}
|
<div>
|
||||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
<h3 className="font-medium mb-2 dark:text-gray-300">Selected Stocks</h3>
|
||||||
disabled={searchLoading}
|
{selectedStocks.length === 0 ? (
|
||||||
>
|
<p className="text-gray-500 dark:text-gray-400 italic">No stocks selected for comparison</p>
|
||||||
<Search size={16} />
|
) : (
|
||||||
Search
|
<div className="flex flex-wrap gap-2">
|
||||||
</button>
|
{selectedStocks.map(stock => {
|
||||||
</div>
|
const metrics = calculatePerformanceMetrics(stock);
|
||||||
|
return (
|
||||||
{/* Search results */}
|
<div
|
||||||
{searchResults.length > 0 && (
|
key={stock.id}
|
||||||
<div className="border rounded mb-4 max-h-[500px] overflow-y-auto dark:border-slate-600">
|
className="bg-gray-100 dark:bg-slate-700 rounded p-2 flex items-center gap-2"
|
||||||
<div className="sticky top-0 bg-gray-100 dark:bg-slate-700 p-2 border-b dark:border-slate-600">
|
>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-300">
|
<div
|
||||||
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found for "{searchQuery}"
|
className="w-3 h-3 rounded-full"
|
||||||
</span>
|
style={{ backgroundColor: stockColors[stock.id] }}
|
||||||
</div>
|
></div>
|
||||||
{searchResults.map(result => (
|
<span className="dark:text-white">{stock.name}</span>
|
||||||
<div
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
key={result.id}
|
({metrics.total})
|
||||||
className="p-3 border-b flex justify-between items-center hover:bg-gray-50 dark:hover:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
</span>
|
||||||
>
|
<button
|
||||||
<div>
|
onClick={() => removeStock(stock.id)}
|
||||||
<div className="font-medium">{result.name}</div>
|
className="text-red-500 hover:text-red-700"
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
title="Remove"
|
||||||
{result.symbol} | {result.quoteType?.toUpperCase() || "Unknown"}
|
>
|
||||||
{result.isin && ` | ${result.isin}`}
|
<X size={16} />
|
||||||
{result.price && ` | ${result.price}`}
|
</button>
|
||||||
{result.priceChangePercent && ` | ${result.priceChangePercent}`}
|
</div>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
)}
|
||||||
<button
|
|
||||||
onClick={() => addStock(result)}
|
|
||||||
className="bg-green-500 text-white p-1 rounded hover:bg-green-600"
|
|
||||||
title="Add to comparison"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Selected stocks */}
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-2 dark:text-gray-300">Selected Stocks</h3>
|
|
||||||
{selectedStocks.length === 0 ? (
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 italic">No stocks selected for comparison</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedStocks.map(stock => {
|
|
||||||
const metrics = calculatePerformanceMetrics(stock);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stock.id}
|
|
||||||
className="bg-gray-100 dark:bg-slate-700 rounded p-2 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: stockColors[stock.id] }}
|
|
||||||
></div>
|
|
||||||
<span className="dark:text-white">{stock.name}</span>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
({metrics.total})
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => removeStock(stock.id)}
|
|
||||||
className="text-red-500 hover:text-red-700"
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time period selector */}
|
{/* Time period selector */}
|
||||||
|
@ -542,14 +922,14 @@ const StockExplorer = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
{Object.entries(TIME_PERIODS).map(([key, label]) => (
|
{Object.entries(TIME_PERIODS).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => updateTimePeriod(key as keyof typeof TIME_PERIODS)}
|
onClick={() => updateTimePeriod(key as keyof typeof TIME_PERIODS)}
|
||||||
className={`px-3 py-1 rounded ${timePeriod === key
|
className={`px-3 py-1 rounded ${timePeriod === key
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-gray-200'
|
: 'bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
@ -557,30 +937,32 @@ const StockExplorer = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom date range selector (only visible when CUSTOM is selected) */}
|
{/* Custom date range selector (if CUSTOM is selected) */}
|
||||||
{timePeriod === "CUSTOM" && (
|
{timePeriod === "CUSTOM" && (
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Start Date</label>
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={format(customDateRange.startDate, 'yyyy-MM-dd')}
|
value={format(customDateRange.startDate, 'yyyy-MM-dd')}
|
||||||
onChange={(e) => handleCustomDateChange(
|
onChange={(e) =>
|
||||||
new Date(e.target.value),
|
handleCustomDateChange(new Date(e.target.value), customDateRange.endDate)
|
||||||
customDateRange.endDate
|
}
|
||||||
)}
|
|
||||||
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">End Date</label>
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={format(customDateRange.endDate, 'yyyy-MM-dd')}
|
value={format(customDateRange.endDate, 'yyyy-MM-dd')}
|
||||||
onChange={(e) => handleCustomDateChange(
|
onChange={(e) =>
|
||||||
customDateRange.startDate,
|
handleCustomDateChange(customDateRange.startDate, new Date(e.target.value))
|
||||||
new Date(e.target.value)
|
}
|
||||||
)}
|
|
||||||
max={format(new Date(), 'yyyy-MM-dd')}
|
max={format(new Date(), 'yyyy-MM-dd')}
|
||||||
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
||||||
/>
|
/>
|
||||||
|
@ -588,8 +970,10 @@ const StockExplorer = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400 italic mt-2">
|
||||||
Showing data from {format(dateRange.startDate, 'MMM d, yyyy')} to {format(dateRange.endDate, 'MMM d, yyyy')}
|
<span className="text-red-400/50 pr-2">Data-Interval: {intervalBasedOnDateRange({ startDate: dateRange.startDate, endDate: dateRange.endDate })}</span>
|
||||||
|
Showing data from {format(dateRange.startDate, 'MMM d, yyyy')} to{' '}
|
||||||
|
{format(dateRange.endDate, 'MMM d, yyyy')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -597,12 +981,14 @@ const StockExplorer = () => {
|
||||||
{selectedStocks.length > 0 && stockData.length > 0 && (
|
{selectedStocks.length > 0 && stockData.length > 0 && (
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 mb-6 dark:border dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 mb-6 dark:border dark:border-slate-700">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-lg font-semibold dark:text-gray-200">Performance Comparison</h2>
|
<h2 className="text-lg font-semibold dark:text-gray-200">
|
||||||
|
Performance Comparison
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[500px] mb-6">
|
<div className="h-[500px] mb-6">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={stockData}>
|
<LineChart data={interpolatedStockData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
|
@ -623,18 +1009,15 @@ const StockExplorer = () => {
|
||||||
formatter={(value: number, name: string, props: any) => {
|
formatter={(value: number, name: string, props: any) => {
|
||||||
const stockId = name.replace('_percent', '');
|
const stockId = name.replace('_percent', '');
|
||||||
const price = props.payload[stockId] || 0;
|
const price = props.payload[stockId] || 0;
|
||||||
const stockName = selectedStocks.find(s => s.id === stockId)?.name || name;
|
const stockName =
|
||||||
return [
|
selectedStocks.find((s) => s.id === stockId)?.name || name;
|
||||||
`${value.toFixed(2)}% (€${price.toFixed(2)})`,
|
return [`${value.toFixed(2)}% (€${price.toFixed(2)})`, stockName];
|
||||||
stockName
|
|
||||||
];
|
|
||||||
}}
|
}}
|
||||||
labelFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
labelFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
|
{/* Render one line per selected stock (using interpolated data) */}
|
||||||
{/* Only percentage lines */}
|
{selectedStocks.map((stock) => (
|
||||||
{selectedStocks.map(stock => (
|
|
||||||
<Line
|
<Line
|
||||||
key={`${stock.id}_percent`}
|
key={`${stock.id}_percent`}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
@ -650,51 +1033,25 @@ const StockExplorer = () => {
|
||||||
|
|
||||||
{/* Performance metrics table */}
|
{/* Performance metrics table */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse">
|
<SortableTable data={tableData} columns={tableColumns} />
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-100 dark:bg-slate-700">
|
|
||||||
<th className="p-2 text-left dark:text-gray-200">Stock</th>
|
|
||||||
<th className="p-2 text-right dark:text-gray-200">Total Return</th>
|
|
||||||
<th className="p-2 text-right dark:text-gray-200">Annualized Return</th>
|
|
||||||
<th className="p-2 text-right dark:text-gray-200">Current Price</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{selectedStocks.map(stock => {
|
|
||||||
const metrics = calculatePerformanceMetrics(stock);
|
|
||||||
const historicalData = Array.from(stock.historicalData.entries());
|
|
||||||
const currentPrice = historicalData.length > 0
|
|
||||||
? historicalData[historicalData.length - 1][1]
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={stock.id} className="border-b dark:border-slate-600">
|
|
||||||
<td className="p-2 dark:text-gray-200">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: stockColors[stock.id] }}
|
|
||||||
></div>
|
|
||||||
{stock.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-right dark:text-gray-200">{metrics.total}</td>
|
|
||||||
<td className="p-2 text-right dark:text-gray-200">{metrics.annualized}</td>
|
|
||||||
<td className="p-2 text-right dark:text-gray-200">€{currentPrice.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Savings Plan Simulator */}
|
||||||
|
{selectedStocks.length > 0 && (
|
||||||
|
<SavingsPlanSimulator stocks={selectedStocks} stockColors={stockColors} onParamsChange={setSavingsPlanParams} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Made with Love Badge */}
|
<a
|
||||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
||||||
Made with ❤️ by <a href="https://github.com/0xroko" className="text-blue-500 hover:underline">0xroko</a>
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
|
className="fixed bottom-4 left-4 text-xs sm:text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
Built with <Heart className="w-3 h-3 sm:w-4 sm:h-4 text-red-500 inline animate-pulse" /> by Tomato6966
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,8 +25,6 @@ export const EQUITY_TYPES = {
|
||||||
export const searchAssets = async (query: string, equityType: string): Promise<Asset[]> => {
|
export const searchAssets = async (query: string, equityType: string): Promise<Asset[]> => {
|
||||||
try {
|
try {
|
||||||
// Log input parameters for debugging
|
// Log input parameters for debugging
|
||||||
console.log(`Searching for "${query}" with type "${equityType}"`);
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
query,
|
query,
|
||||||
lang: 'en-US',
|
lang: 'en-US',
|
||||||
|
@ -35,7 +33,6 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||||
console.log(`Request URL: ${url}`);
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
@ -44,7 +41,6 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as YahooSearchResponse;
|
const data = await response.json() as YahooSearchResponse;
|
||||||
console.log("API response:", data);
|
|
||||||
|
|
||||||
if (data.finance.error) {
|
if (data.finance.error) {
|
||||||
console.error(`API error: ${data.finance.error}`);
|
console.error(`API error: ${data.finance.error}`);
|
||||||
|
@ -52,7 +48,6 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.finance.result?.[0]?.documents) {
|
if (!data.finance.result?.[0]?.documents) {
|
||||||
console.log("No results found");
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,9 +56,6 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
||||||
return data.finance.result[0].documents
|
return data.finance.result[0].documents
|
||||||
.filter(quote => {
|
.filter(quote => {
|
||||||
const matches = equityTypes.includes(quote.quoteType.toLowerCase());
|
const matches = equityTypes.includes(quote.quoteType.toLowerCase());
|
||||||
if (!matches) {
|
|
||||||
console.log(`Filtering out ${quote.symbol} (${quote.quoteType}) as it doesn't match ${equityTypes.join(', ')}`);
|
|
||||||
}
|
|
||||||
return matches;
|
return matches;
|
||||||
})
|
})
|
||||||
.map((quote) => ({
|
.map((quote) => ({
|
||||||
|
@ -104,14 +96,16 @@ export const getHistoricalData = async (symbol: string, startDate: Date, endDate
|
||||||
if (!response.ok) throw new Error(`Network response was not ok (${response.status} - ${response.statusText} - ${await response.text().catch(() => 'No text')})`);
|
if (!response.ok) throw new Error(`Network response was not ok (${response.status} - ${response.statusText} - ${await response.text().catch(() => 'No text')})`);
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
|
const { timestamp = [], indicators, meta } = data.chart.result[0] as YahooChartResult;
|
||||||
const quotes = indicators.quote[0];
|
const quotes = indicators.quote[0];
|
||||||
|
|
||||||
const lessThenADay = ["60m", "1h", "90m", "45m", "30m", "15m", "5m", "2m", "1m"].includes(interval);
|
const lessThenADay = ["60m", "1h", "90m", "45m", "30m", "15m", "5m", "2m", "1m"].includes(interval);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
historicalData: new Map(timestamp.map((time: number, index: number) => [formatDateToISO(new Date(time * 1000), lessThenADay), quotes.close[index]])),
|
historicalData: new Map(timestamp.map((time: number, index: number) => [formatDateToISO(new Date(time * 1000), lessThenADay), quotes.close[index]])),
|
||||||
longName: meta.longName
|
longName: meta.longName,
|
||||||
|
currency: meta.currency,
|
||||||
|
lastPrice: meta.chartPreviousClose
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching historical data:', error);
|
console.error('Error fetching historical data:', error);
|
||||||
|
|
|
@ -12,8 +12,7 @@ export const intervalBasedOnDateRange = (dateRange: DateRange, withSubDays: bool
|
||||||
if(withSubDays && diffDays > 60 && diffDays < 100) return "1h";
|
if(withSubDays && diffDays > 60 && diffDays < 100) return "1h";
|
||||||
if(diffDays < oneYear * 2.5) return "1d";
|
if(diffDays < oneYear * 2.5) return "1d";
|
||||||
if(diffDays < oneYear * 6 && diffDays >= oneYear * 2.5) return "5d";
|
if(diffDays < oneYear * 6 && diffDays >= oneYear * 2.5) return "5d";
|
||||||
if(diffDays < oneYear * 15 && diffDays >= oneYear * 6) return "1wk";
|
if(diffDays <= oneYear * 15 && diffDays >= oneYear * 6) return "1wk";
|
||||||
if(diffDays >= oneYear * 30) return "1mo";
|
if(diffDays >= 20) return "1mo";
|
||||||
return "1d";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
import { formatDate, isValid, parseISO } from "date-fns";
|
import { formatDate, isValid, parseISO } from "date-fns";
|
||||||
|
|
||||||
export const formatCurrency = (value: number): string => {
|
const currencyFormatter = (currency: string|null) => {
|
||||||
return `€${value.toLocaleString('de-DE', {
|
if(currency?.toUpperCase() === "USD") return "$";
|
||||||
|
if(currency?.toUpperCase() === "GBP") return "£";
|
||||||
|
if(currency?.toUpperCase() === "EUR") return "€";
|
||||||
|
return currency ?? "🪙";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatCurrency = (value: number|undefined|null, currencyString:string|null = "€"): string => {
|
||||||
|
return `${currencyFormatter(currencyString)} ${value?.toLocaleString('de-DE', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2
|
maximumFractionDigits: 2
|
||||||
})}`;
|
}) ?? "N/A"}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIGHT_MODE_COLORS = [
|
const LIGHT_MODE_COLORS = [
|
||||||
|
|
Loading…
Add table
Reference in a new issue