/** * 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 = { 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 = ({ 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 (

{label}

Drift maximo: {drift.toFixed(1)}%

); }; 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 = ({ active, payload }) => { if (!active || !payload || !payload.length) return null; const data = payload[0].payload; return (

{data.asset}

Valor: ${data.value.toLocaleString(undefined, { minimumFractionDigits: 2 })}
Actual: {data.currentPercent.toFixed(1)}%
Objetivo: {data.targetPercent.toFixed(1)}%
Drift: 0 ? 'text-green-500' : data.deviation < 0 ? 'text-red-500' : 'text-gray-500'}> {data.deviation > 0 ? '+' : ''}{data.deviation.toFixed(1)}%
P&L: = 0 ? 'text-green-500' : 'text-red-500'}> {data.pnl >= 0 ? '+' : ''}${data.pnl.toLocaleString(undefined, { minimumFractionDigits: 2 })}
); }; export const AllocationsCard: React.FC = ({ 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 (
{/* Header */}

Distribucion de Activos

Valor total: ${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2 })}

{/* Charts Container */}
{/* Current Allocation Chart */}

Actual

{chartData.map((entry, index) => ( ))} } />
{/* Target Allocation Chart */} {showTargetComparison && (

Objetivo

{targetChartData.map((entry, index) => ( ))}
)}
{/* Legend with Deviation */}
{allocations.map((alloc, index) => (

{alloc.asset}

{alloc.currentPercent.toFixed(1)}% {showTargetComparison && ( <> / {alloc.targetPercent.toFixed(1)}% {alloc.deviation !== 0 && ( 0 ? 'text-green-500' : 'text-red-500' }`} > ({alloc.deviation > 0 ? '+' : ''}{alloc.deviation.toFixed(1)}%) )} )}
))}
{/* Deviation Details Table */} {showTargetComparison && (

Detalle de Desviaciones

{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) => (
{alloc.asset} 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'}

{alloc.currentPercent.toFixed(1)}% → {alloc.targetPercent.toFixed(1)}%

0 ? 'text-green-500' : 'text-red-500' }`} > {alloc.deviation > 0 ? '+' : ''}{alloc.deviation.toFixed(1)}%
))}
)} {/* Rebalance Button */} {needsRebalance && onRebalance && ( )} {/* Info Footer */}

Se recomienda rebalancear cuando la desviacion maxima supera el {maxDriftThreshold}% del objetivo.

); }; export default AllocationsCard;