- Add AllocationsCard component - Add GoalProgressCard component - Add PerformanceMetricsCard component - Add RebalanceModal component - Add PortfolioDetailPage - Update portfolio service - Add module exports Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
|
* Allocations Card Component
|
|
* Displays portfolio allocations with donut chart,
|
|
* comparison with target allocation, and drift indicator
|
|
*/
|
|
|
|
import React, { useMemo } from 'react';
|
|
import {
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
Legend,
|
|
} from 'recharts';
|
|
import {
|
|
ExclamationTriangleIcon,
|
|
CheckCircleIcon,
|
|
ArrowsRightLeftIcon,
|
|
} from '@heroicons/react/24/solid';
|
|
import type { PortfolioAllocation } from '../../../services/portfolio.service';
|
|
|
|
interface AllocationsCardProps {
|
|
allocations: PortfolioAllocation[];
|
|
showTargetComparison?: boolean;
|
|
onRebalance?: () => void;
|
|
maxDriftThreshold?: number;
|
|
}
|
|
|
|
// Asset colors for chart
|
|
const ASSET_COLORS: Record<string, string> = {
|
|
BTC: '#F7931A',
|
|
ETH: '#627EEA',
|
|
USDT: '#26A17B',
|
|
USDC: '#2775CA',
|
|
SOL: '#9945FF',
|
|
LINK: '#2A5ADA',
|
|
AVAX: '#E84142',
|
|
ADA: '#0033AD',
|
|
DOT: '#E6007A',
|
|
MATIC: '#8247E5',
|
|
XRP: '#23292F',
|
|
BNB: '#F0B90B',
|
|
DOGE: '#C3A634',
|
|
ATOM: '#2E3148',
|
|
LTC: '#BFBBBB',
|
|
};
|
|
|
|
const DEFAULT_COLORS = [
|
|
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
|
'#EC4899', '#06B6D4', '#84CC16', '#F97316', '#6366F1',
|
|
];
|
|
|
|
function getAssetColor(asset: string, index: number): string {
|
|
return ASSET_COLORS[asset] || DEFAULT_COLORS[index % DEFAULT_COLORS.length];
|
|
}
|
|
|
|
interface DriftIndicatorProps {
|
|
drift: number;
|
|
threshold: number;
|
|
}
|
|
|
|
const DriftIndicator: React.FC<DriftIndicatorProps> = ({ drift, threshold }) => {
|
|
const severity = Math.abs(drift) <= threshold / 2
|
|
? 'low'
|
|
: Math.abs(drift) <= threshold
|
|
? 'medium'
|
|
: 'high';
|
|
|
|
const config = {
|
|
low: {
|
|
icon: CheckCircleIcon,
|
|
color: 'text-green-500',
|
|
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
|
label: 'Balanceado',
|
|
},
|
|
medium: {
|
|
icon: ArrowsRightLeftIcon,
|
|
color: 'text-yellow-500',
|
|
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
|
|
label: 'Drift Moderado',
|
|
},
|
|
high: {
|
|
icon: ExclamationTriangleIcon,
|
|
color: 'text-red-500',
|
|
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
|
label: 'Requiere Rebalanceo',
|
|
},
|
|
};
|
|
|
|
const { icon: Icon, color, bgColor, label } = config[severity];
|
|
|
|
return (
|
|
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${bgColor}`}>
|
|
<Icon className={`w-5 h-5 ${color}`} />
|
|
<div>
|
|
<p className={`text-sm font-medium ${color}`}>{label}</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Drift maximo: {drift.toFixed(1)}%
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface CustomTooltipProps {
|
|
active?: boolean;
|
|
payload?: Array<{
|
|
payload: {
|
|
asset: string;
|
|
value: number;
|
|
currentPercent: number;
|
|
targetPercent: number;
|
|
deviation: number;
|
|
pnl: number;
|
|
pnlPercent: number;
|
|
};
|
|
}>;
|
|
}
|
|
|
|
const CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload }) => {
|
|
if (!active || !payload || !payload.length) return null;
|
|
|
|
const data = payload[0].payload;
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 p-3 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
|
<p className="font-bold text-gray-900 dark:text-white">{data.asset}</p>
|
|
<div className="mt-2 space-y-1 text-sm">
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-gray-500 dark:text-gray-400">Valor:</span>
|
|
<span className="text-gray-900 dark:text-white">
|
|
${data.value.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-gray-500 dark:text-gray-400">Actual:</span>
|
|
<span className="text-gray-900 dark:text-white">{data.currentPercent.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-gray-500 dark:text-gray-400">Objetivo:</span>
|
|
<span className="text-gray-900 dark:text-white">{data.targetPercent.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-gray-500 dark:text-gray-400">Drift:</span>
|
|
<span className={data.deviation > 0 ? 'text-green-500' : data.deviation < 0 ? 'text-red-500' : 'text-gray-500'}>
|
|
{data.deviation > 0 ? '+' : ''}{data.deviation.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4 pt-1 border-t border-gray-200 dark:border-gray-700">
|
|
<span className="text-gray-500 dark:text-gray-400">P&L:</span>
|
|
<span className={data.pnl >= 0 ? 'text-green-500' : 'text-red-500'}>
|
|
{data.pnl >= 0 ? '+' : ''}${data.pnl.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const AllocationsCard: React.FC<AllocationsCardProps> = ({
|
|
allocations,
|
|
showTargetComparison = true,
|
|
onRebalance,
|
|
maxDriftThreshold = 5,
|
|
}) => {
|
|
const chartData = useMemo(() => {
|
|
return allocations.map((alloc, index) => ({
|
|
...alloc,
|
|
name: alloc.asset,
|
|
fill: getAssetColor(alloc.asset, index),
|
|
}));
|
|
}, [allocations]);
|
|
|
|
const totalValue = useMemo(() => {
|
|
return allocations.reduce((sum, alloc) => sum + alloc.value, 0);
|
|
}, [allocations]);
|
|
|
|
const maxDrift = useMemo(() => {
|
|
return Math.max(...allocations.map((a) => Math.abs(a.deviation)));
|
|
}, [allocations]);
|
|
|
|
const needsRebalance = maxDrift > maxDriftThreshold;
|
|
|
|
const targetChartData = useMemo(() => {
|
|
return allocations.map((alloc, index) => ({
|
|
name: alloc.asset,
|
|
value: alloc.targetPercent,
|
|
fill: getAssetColor(alloc.asset, index),
|
|
}));
|
|
}, [allocations]);
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="font-bold text-gray-900 dark:text-white">
|
|
Distribucion de Activos
|
|
</h3>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Valor total: ${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
</p>
|
|
</div>
|
|
<DriftIndicator drift={maxDrift} threshold={maxDriftThreshold} />
|
|
</div>
|
|
|
|
{/* Charts Container */}
|
|
<div className={`grid ${showTargetComparison ? 'grid-cols-2' : 'grid-cols-1'} gap-4`}>
|
|
{/* Current Allocation Chart */}
|
|
<div>
|
|
<p className="text-sm font-medium text-center text-gray-600 dark:text-gray-400 mb-2">
|
|
Actual
|
|
</p>
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<PieChart>
|
|
<Pie
|
|
data={chartData}
|
|
dataKey="currentPercent"
|
|
nameKey="asset"
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={50}
|
|
outerRadius={80}
|
|
paddingAngle={2}
|
|
>
|
|
{chartData.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Target Allocation Chart */}
|
|
{showTargetComparison && (
|
|
<div>
|
|
<p className="text-sm font-medium text-center text-gray-600 dark:text-gray-400 mb-2">
|
|
Objetivo
|
|
</p>
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<PieChart>
|
|
<Pie
|
|
data={targetChartData}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={50}
|
|
outerRadius={80}
|
|
paddingAngle={2}
|
|
>
|
|
{targetChartData.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Legend with Deviation */}
|
|
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
{allocations.map((alloc, index) => (
|
|
<div
|
|
key={alloc.asset}
|
|
className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 dark:bg-gray-700/50"
|
|
>
|
|
<div
|
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: getAssetColor(alloc.asset, index) }}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
{alloc.asset}
|
|
</p>
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
{alloc.currentPercent.toFixed(1)}%
|
|
</span>
|
|
{showTargetComparison && (
|
|
<>
|
|
<span className="text-gray-400 dark:text-gray-500">/</span>
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
{alloc.targetPercent.toFixed(1)}%
|
|
</span>
|
|
{alloc.deviation !== 0 && (
|
|
<span
|
|
className={`ml-1 ${
|
|
alloc.deviation > 0 ? 'text-green-500' : 'text-red-500'
|
|
}`}
|
|
>
|
|
({alloc.deviation > 0 ? '+' : ''}{alloc.deviation.toFixed(1)}%)
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Deviation Details Table */}
|
|
{showTargetComparison && (
|
|
<div className="mt-6">
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
|
Detalle de Desviaciones
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{allocations
|
|
.filter((a) => Math.abs(a.deviation) > 0.5)
|
|
.sort((a, b) => Math.abs(b.deviation) - Math.abs(a.deviation))
|
|
.slice(0, 5)
|
|
.map((alloc) => (
|
|
<div
|
|
key={alloc.asset}
|
|
className="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-gray-700/50"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-gray-900 dark:text-white">
|
|
{alloc.asset}
|
|
</span>
|
|
<span
|
|
className={`text-xs px-2 py-0.5 rounded-full ${
|
|
alloc.deviation > 0
|
|
? 'bg-green-100 dark:bg-green-900/30 text-green-600'
|
|
: 'bg-red-100 dark:bg-red-900/30 text-red-600'
|
|
}`}
|
|
>
|
|
{alloc.deviation > 0 ? 'Exceso' : 'Deficit'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-right">
|
|
<p className="text-sm text-gray-900 dark:text-white">
|
|
{alloc.currentPercent.toFixed(1)}% → {alloc.targetPercent.toFixed(1)}%
|
|
</p>
|
|
</div>
|
|
<span
|
|
className={`font-bold ${
|
|
alloc.deviation > 0 ? 'text-green-500' : 'text-red-500'
|
|
}`}
|
|
>
|
|
{alloc.deviation > 0 ? '+' : ''}{alloc.deviation.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rebalance Button */}
|
|
{needsRebalance && onRebalance && (
|
|
<button
|
|
onClick={onRebalance}
|
|
className="mt-6 w-full py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<ArrowsRightLeftIcon className="w-5 h-5" />
|
|
Rebalancear Portfolio
|
|
</button>
|
|
)}
|
|
|
|
{/* Info Footer */}
|
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Se recomienda rebalancear cuando la desviacion maxima supera el {maxDriftThreshold}% del objetivo.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AllocationsCard;
|