[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:
Adrian Flores Cortes 2026-02-03 08:20:34 -06:00
parent 954da4656c
commit e639f36a22
9 changed files with 2348 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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';

View File

@ -0,0 +1,10 @@
/**
* Portfolio Module Index
* Export all portfolio-related components and pages
*/
// Components
export * from './components';
// Pages
export * from './pages';

View 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>
);
}

View 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';

View File

@ -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;
}