trading-platform-frontend-v2/src/modules/portfolio/components/AllocationsCard.tsx
Adrian Flores Cortes e639f36a22 [SYNC] feat: Add portfolio module components and pages
- 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>
2026-02-03 08:20:34 -06:00

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;