[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>
This commit is contained in:
parent
954da4656c
commit
e639f36a22
378
src/modules/portfolio/components/AllocationsCard.tsx
Normal file
378
src/modules/portfolio/components/AllocationsCard.tsx
Normal file
@ -0,0 +1,378 @@
|
||||
/**
|
||||
* 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;
|
||||
430
src/modules/portfolio/components/GoalProgressCard.tsx
Normal file
430
src/modules/portfolio/components/GoalProgressCard.tsx
Normal file
@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Goal Progress Card Component
|
||||
* Enhanced goal tracking with progress visualization,
|
||||
* status indicators, and milestone tracking
|
||||
* Connects to GET /api/portfolio/goals/:goalId/progress
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
FlagIcon,
|
||||
CalendarIcon,
|
||||
BanknotesIcon,
|
||||
ChartBarIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
SparklesIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import {
|
||||
getGoalProgress,
|
||||
type GoalProgress,
|
||||
type GoalMilestone,
|
||||
} from '../../../services/portfolio.service';
|
||||
|
||||
interface GoalProgressCardProps {
|
||||
goalId: string;
|
||||
compact?: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const STATUS_CONFIGS: Record<string, StatusConfig> = {
|
||||
on_track: {
|
||||
icon: CheckCircleIcon,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
label: 'En Camino',
|
||||
description: 'Vas bien para alcanzar tu meta',
|
||||
},
|
||||
at_risk: {
|
||||
icon: ClockIcon,
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
label: 'En Riesgo',
|
||||
description: 'Considera aumentar tus aportes',
|
||||
},
|
||||
behind: {
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||
label: 'Atrasado',
|
||||
description: 'Necesitas ajustar tu estrategia',
|
||||
},
|
||||
};
|
||||
|
||||
interface MilestoneItemProps {
|
||||
milestone: GoalMilestone;
|
||||
goalAmount: number;
|
||||
}
|
||||
|
||||
const MilestoneItem: React.FC<MilestoneItemProps> = ({ milestone, goalAmount }) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-2 rounded-lg ${
|
||||
milestone.achieved
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-1.5 rounded-full ${
|
||||
milestone.achieved
|
||||
? 'bg-green-100 dark:bg-green-900/40'
|
||||
: 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{milestone.achieved ? (
|
||||
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<FlagIcon className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${milestone.achieved ? 'text-green-700 dark:text-green-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{milestone.percent}% - ${milestone.amount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{milestone.achieved
|
||||
? `Alcanzado el ${new Date(milestone.date).toLocaleDateString()}`
|
||||
: `Proyectado: ${new Date(milestone.date).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GoalProgressCard: React.FC<GoalProgressCardProps> = ({
|
||||
goalId,
|
||||
compact = false,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [progress, setProgress] = useState<GoalProgress | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProgress();
|
||||
}, [goalId]);
|
||||
|
||||
const loadProgress = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getGoalProgress(goalId);
|
||||
setProgress(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading goal progress:', err);
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar progreso');
|
||||
// Use mock data for demo
|
||||
setProgress(generateMockProgress(goalId));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockProgress = (gId: string): GoalProgress => ({
|
||||
goalId: gId,
|
||||
name: 'Fondo de Emergencia',
|
||||
currentAmount: 7500,
|
||||
targetAmount: 15000,
|
||||
progress: 50,
|
||||
monthlyContribution: 500,
|
||||
monthsRemaining: 15,
|
||||
projectedCompletion: new Date(Date.now() + 15 * 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'on_track',
|
||||
requiredMonthlyRate: 500,
|
||||
onTrackPercentage: 95,
|
||||
milestones: [
|
||||
{ percent: 25, amount: 3750, date: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), achieved: true },
|
||||
{ percent: 50, amount: 7500, date: new Date().toISOString(), achieved: true },
|
||||
{ percent: 75, amount: 11250, date: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), achieved: false },
|
||||
{ percent: 100, amount: 15000, date: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(), achieved: false },
|
||||
],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="text-center py-4">
|
||||
<ExclamationTriangleIcon className="w-8 h-8 text-yellow-500 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{error || 'No se pudo cargar el progreso'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = STATUS_CONFIGS[progress.status];
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const progressPercent = Math.min(100, progress.progress);
|
||||
const remaining = progress.targetAmount - progress.currentAmount;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-1.5 rounded-lg ${statusConfig.bgColor}`}>
|
||||
<StatusIcon className={`w-4 h-4 ${statusConfig.color}`} />
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{progress.name}
|
||||
</h4>
|
||||
</div>
|
||||
<span className={`text-sm font-bold ${statusConfig.color}`}>
|
||||
{progressPercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
progress.status === 'on_track'
|
||||
? 'bg-green-500'
|
||||
: progress.status === 'at_risk'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>${progress.currentAmount.toLocaleString()}</span>
|
||||
<span>${progress.targetAmount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-3 rounded-lg ${statusConfig.bgColor}`}>
|
||||
<FlagIcon className={`w-6 h-6 ${statusConfig.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||
{progress.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<StatusIcon className={`w-4 h-4 ${statusConfig.color}`} />
|
||||
<span className={`text-sm ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{progress.status === 'on_track' && progress.onTrackPercentage >= 90 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||
<SparklesIcon className="w-4 h-4 text-green-500" />
|
||||
<span className="text-xs font-medium text-green-600">Excelente</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Progress Display */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-end justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Progreso Actual</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{progressPercent.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
${progress.currentAmount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
de ${progress.targetAmount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar with milestones */}
|
||||
<div className="relative">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
progress.status === 'on_track'
|
||||
? 'bg-gradient-to-r from-green-400 to-green-500'
|
||||
: progress.status === 'at_risk'
|
||||
? 'bg-gradient-to-r from-yellow-400 to-yellow-500'
|
||||
: 'bg-gradient-to-r from-red-400 to-red-500'
|
||||
}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Milestone markers */}
|
||||
<div className="absolute inset-0 flex justify-between items-center px-1">
|
||||
{[25, 50, 75, 100].map((milestone) => (
|
||||
<div
|
||||
key={milestone}
|
||||
className={`w-2 h-2 rounded-full transform -translate-y-0 ${
|
||||
progressPercent >= milestone
|
||||
? 'bg-white shadow'
|
||||
: 'bg-gray-400 dark:bg-gray-500'
|
||||
}`}
|
||||
style={{ left: `${milestone}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BanknotesIcon className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Faltante</span>
|
||||
</div>
|
||||
<p className="font-bold text-gray-900 dark:text-white">
|
||||
${remaining.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ChartBarIcon className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Aporte Mensual</span>
|
||||
</div>
|
||||
<p className="font-bold text-gray-900 dark:text-white">
|
||||
${progress.monthlyContribution.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CalendarIcon className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Meses Restantes</span>
|
||||
</div>
|
||||
<p className="font-bold text-gray-900 dark:text-white">
|
||||
{progress.monthsRemaining}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ArrowTrendingUpIcon className="w-4 h-4 text-green-500" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Aporte Requerido</span>
|
||||
</div>
|
||||
<p className={`font-bold ${
|
||||
progress.requiredMonthlyRate <= progress.monthlyContribution
|
||||
? 'text-green-500'
|
||||
: 'text-red-500'
|
||||
}`}>
|
||||
${progress.requiredMonthlyRate.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projected Completion */}
|
||||
{progress.projectedCompletion && (
|
||||
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Fecha Proyectada de Cumplimiento
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold text-blue-600 dark:text-blue-400">
|
||||
{new Date(progress.projectedCompletion).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* On-Track Indicator */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Probabilidad de Exito
|
||||
</span>
|
||||
<span className={`font-bold ${
|
||||
progress.onTrackPercentage >= 80
|
||||
? 'text-green-500'
|
||||
: progress.onTrackPercentage >= 50
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}`}>
|
||||
{progress.onTrackPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
progress.onTrackPercentage >= 80
|
||||
? 'bg-green-500'
|
||||
: progress.onTrackPercentage >= 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${progress.onTrackPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Milestones */}
|
||||
{progress.milestones && progress.milestones.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Hitos
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{progress.milestones.map((milestone, index) => (
|
||||
<MilestoneItem
|
||||
key={index}
|
||||
milestone={milestone}
|
||||
goalAmount={progress.targetAmount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Description */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{statusConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Update Button */}
|
||||
{onUpdate && (
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
className="mt-4 w-full py-3 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
|
||||
>
|
||||
Actualizar Progreso
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoalProgressCard;
|
||||
367
src/modules/portfolio/components/PerformanceMetricsCard.tsx
Normal file
367
src/modules/portfolio/components/PerformanceMetricsCard.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Performance Metrics Card Component
|
||||
* Displays detailed portfolio performance metrics including
|
||||
* returns, volatility, Sharpe ratio, and drawdown
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
ChartBarIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import {
|
||||
getPortfolioMetrics,
|
||||
type PortfolioMetrics,
|
||||
} from '../../../services/portfolio.service';
|
||||
|
||||
interface PerformanceMetricsCardProps {
|
||||
portfolioId: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface MetricItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tooltip?: string;
|
||||
type?: 'positive' | 'negative' | 'neutral';
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
const MetricItem: React.FC<MetricItemProps> = ({
|
||||
label,
|
||||
value,
|
||||
tooltip,
|
||||
type = 'neutral',
|
||||
suffix = '',
|
||||
}) => {
|
||||
const colorClass =
|
||||
type === 'positive'
|
||||
? 'text-green-500'
|
||||
: type === 'negative'
|
||||
? 'text-red-500'
|
||||
: 'text-gray-900 dark:text-white';
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>
|
||||
{tooltip && (
|
||||
<div className="group relative">
|
||||
<InformationCircleIcon className="w-4 h-4 text-gray-400 cursor-help" />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||
{tooltip}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-medium ${colorClass}`}>
|
||||
{typeof value === 'number' ? value.toFixed(2) : value}
|
||||
{suffix}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PerformanceMetricsCard: React.FC<PerformanceMetricsCardProps> = ({
|
||||
portfolioId,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [metrics, setMetrics] = useState<PortfolioMetrics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadMetrics();
|
||||
}, [portfolioId]);
|
||||
|
||||
const loadMetrics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getPortfolioMetrics(portfolioId);
|
||||
setMetrics(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading metrics:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load metrics');
|
||||
// Use mock data for demo
|
||||
setMetrics(generateMockMetrics());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockMetrics = (): PortfolioMetrics => ({
|
||||
totalReturn: 2543.67,
|
||||
totalReturnPercent: 25.44,
|
||||
annualizedReturn: 1850.25,
|
||||
annualizedReturnPercent: 18.5,
|
||||
volatility: 12.35,
|
||||
sharpeRatio: 1.45,
|
||||
sortinoRatio: 1.82,
|
||||
maxDrawdown: -856.32,
|
||||
maxDrawdownPercent: -8.56,
|
||||
maxDrawdownDate: '2025-11-15',
|
||||
beta: 0.85,
|
||||
alpha: 3.2,
|
||||
rSquared: 0.78,
|
||||
trackingError: 4.5,
|
||||
informationRatio: 0.71,
|
||||
calmarRatio: 2.16,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<ChartBarIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">Metricas de Rendimiento</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="text-center py-4">
|
||||
<ExclamationTriangleIcon className="w-8 h-8 text-yellow-500 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{error || 'No hay metricas disponibles'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPositiveReturn = metrics.totalReturnPercent >= 0;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<ChartBarIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">Metricas</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Sharpe</p>
|
||||
<p className={`text-lg font-bold ${metrics.sharpeRatio >= 1 ? 'text-green-500' : metrics.sharpeRatio >= 0 ? 'text-yellow-500' : 'text-red-500'}`}>
|
||||
{metrics.sharpeRatio.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Volatilidad</p>
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{metrics.volatility.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Max DD</p>
|
||||
<p className="text-lg font-bold text-red-500">
|
||||
{metrics.maxDrawdownPercent.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Retorno Anual</p>
|
||||
<p className={`text-lg font-bold ${metrics.annualizedReturnPercent >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{metrics.annualizedReturnPercent >= 0 ? '+' : ''}{metrics.annualizedReturnPercent.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<ChartBarIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">Metricas de Rendimiento</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Actualizado: {new Date(metrics.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Return Display */}
|
||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Retorno Total</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPositiveReturn ? (
|
||||
<ArrowTrendingUpIcon className="w-6 h-6 text-green-500" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="w-6 h-6 text-red-500" />
|
||||
)}
|
||||
<span className={`text-2xl font-bold ${isPositiveReturn ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{isPositiveReturn ? '+' : ''}${Math.abs(metrics.totalReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Porcentaje</p>
|
||||
<span className={`text-2xl font-bold ${isPositiveReturn ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{isPositiveReturn ? '+' : ''}{metrics.totalReturnPercent.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Returns Section */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Retornos
|
||||
</h4>
|
||||
<div className="space-y-1 border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<MetricItem
|
||||
label="Retorno Anualizado"
|
||||
value={`${metrics.annualizedReturnPercent >= 0 ? '+' : ''}${metrics.annualizedReturnPercent.toFixed(2)}`}
|
||||
suffix="%"
|
||||
type={metrics.annualizedReturnPercent >= 0 ? 'positive' : 'negative'}
|
||||
tooltip="Retorno proyectado anual basado en el rendimiento actual"
|
||||
/>
|
||||
<MetricItem
|
||||
label="Alpha"
|
||||
value={`${metrics.alpha >= 0 ? '+' : ''}${metrics.alpha.toFixed(2)}`}
|
||||
suffix="%"
|
||||
type={metrics.alpha >= 0 ? 'positive' : 'negative'}
|
||||
tooltip="Rendimiento en exceso sobre el benchmark"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Metrics Section */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Metricas de Riesgo
|
||||
</h4>
|
||||
<div className="space-y-1 border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<MetricItem
|
||||
label="Volatilidad"
|
||||
value={metrics.volatility}
|
||||
suffix="%"
|
||||
tooltip="Desviacion estandar anualizada de los retornos"
|
||||
/>
|
||||
<MetricItem
|
||||
label="Beta"
|
||||
value={metrics.beta}
|
||||
tooltip="Sensibilidad al mercado (1 = igual que el mercado)"
|
||||
/>
|
||||
<MetricItem
|
||||
label="R-Squared"
|
||||
value={(metrics.rSquared * 100).toFixed(0)}
|
||||
suffix="%"
|
||||
tooltip="Porcentaje de variacion explicado por el benchmark"
|
||||
/>
|
||||
<MetricItem
|
||||
label="Tracking Error"
|
||||
value={metrics.trackingError}
|
||||
suffix="%"
|
||||
tooltip="Desviacion de los retornos respecto al benchmark"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk-Adjusted Returns Section */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Retornos Ajustados por Riesgo
|
||||
</h4>
|
||||
<div className="space-y-1 border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<MetricItem
|
||||
label="Sharpe Ratio"
|
||||
value={metrics.sharpeRatio}
|
||||
type={metrics.sharpeRatio >= 1 ? 'positive' : metrics.sharpeRatio >= 0 ? 'neutral' : 'negative'}
|
||||
tooltip="Retorno en exceso por unidad de riesgo (>1 es bueno)"
|
||||
/>
|
||||
<MetricItem
|
||||
label="Sortino Ratio"
|
||||
value={metrics.sortinoRatio}
|
||||
type={metrics.sortinoRatio >= 1 ? 'positive' : metrics.sortinoRatio >= 0 ? 'neutral' : 'negative'}
|
||||
tooltip="Como Sharpe pero solo considera riesgo a la baja"
|
||||
/>
|
||||
<MetricItem
|
||||
label="Information Ratio"
|
||||
value={metrics.informationRatio}
|
||||
type={metrics.informationRatio >= 0.5 ? 'positive' : 'neutral'}
|
||||
tooltip="Alpha dividido por tracking error"
|
||||
/>
|
||||
<MetricItem
|
||||
label="Calmar Ratio"
|
||||
value={metrics.calmarRatio}
|
||||
type={metrics.calmarRatio >= 1 ? 'positive' : 'neutral'}
|
||||
tooltip="Retorno anualizado dividido por max drawdown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drawdown Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Drawdown
|
||||
</h4>
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-red-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Max Drawdown</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-red-500">
|
||||
{metrics.maxDrawdownPercent.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Monto</span>
|
||||
<span className="text-red-500">
|
||||
${Math.abs(metrics.maxDrawdown).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
{metrics.maxDrawdownDate && (
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">Fecha</span>
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{new Date(metrics.maxDrawdownDate).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Indicators */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${metrics.sharpeRatio >= 1 ? 'bg-green-500' : metrics.sharpeRatio >= 0 ? 'bg-yellow-500' : 'bg-red-500'}`}></span>
|
||||
Sharpe: {metrics.sharpeRatio >= 1 ? 'Bueno' : metrics.sharpeRatio >= 0 ? 'Aceptable' : 'Bajo'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${Math.abs(metrics.maxDrawdownPercent) <= 10 ? 'bg-green-500' : Math.abs(metrics.maxDrawdownPercent) <= 20 ? 'bg-yellow-500' : 'bg-red-500'}`}></span>
|
||||
Drawdown: {Math.abs(metrics.maxDrawdownPercent) <= 10 ? 'Bajo' : Math.abs(metrics.maxDrawdownPercent) <= 20 ? 'Moderado' : 'Alto'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceMetricsCard;
|
||||
411
src/modules/portfolio/components/RebalanceModal.tsx
Normal file
411
src/modules/portfolio/components/RebalanceModal.tsx
Normal file
@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Rebalance Modal Component
|
||||
* Modal for reviewing and confirming portfolio rebalancing trades
|
||||
* Connects to GET /api/portfolio/:id/rebalance/calculate
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
XMarkIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowPathIcon,
|
||||
BanknotesIcon,
|
||||
ReceiptPercentIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import {
|
||||
calculateRebalance,
|
||||
executeRebalance,
|
||||
type RebalanceCalculation,
|
||||
type RebalanceTrade,
|
||||
} from '../../../services/portfolio.service';
|
||||
|
||||
interface RebalanceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
portfolioId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface TradeRowProps {
|
||||
trade: RebalanceTrade;
|
||||
}
|
||||
|
||||
const TradeRow: React.FC<TradeRowProps> = ({ trade }) => {
|
||||
const isBuy = trade.action === 'buy';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 rounded-lg ${
|
||||
isBuy
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isBuy ? (
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-full">
|
||||
<ArrowUpIcon className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/40 rounded-full">
|
||||
<ArrowDownIcon className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-900 dark:text-white">
|
||||
{trade.asset}
|
||||
</span>
|
||||
{trade.priority === 'high' && (
|
||||
<span className="px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900/40 text-red-600 rounded-full">
|
||||
Prioritario
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{trade.currentPercent.toFixed(1)}% → {trade.targetPercent.toFixed(1)}%
|
||||
<span className="ml-2 text-gray-400">
|
||||
(Desv: {trade.deviation > 0 ? '+' : ''}{trade.deviation.toFixed(1)}%)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`font-bold ${isBuy ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{isBuy ? 'Comprar' : 'Vender'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{trade.quantity.toLocaleString(undefined, { maximumFractionDigits: 8 })} unidades
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
~${trade.estimatedValue.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RebalanceModal: React.FC<RebalanceModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
portfolioId,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [calculation, setCalculation] = useState<RebalanceCalculation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && portfolioId) {
|
||||
loadCalculation();
|
||||
}
|
||||
}, [isOpen, portfolioId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSuccess(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadCalculation = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await calculateRebalance(portfolioId);
|
||||
setCalculation(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading rebalance calculation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Error al calcular rebalanceo');
|
||||
// Use mock data for demo
|
||||
setCalculation(generateMockCalculation(portfolioId));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockCalculation = (pId: string): RebalanceCalculation => ({
|
||||
portfolioId: pId,
|
||||
currentValue: 25000,
|
||||
trades: [
|
||||
{
|
||||
asset: 'BTC',
|
||||
action: 'buy',
|
||||
quantity: 0.015,
|
||||
estimatedPrice: 45000,
|
||||
estimatedValue: 675,
|
||||
currentPercent: 35.2,
|
||||
targetPercent: 40,
|
||||
deviation: -4.8,
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
asset: 'ETH',
|
||||
action: 'sell',
|
||||
quantity: 0.25,
|
||||
estimatedPrice: 2400,
|
||||
estimatedValue: 600,
|
||||
currentPercent: 32.5,
|
||||
targetPercent: 30,
|
||||
deviation: 2.5,
|
||||
priority: 'medium',
|
||||
},
|
||||
{
|
||||
asset: 'SOL',
|
||||
action: 'buy',
|
||||
quantity: 2.5,
|
||||
estimatedPrice: 100,
|
||||
estimatedValue: 250,
|
||||
currentPercent: 12.3,
|
||||
targetPercent: 15,
|
||||
deviation: -2.7,
|
||||
priority: 'medium',
|
||||
},
|
||||
],
|
||||
estimatedCost: 1525,
|
||||
estimatedFees: 7.62,
|
||||
totalTrades: 3,
|
||||
netChange: 75,
|
||||
summary: {
|
||||
totalBuy: 925,
|
||||
totalSell: 600,
|
||||
netFlow: 325,
|
||||
assetsToAdjust: 3,
|
||||
maxDeviation: 4.8,
|
||||
averageDeviation: 3.33,
|
||||
},
|
||||
});
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!portfolioId) return;
|
||||
|
||||
try {
|
||||
setExecuting(true);
|
||||
setError(null);
|
||||
await executeRebalance(portfolioId);
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al ejecutar rebalanceo');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative min-h-screen flex items-center justify-center p-4">
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<ArrowPathIcon className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Rebalancear Portfolio
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Revisa los trades sugeridos antes de confirmar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
<p className="mt-4 text-gray-500 dark:text-gray-400">
|
||||
Calculando trades optimos...
|
||||
</p>
|
||||
</div>
|
||||
) : success ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="p-4 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||
<CheckCircleIcon className="w-16 h-16 text-green-500" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">
|
||||
Rebalanceo Exitoso
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">
|
||||
Tu portfolio ha sido rebalanceado correctamente
|
||||
</p>
|
||||
</div>
|
||||
) : error && !calculation ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<ExclamationTriangleIcon className="w-16 h-16 text-red-500" />
|
||||
<p className="mt-4 text-red-500">{error}</p>
|
||||
<button
|
||||
onClick={loadCalculation}
|
||||
className="mt-4 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : calculation ? (
|
||||
<>
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
|
||||
<BanknotesIcon className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Valor Portfolio
|
||||
</p>
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
${calculation.currentValue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
|
||||
<ArrowPathIcon className="w-6 h-6 text-purple-500 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Total Trades
|
||||
</p>
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{calculation.totalTrades}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
|
||||
<ReceiptPercentIcon className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Comisiones Est.
|
||||
</p>
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
${calculation.estimatedFees.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Summary */}
|
||||
<div className="mb-6 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Resumen de Transacciones
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Total Compras:</span>
|
||||
<span className="font-medium text-green-600">
|
||||
+${calculation.summary.totalBuy.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Total Ventas:</span>
|
||||
<span className="font-medium text-red-600">
|
||||
-${calculation.summary.totalSell.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Flujo Neto:</span>
|
||||
<span className={`font-medium ${calculation.summary.netFlow >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{calculation.summary.netFlow >= 0 ? '+' : ''}${calculation.summary.netFlow.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Desv. Maxima:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{calculation.summary.maxDeviation.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trades List */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">
|
||||
Trades Sugeridos
|
||||
</h4>
|
||||
{calculation.trades.map((trade, index) => (
|
||||
<TradeRow key={index} trade={trade} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cost Warning */}
|
||||
<div className="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Costos Estimados
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
Esta operacion generara comisiones estimadas de{' '}
|
||||
<strong>${calculation.estimatedFees.toFixed(2)}</strong>.
|
||||
Los precios finales pueden variar segun el mercado al momento de ejecucion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!loading && !success && calculation && (
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={executing}
|
||||
className="flex-1 py-3 px-4 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing}
|
||||
className="flex-1 py-3 px-4 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
Ejecutando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon className="w-5 h-5" />
|
||||
Confirmar Rebalanceo
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RebalanceModal;
|
||||
14
src/modules/portfolio/components/index.ts
Normal file
14
src/modules/portfolio/components/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Portfolio Components Index
|
||||
* Export all portfolio-related components
|
||||
*/
|
||||
|
||||
export { AllocationChart } from './AllocationChart';
|
||||
export { AllocationTable } from './AllocationTable';
|
||||
export { AllocationsCard } from './AllocationsCard';
|
||||
export { GoalCard } from './GoalCard';
|
||||
export { GoalProgressCard } from './GoalProgressCard';
|
||||
export { PerformanceChart } from './PerformanceChart';
|
||||
export { PerformanceMetricsCard } from './PerformanceMetricsCard';
|
||||
export { RebalanceCard } from './RebalanceCard';
|
||||
export { RebalanceModal } from './RebalanceModal';
|
||||
10
src/modules/portfolio/index.ts
Normal file
10
src/modules/portfolio/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Portfolio Module Index
|
||||
* Export all portfolio-related components and pages
|
||||
*/
|
||||
|
||||
// Components
|
||||
export * from './components';
|
||||
|
||||
// Pages
|
||||
export * from './pages';
|
||||
567
src/modules/portfolio/pages/PortfolioDetailPage.tsx
Normal file
567
src/modules/portfolio/pages/PortfolioDetailPage.tsx
Normal file
@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Portfolio Detail Page
|
||||
* Detailed view of a single portfolio with performance charts,
|
||||
* allocations, positions table, and advanced metrics
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowPathIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
ChartBarIcon,
|
||||
CurrencyDollarIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
|
||||
import {
|
||||
getPortfolio,
|
||||
getPortfolioPerformance,
|
||||
type Portfolio,
|
||||
type PerformanceDataPoint,
|
||||
type PerformancePeriod,
|
||||
} from '../../../services/portfolio.service';
|
||||
import { usePortfolioStore } from '../../../stores/portfolioStore';
|
||||
import { AllocationsCard } from '../components/AllocationsCard';
|
||||
import { AllocationTable } from '../components/AllocationTable';
|
||||
import { PerformanceMetricsCard } from '../components/PerformanceMetricsCard';
|
||||
import { RebalanceModal } from '../components/RebalanceModal';
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const PERIOD_OPTIONS: { value: PerformancePeriod; label: string }[] = [
|
||||
{ value: 'week', label: '7D' },
|
||||
{ value: 'month', label: '1M' },
|
||||
{ value: '3months', label: '3M' },
|
||||
{ value: 'year', label: '1A' },
|
||||
{ value: 'all', label: 'Todo' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Subcomponents
|
||||
// ============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
change?: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ label, value, change, icon, color }) => {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={`p-2 rounded-lg ${color}`}>{icon}</div>
|
||||
{change !== undefined && (
|
||||
<div
|
||||
className={`flex items-center gap-1 text-sm font-medium ${
|
||||
change >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<ArrowTrendingUpIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="w-4 h-4" />
|
||||
)}
|
||||
{Math.abs(change).toFixed(2)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-white mt-1">{value}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PerformanceChartProps {
|
||||
data: PerformanceDataPoint[];
|
||||
period: PerformancePeriod;
|
||||
onPeriodChange: (period: PerformancePeriod) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const PerformanceChartSection: React.FC<PerformanceChartProps> = ({
|
||||
data,
|
||||
period,
|
||||
onPeriodChange,
|
||||
loading,
|
||||
}) => {
|
||||
const isPositive = data.length > 0 && data[data.length - 1].value >= data[0].value;
|
||||
const totalChange = data.length > 0 ? data[data.length - 1].value - data[0].value : 0;
|
||||
const totalChangePercent = data.length > 0 && data[0].value > 0
|
||||
? ((data[data.length - 1].value - data[0].value) / data[0].value) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">Rendimiento Historico</h3>
|
||||
{data.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={`text-lg font-bold ${
|
||||
isPositive ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{isPositive ? '+' : ''}${totalChange.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isPositive ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
({isPositive ? '+' : ''}{totalChangePercent.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onPeriodChange(option.value)}
|
||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
||||
period === option.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={isPositive ? '#10B981' : '#EF4444'}
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={isPositive ? '#10B981' : '#EF4444'}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#9CA3AF"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#9CA3AF"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => `$${value.toLocaleString()}`}
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`$${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}`,
|
||||
'Valor',
|
||||
]}
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={isPositive ? '#10B981' : '#EF4444'}
|
||||
strokeWidth={2}
|
||||
fill="url(#colorValue)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export default function PortfolioDetailPage() {
|
||||
const { portfolioId } = useParams<{ portfolioId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Local state
|
||||
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
|
||||
const [performanceData, setPerformanceData] = useState<PerformanceDataPoint[]>([]);
|
||||
const [period, setPeriod] = useState<PerformancePeriod>('month');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingPerformance, setLoadingPerformance] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRebalanceModal, setShowRebalanceModal] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
// Store
|
||||
const { fetchPortfolios } = usePortfolioStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (portfolioId) {
|
||||
loadPortfolio();
|
||||
}
|
||||
}, [portfolioId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (portfolioId) {
|
||||
loadPerformance();
|
||||
}
|
||||
}, [portfolioId, period]);
|
||||
|
||||
const loadPortfolio = async () => {
|
||||
if (!portfolioId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getPortfolio(portfolioId);
|
||||
setPortfolio(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading portfolio:', err);
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar portfolio');
|
||||
// Generate mock portfolio for demo
|
||||
setPortfolio(generateMockPortfolio(portfolioId));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPerformance = async () => {
|
||||
if (!portfolioId) return;
|
||||
|
||||
try {
|
||||
setLoadingPerformance(true);
|
||||
const data = await getPortfolioPerformance(portfolioId, period);
|
||||
setPerformanceData(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading performance:', err);
|
||||
setPerformanceData(generateMockPerformance(period));
|
||||
} finally {
|
||||
setLoadingPerformance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockPortfolio = (id: string): Portfolio => ({
|
||||
id,
|
||||
userId: 'user-1',
|
||||
name: 'Mi Portfolio Principal',
|
||||
riskProfile: 'moderate',
|
||||
allocations: [
|
||||
{ id: '1', portfolioId: id, asset: 'BTC', targetPercent: 40, currentPercent: 38.5, quantity: 0.25, value: 11250, cost: 9500, pnl: 1750, pnlPercent: 18.4, deviation: -1.5 },
|
||||
{ id: '2', portfolioId: id, asset: 'ETH', targetPercent: 30, currentPercent: 32.1, quantity: 3.5, value: 9380, cost: 8200, pnl: 1180, pnlPercent: 14.4, deviation: 2.1 },
|
||||
{ id: '3', portfolioId: id, asset: 'SOL', targetPercent: 15, currentPercent: 14.2, quantity: 42, value: 4150, cost: 3800, pnl: 350, pnlPercent: 9.2, deviation: -0.8 },
|
||||
{ id: '4', portfolioId: id, asset: 'LINK', targetPercent: 10, currentPercent: 10.5, quantity: 180, value: 3070, cost: 2900, pnl: 170, pnlPercent: 5.9, deviation: 0.5 },
|
||||
{ id: '5', portfolioId: id, asset: 'USDT', targetPercent: 5, currentPercent: 4.7, quantity: 1375, value: 1375, cost: 1375, pnl: 0, pnlPercent: 0, deviation: -0.3 },
|
||||
],
|
||||
totalValue: 29225,
|
||||
totalCost: 25775,
|
||||
unrealizedPnl: 3450,
|
||||
unrealizedPnlPercent: 13.39,
|
||||
realizedPnl: 1250,
|
||||
lastRebalanced: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const generateMockPerformance = (p: PerformancePeriod): PerformanceDataPoint[] => {
|
||||
const days = p === 'week' ? 7 : p === 'month' ? 30 : p === '3months' ? 90 : p === 'year' ? 365 : 180;
|
||||
const data: PerformanceDataPoint[] = [];
|
||||
let value = 25000;
|
||||
|
||||
for (let i = days; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
const change = (Math.random() - 0.4) * value * 0.02;
|
||||
value += change;
|
||||
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
value,
|
||||
pnl: value - 25000,
|
||||
pnlPercent: ((value - 25000) / 25000) * 100,
|
||||
change,
|
||||
changePercent: (change / (value - change)) * 100,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleRebalanceSuccess = () => {
|
||||
loadPortfolio();
|
||||
fetchPortfolios();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !portfolio) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadPortfolio}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!portfolio) return null;
|
||||
|
||||
const riskProfileLabels = {
|
||||
conservative: 'Conservador',
|
||||
moderate: 'Moderado',
|
||||
aggressive: 'Agresivo',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/portfolio')}
|
||||
className="p-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{portfolio.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Perfil: {riskProfileLabels[portfolio.riskProfile]}
|
||||
</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{portfolio.allocations.length} activos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={loadPortfolio}
|
||||
className="p-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRebalanceModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<ArrowsRightLeftIcon className="w-5 h-5" />
|
||||
Rebalancear
|
||||
</button>
|
||||
<Link
|
||||
to={`/portfolio/${portfolioId}/add-position`}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
Agregar
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowActions(!showActions)}
|
||||
className="p-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
<EllipsisVerticalIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
{showActions && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||
<Link
|
||||
to={`/portfolio/${portfolioId}/edit`}
|
||||
className="flex items-center gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Editar Portfolio</span>
|
||||
</Link>
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-3 w-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<span>Eliminar Portfolio</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label="Valor Total"
|
||||
value={`$${portfolio.totalValue.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
icon={<CurrencyDollarIcon className="w-5 h-5 text-blue-600" />}
|
||||
color="bg-blue-100 dark:bg-blue-900/30"
|
||||
/>
|
||||
<StatCard
|
||||
label="P&L No Realizado"
|
||||
value={`${portfolio.unrealizedPnl >= 0 ? '+' : ''}$${Math.abs(portfolio.unrealizedPnl).toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
change={portfolio.unrealizedPnlPercent}
|
||||
icon={<ArrowTrendingUpIcon className="w-5 h-5 text-green-600" />}
|
||||
color="bg-green-100 dark:bg-green-900/30"
|
||||
/>
|
||||
<StatCard
|
||||
label="Costo Base"
|
||||
value={`$${portfolio.totalCost.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
icon={<ChartBarIcon className="w-5 h-5 text-purple-600" />}
|
||||
color="bg-purple-100 dark:bg-purple-900/30"
|
||||
/>
|
||||
<StatCard
|
||||
label="P&L Realizado"
|
||||
value={`${portfolio.realizedPnl >= 0 ? '+' : ''}$${Math.abs(portfolio.realizedPnl).toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
icon={<CurrencyDollarIcon className="w-5 h-5 text-orange-600" />}
|
||||
color="bg-orange-100 dark:bg-orange-900/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Chart */}
|
||||
<div className="mb-8">
|
||||
<PerformanceChartSection
|
||||
data={performanceData}
|
||||
period={period}
|
||||
onPeriodChange={setPeriod}
|
||||
loading={loadingPerformance}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Left Column: Allocations & Positions */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Allocations Chart */}
|
||||
<AllocationsCard
|
||||
allocations={portfolio.allocations}
|
||||
showTargetComparison={true}
|
||||
onRebalance={() => setShowRebalanceModal(true)}
|
||||
maxDriftThreshold={5}
|
||||
/>
|
||||
|
||||
{/* Positions Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||
Detalle de Posiciones
|
||||
</h3>
|
||||
<Link
|
||||
to={`/portfolio/${portfolioId}/edit`}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Editar Allocaciones
|
||||
</Link>
|
||||
</div>
|
||||
<AllocationTable
|
||||
allocations={portfolio.allocations}
|
||||
showDeviation={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Metrics */}
|
||||
<div className="space-y-8">
|
||||
<PerformanceMetricsCard
|
||||
portfolioId={portfolio.id}
|
||||
compact={false}
|
||||
/>
|
||||
|
||||
{/* Portfolio Info */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white mb-4">
|
||||
Informacion del Portfolio
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Perfil de Riesgo</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{riskProfileLabels[portfolio.riskProfile]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Ultimo Rebalanceo</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{portfolio.lastRebalanced
|
||||
? new Date(portfolio.lastRebalanced).toLocaleDateString()
|
||||
: 'Nunca'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Creado</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(portfolio.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Actualizado</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(portfolio.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rebalance Modal */}
|
||||
<RebalanceModal
|
||||
isOpen={showRebalanceModal}
|
||||
onClose={() => setShowRebalanceModal(false)}
|
||||
portfolioId={portfolio.id}
|
||||
onSuccess={handleRebalanceSuccess}
|
||||
/>
|
||||
|
||||
{/* Click outside to close actions menu */}
|
||||
{showActions && (
|
||||
<div
|
||||
className="fixed inset-0 z-0"
|
||||
onClick={() => setShowActions(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/modules/portfolio/pages/index.ts
Normal file
10
src/modules/portfolio/pages/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Portfolio Pages Index
|
||||
* Export all portfolio-related pages
|
||||
*/
|
||||
|
||||
export { default as PortfolioDashboard } from './PortfolioDashboard';
|
||||
export { default as PortfolioDetailPage } from './PortfolioDetailPage';
|
||||
export { default as CreatePortfolio } from './CreatePortfolio';
|
||||
export { default as CreateGoal } from './CreateGoal';
|
||||
export { default as EditAllocations } from './EditAllocations';
|
||||
@ -308,3 +308,164 @@ export async function deleteGoal(goalId: string): Promise<void> {
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete goal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get goal progress details
|
||||
*/
|
||||
export async function getGoalProgress(goalId: string): Promise<GoalProgress> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/goals/${goalId}/progress`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch goal progress');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Portfolio Metrics API Functions
|
||||
// ============================================================================
|
||||
|
||||
export interface PortfolioMetrics {
|
||||
totalReturn: number;
|
||||
totalReturnPercent: number;
|
||||
annualizedReturn: number;
|
||||
annualizedReturnPercent: number;
|
||||
volatility: number;
|
||||
sharpeRatio: number;
|
||||
sortinoRatio: number;
|
||||
maxDrawdown: number;
|
||||
maxDrawdownPercent: number;
|
||||
maxDrawdownDate: string | null;
|
||||
beta: number;
|
||||
alpha: number;
|
||||
rSquared: number;
|
||||
trackingError: number;
|
||||
informationRatio: number;
|
||||
calmarRatio: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GoalProgress {
|
||||
goalId: string;
|
||||
name: string;
|
||||
currentAmount: number;
|
||||
targetAmount: number;
|
||||
progress: number;
|
||||
monthlyContribution: number;
|
||||
monthsRemaining: number;
|
||||
projectedCompletion: string | null;
|
||||
status: 'on_track' | 'at_risk' | 'behind';
|
||||
requiredMonthlyRate: number;
|
||||
onTrackPercentage: number;
|
||||
milestones: GoalMilestone[];
|
||||
}
|
||||
|
||||
export interface GoalMilestone {
|
||||
percent: number;
|
||||
amount: number;
|
||||
date: string;
|
||||
achieved: boolean;
|
||||
}
|
||||
|
||||
export interface RebalanceCalculation {
|
||||
portfolioId: string;
|
||||
currentValue: number;
|
||||
trades: RebalanceTrade[];
|
||||
estimatedCost: number;
|
||||
estimatedFees: number;
|
||||
totalTrades: number;
|
||||
netChange: number;
|
||||
summary: RebalanceSummary;
|
||||
}
|
||||
|
||||
export interface RebalanceTrade {
|
||||
asset: string;
|
||||
action: 'buy' | 'sell';
|
||||
quantity: number;
|
||||
estimatedPrice: number;
|
||||
estimatedValue: number;
|
||||
currentPercent: number;
|
||||
targetPercent: number;
|
||||
deviation: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface RebalanceSummary {
|
||||
totalBuy: number;
|
||||
totalSell: number;
|
||||
netFlow: number;
|
||||
assetsToAdjust: number;
|
||||
maxDeviation: number;
|
||||
averageDeviation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed portfolio metrics
|
||||
*/
|
||||
export async function getPortfolioMetrics(portfolioId: string): Promise<PortfolioMetrics> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/metrics`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch portfolio metrics');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rebalance trades without executing
|
||||
*/
|
||||
export async function calculateRebalance(portfolioId: string): Promise<RebalanceCalculation> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance/calculate`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to calculate rebalance');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update portfolio settings
|
||||
*/
|
||||
export async function updatePortfolio(
|
||||
portfolioId: string,
|
||||
updates: Partial<Pick<Portfolio, 'name' | 'riskProfile'>>
|
||||
): Promise<Portfolio> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update portfolio');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a portfolio
|
||||
*/
|
||||
export async function deletePortfolio(portfolioId: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete portfolio');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add position to portfolio
|
||||
*/
|
||||
export async function addPosition(
|
||||
portfolioId: string,
|
||||
position: { asset: string; quantity: number; cost: number }
|
||||
): Promise<Portfolio> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/positions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(position),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to add position');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user