diff --git a/src/modules/portfolio/components/AllocationsCard.tsx b/src/modules/portfolio/components/AllocationsCard.tsx new file mode 100644 index 0000000..e33e106 --- /dev/null +++ b/src/modules/portfolio/components/AllocationsCard.tsx @@ -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 = { + BTC: '#F7931A', + ETH: '#627EEA', + USDT: '#26A17B', + USDC: '#2775CA', + SOL: '#9945FF', + LINK: '#2A5ADA', + AVAX: '#E84142', + ADA: '#0033AD', + DOT: '#E6007A', + MATIC: '#8247E5', + XRP: '#23292F', + BNB: '#F0B90B', + DOGE: '#C3A634', + ATOM: '#2E3148', + LTC: '#BFBBBB', +}; + +const DEFAULT_COLORS = [ + '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', + '#EC4899', '#06B6D4', '#84CC16', '#F97316', '#6366F1', +]; + +function getAssetColor(asset: string, index: number): string { + return ASSET_COLORS[asset] || DEFAULT_COLORS[index % DEFAULT_COLORS.length]; +} + +interface DriftIndicatorProps { + drift: number; + threshold: number; +} + +const DriftIndicator: React.FC = ({ drift, threshold }) => { + const severity = Math.abs(drift) <= threshold / 2 + ? 'low' + : Math.abs(drift) <= threshold + ? 'medium' + : 'high'; + + const config = { + low: { + icon: CheckCircleIcon, + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900/30', + label: 'Balanceado', + }, + medium: { + icon: ArrowsRightLeftIcon, + color: 'text-yellow-500', + bgColor: 'bg-yellow-100 dark:bg-yellow-900/30', + label: 'Drift Moderado', + }, + high: { + icon: ExclamationTriangleIcon, + color: 'text-red-500', + bgColor: 'bg-red-100 dark:bg-red-900/30', + label: 'Requiere Rebalanceo', + }, + }; + + const { icon: Icon, color, bgColor, label } = config[severity]; + + return ( +
+ +
+

{label}

+

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

+
+
+ ); +}; + +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ + payload: { + asset: string; + value: number; + currentPercent: number; + targetPercent: number; + deviation: number; + pnl: number; + pnlPercent: number; + }; + }>; +} + +const CustomTooltip: React.FC = ({ active, payload }) => { + if (!active || !payload || !payload.length) return null; + + const data = payload[0].payload; + + return ( +
+

{data.asset}

+
+
+ Valor: + + ${data.value.toLocaleString(undefined, { minimumFractionDigits: 2 })} + +
+
+ Actual: + {data.currentPercent.toFixed(1)}% +
+
+ Objetivo: + {data.targetPercent.toFixed(1)}% +
+
+ Drift: + 0 ? 'text-green-500' : data.deviation < 0 ? 'text-red-500' : 'text-gray-500'}> + {data.deviation > 0 ? '+' : ''}{data.deviation.toFixed(1)}% + +
+
+ P&L: + = 0 ? 'text-green-500' : 'text-red-500'}> + {data.pnl >= 0 ? '+' : ''}${data.pnl.toLocaleString(undefined, { minimumFractionDigits: 2 })} + +
+
+
+ ); +}; + +export const AllocationsCard: React.FC = ({ + allocations, + showTargetComparison = true, + onRebalance, + maxDriftThreshold = 5, +}) => { + const chartData = useMemo(() => { + return allocations.map((alloc, index) => ({ + ...alloc, + name: alloc.asset, + fill: getAssetColor(alloc.asset, index), + })); + }, [allocations]); + + const totalValue = useMemo(() => { + return allocations.reduce((sum, alloc) => sum + alloc.value, 0); + }, [allocations]); + + const maxDrift = useMemo(() => { + return Math.max(...allocations.map((a) => Math.abs(a.deviation))); + }, [allocations]); + + const needsRebalance = maxDrift > maxDriftThreshold; + + const targetChartData = useMemo(() => { + return allocations.map((alloc, index) => ({ + name: alloc.asset, + value: alloc.targetPercent, + fill: getAssetColor(alloc.asset, index), + })); + }, [allocations]); + + return ( +
+ {/* Header */} +
+
+

+ Distribucion de Activos +

+

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

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

+ Actual +

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

+ Objetivo +

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

+ {alloc.asset} +

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

+ Detalle de Desviaciones +

+
+ {allocations + .filter((a) => Math.abs(a.deviation) > 0.5) + .sort((a, b) => Math.abs(b.deviation) - Math.abs(a.deviation)) + .slice(0, 5) + .map((alloc) => ( +
+
+ + {alloc.asset} + + 0 + ? 'bg-green-100 dark:bg-green-900/30 text-green-600' + : 'bg-red-100 dark:bg-red-900/30 text-red-600' + }`} + > + {alloc.deviation > 0 ? 'Exceso' : 'Deficit'} + +
+
+
+

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

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

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

+
+
+ ); +}; + +export default AllocationsCard; diff --git a/src/modules/portfolio/components/GoalProgressCard.tsx b/src/modules/portfolio/components/GoalProgressCard.tsx new file mode 100644 index 0000000..d95499e --- /dev/null +++ b/src/modules/portfolio/components/GoalProgressCard.tsx @@ -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 = { + 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 = ({ milestone, goalAmount }) => { + return ( +
+
+ {milestone.achieved ? ( + + ) : ( + + )} +
+
+

+ {milestone.percent}% - ${milestone.amount.toLocaleString()} +

+

+ {milestone.achieved + ? `Alcanzado el ${new Date(milestone.date).toLocaleDateString()}` + : `Proyectado: ${new Date(milestone.date).toLocaleDateString()}`} +

+
+
+ ); +}; + +export const GoalProgressCard: React.FC = ({ + goalId, + compact = false, + onUpdate, +}) => { + const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+
+
+ ); + } + + if (!progress) { + return ( +
+
+ +

+ {error || 'No se pudo cargar el progreso'} +

+
+
+ ); + } + + 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 ( +
+
+
+
+ +
+

+ {progress.name} +

+
+ + {progressPercent.toFixed(0)}% + +
+ {/* Progress bar */} +
+
+
+
+ ${progress.currentAmount.toLocaleString()} + ${progress.targetAmount.toLocaleString()} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ {progress.name} +

+
+ + + {statusConfig.label} + +
+
+
+ {progress.status === 'on_track' && progress.onTrackPercentage >= 90 && ( +
+ + Excelente +
+ )} +
+ + {/* Main Progress Display */} +
+
+
+

Progreso Actual

+

+ {progressPercent.toFixed(1)}% +

+
+
+

+ ${progress.currentAmount.toLocaleString()} +

+

+ de ${progress.targetAmount.toLocaleString()} +

+
+
+ + {/* Progress bar with milestones */} +
+
+
+
+ {/* Milestone markers */} +
+ {[25, 50, 75, 100].map((milestone) => ( +
= milestone + ? 'bg-white shadow' + : 'bg-gray-400 dark:bg-gray-500' + }`} + style={{ left: `${milestone}%` }} + /> + ))} +
+
+
+ + {/* Stats Grid */} +
+
+
+ + Faltante +
+

+ ${remaining.toLocaleString()} +

+
+
+
+ + Aporte Mensual +
+

+ ${progress.monthlyContribution.toLocaleString()} +

+
+
+
+ + Meses Restantes +
+

+ {progress.monthsRemaining} +

+
+
+
+ + Aporte Requerido +
+

+ ${progress.requiredMonthlyRate.toLocaleString()} +

+
+
+ + {/* Projected Completion */} + {progress.projectedCompletion && ( +
+
+
+ + + Fecha Proyectada de Cumplimiento + +
+ + {new Date(progress.projectedCompletion).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+ )} + + {/* On-Track Indicator */} +
+
+ + Probabilidad de Exito + + = 80 + ? 'text-green-500' + : progress.onTrackPercentage >= 50 + ? 'text-yellow-500' + : 'text-red-500' + }`}> + {progress.onTrackPercentage}% + +
+
+
= 80 + ? 'bg-green-500' + : progress.onTrackPercentage >= 50 + ? 'bg-yellow-500' + : 'bg-red-500' + }`} + style={{ width: `${progress.onTrackPercentage}%` }} + /> +
+
+ + {/* Milestones */} + {progress.milestones && progress.milestones.length > 0 && ( +
+

+ Hitos +

+
+ {progress.milestones.map((milestone, index) => ( + + ))} +
+
+ )} + + {/* Status Description */} +
+

+ {statusConfig.description} +

+
+ + {/* Update Button */} + {onUpdate && ( + + )} +
+ ); +}; + +export default GoalProgressCard; diff --git a/src/modules/portfolio/components/PerformanceMetricsCard.tsx b/src/modules/portfolio/components/PerformanceMetricsCard.tsx new file mode 100644 index 0000000..e51f5db --- /dev/null +++ b/src/modules/portfolio/components/PerformanceMetricsCard.tsx @@ -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 = ({ + 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 ( +
+
+ {label} + {tooltip && ( +
+ +
+ {tooltip} +
+
+ )} +
+ + {typeof value === 'number' ? value.toFixed(2) : value} + {suffix} + +
+ ); +}; + +export const PerformanceMetricsCard: React.FC = ({ + portfolioId, + compact = false, +}) => { + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ +
+

Metricas de Rendimiento

+
+
+
+
+
+ ); + } + + if (!metrics) { + return ( +
+
+ +

+ {error || 'No hay metricas disponibles'} +

+
+
+ ); + } + + const isPositiveReturn = metrics.totalReturnPercent >= 0; + + if (compact) { + return ( +
+
+
+
+ +
+

Metricas

+
+
+
+
+

Sharpe

+

= 1 ? 'text-green-500' : metrics.sharpeRatio >= 0 ? 'text-yellow-500' : 'text-red-500'}`}> + {metrics.sharpeRatio.toFixed(2)} +

+
+
+

Volatilidad

+

+ {metrics.volatility.toFixed(1)}% +

+
+
+

Max DD

+

+ {metrics.maxDrawdownPercent.toFixed(1)}% +

+
+
+

Retorno Anual

+

= 0 ? 'text-green-500' : 'text-red-500'}`}> + {metrics.annualizedReturnPercent >= 0 ? '+' : ''}{metrics.annualizedReturnPercent.toFixed(1)}% +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Metricas de Rendimiento

+

+ Actualizado: {new Date(metrics.updatedAt).toLocaleString()} +

+
+
+
+ + {/* Main Return Display */} +
+
+
+

Retorno Total

+
+ {isPositiveReturn ? ( + + ) : ( + + )} + + {isPositiveReturn ? '+' : ''}${Math.abs(metrics.totalReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })} + +
+
+
+

Porcentaje

+ + {isPositiveReturn ? '+' : ''}{metrics.totalReturnPercent.toFixed(2)}% + +
+
+
+ + {/* Returns Section */} +
+

+ Retornos +

+
+ = 0 ? '+' : ''}${metrics.annualizedReturnPercent.toFixed(2)}`} + suffix="%" + type={metrics.annualizedReturnPercent >= 0 ? 'positive' : 'negative'} + tooltip="Retorno proyectado anual basado en el rendimiento actual" + /> + = 0 ? '+' : ''}${metrics.alpha.toFixed(2)}`} + suffix="%" + type={metrics.alpha >= 0 ? 'positive' : 'negative'} + tooltip="Rendimiento en exceso sobre el benchmark" + /> +
+
+ + {/* Risk Metrics Section */} +
+

+ Metricas de Riesgo +

+
+ + + + +
+
+ + {/* Risk-Adjusted Returns Section */} +
+

+ Retornos Ajustados por Riesgo +

+
+ = 1 ? 'positive' : metrics.sharpeRatio >= 0 ? 'neutral' : 'negative'} + tooltip="Retorno en exceso por unidad de riesgo (>1 es bueno)" + /> + = 1 ? 'positive' : metrics.sortinoRatio >= 0 ? 'neutral' : 'negative'} + tooltip="Como Sharpe pero solo considera riesgo a la baja" + /> + = 0.5 ? 'positive' : 'neutral'} + tooltip="Alpha dividido por tracking error" + /> + = 1 ? 'positive' : 'neutral'} + tooltip="Retorno anualizado dividido por max drawdown" + /> +
+
+ + {/* Drawdown Section */} +
+

+ Drawdown +

+
+
+
+ + Max Drawdown +
+ + {metrics.maxDrawdownPercent.toFixed(2)}% + +
+
+ Monto + + ${Math.abs(metrics.maxDrawdown).toLocaleString(undefined, { minimumFractionDigits: 2 })} + +
+ {metrics.maxDrawdownDate && ( +
+ Fecha + + {new Date(metrics.maxDrawdownDate).toLocaleDateString()} + +
+ )} +
+
+ + {/* Quality Indicators */} +
+
+
+ = 1 ? 'bg-green-500' : metrics.sharpeRatio >= 0 ? 'bg-yellow-500' : 'bg-red-500'}`}> + Sharpe: {metrics.sharpeRatio >= 1 ? 'Bueno' : metrics.sharpeRatio >= 0 ? 'Aceptable' : 'Bajo'} +
+
+ + Drawdown: {Math.abs(metrics.maxDrawdownPercent) <= 10 ? 'Bajo' : Math.abs(metrics.maxDrawdownPercent) <= 20 ? 'Moderado' : 'Alto'} +
+
+
+
+ ); +}; + +export default PerformanceMetricsCard; diff --git a/src/modules/portfolio/components/RebalanceModal.tsx b/src/modules/portfolio/components/RebalanceModal.tsx new file mode 100644 index 0000000..3d696c8 --- /dev/null +++ b/src/modules/portfolio/components/RebalanceModal.tsx @@ -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 = ({ trade }) => { + const isBuy = trade.action === 'buy'; + + return ( +
+
+ {isBuy ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + {trade.asset} + + {trade.priority === 'high' && ( + + Prioritario + + )} +
+

+ {trade.currentPercent.toFixed(1)}% → {trade.targetPercent.toFixed(1)}% + + (Desv: {trade.deviation > 0 ? '+' : ''}{trade.deviation.toFixed(1)}%) + +

+
+
+
+

+ {isBuy ? 'Comprar' : 'Vender'} +

+

+ {trade.quantity.toLocaleString(undefined, { maximumFractionDigits: 8 })} unidades +

+

+ ~${trade.estimatedValue.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+ ); +}; + +export const RebalanceModal: React.FC = ({ + isOpen, + onClose, + portfolioId, + onSuccess, +}) => { + const [calculation, setCalculation] = useState(null); + const [loading, setLoading] = useState(true); + const [executing, setExecuting] = useState(false); + const [error, setError] = useState(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 ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Rebalancear Portfolio +

+

+ Revisa los trades sugeridos antes de confirmar +

+
+
+ +
+ + {/* Content */} +
+ {loading ? ( +
+
+

+ Calculando trades optimos... +

+
+ ) : success ? ( +
+
+ +
+

+ Rebalanceo Exitoso +

+

+ Tu portfolio ha sido rebalanceado correctamente +

+
+ ) : error && !calculation ? ( +
+ +

{error}

+ +
+ ) : calculation ? ( + <> + {/* Summary Stats */} +
+
+ +

+ Valor Portfolio +

+

+ ${calculation.currentValue.toLocaleString()} +

+
+
+ +

+ Total Trades +

+

+ {calculation.totalTrades} +

+
+
+ +

+ Comisiones Est. +

+

+ ${calculation.estimatedFees.toFixed(2)} +

+
+
+ + {/* Trade Summary */} +
+

+ Resumen de Transacciones +

+
+
+ Total Compras: + + +${calculation.summary.totalBuy.toLocaleString()} + +
+
+ Total Ventas: + + -${calculation.summary.totalSell.toLocaleString()} + +
+
+ Flujo Neto: + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {calculation.summary.netFlow >= 0 ? '+' : ''}${calculation.summary.netFlow.toLocaleString()} + +
+
+ Desv. Maxima: + + {calculation.summary.maxDeviation.toFixed(1)}% + +
+
+
+ + {/* Trades List */} +
+

+ Trades Sugeridos +

+ {calculation.trades.map((trade, index) => ( + + ))} +
+ + {/* Cost Warning */} +
+
+ +
+

+ Costos Estimados +

+

+ Esta operacion generara comisiones estimadas de{' '} + ${calculation.estimatedFees.toFixed(2)}. + Los precios finales pueden variar segun el mercado al momento de ejecucion. +

+
+
+
+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + ) : null} +
+ + {/* Footer */} + {!loading && !success && calculation && ( +
+
+ + +
+
+ )} +
+
+
+ ); +}; + +export default RebalanceModal; diff --git a/src/modules/portfolio/components/index.ts b/src/modules/portfolio/components/index.ts new file mode 100644 index 0000000..4922cf9 --- /dev/null +++ b/src/modules/portfolio/components/index.ts @@ -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'; diff --git a/src/modules/portfolio/index.ts b/src/modules/portfolio/index.ts new file mode 100644 index 0000000..45412f2 --- /dev/null +++ b/src/modules/portfolio/index.ts @@ -0,0 +1,10 @@ +/** + * Portfolio Module Index + * Export all portfolio-related components and pages + */ + +// Components +export * from './components'; + +// Pages +export * from './pages'; diff --git a/src/modules/portfolio/pages/PortfolioDetailPage.tsx b/src/modules/portfolio/pages/PortfolioDetailPage.tsx new file mode 100644 index 0000000..66982a3 --- /dev/null +++ b/src/modules/portfolio/pages/PortfolioDetailPage.tsx @@ -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 = ({ label, value, change, icon, color }) => { + return ( +
+
+
{icon}
+ {change !== undefined && ( +
= 0 ? 'text-green-500' : 'text-red-500' + }`} + > + {change >= 0 ? ( + + ) : ( + + )} + {Math.abs(change).toFixed(2)}% +
+ )} +
+

{label}

+

{value}

+
+ ); +}; + +interface PerformanceChartProps { + data: PerformanceDataPoint[]; + period: PerformancePeriod; + onPeriodChange: (period: PerformancePeriod) => void; + loading: boolean; +} + +const PerformanceChartSection: React.FC = ({ + 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 ( +
+
+
+

Rendimiento Historico

+ {data.length > 0 && ( +
+ + {isPositive ? '+' : ''}${totalChange.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + ({isPositive ? '+' : ''}{totalChangePercent.toFixed(2)}%) + +
+ )} +
+
+ {PERIOD_OPTIONS.map((option) => ( + + ))} +
+
+ + {loading ? ( +
+
+
+ ) : ( + + + + + + + + + + { + const date = new Date(value); + return `${date.getDate()}/${date.getMonth() + 1}`; + }} + /> + `$${value.toLocaleString()}`} + domain={['auto', 'auto']} + /> + [ + `$${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, + 'Valor', + ]} + labelFormatter={(label) => new Date(label).toLocaleDateString()} + /> + + + + )} +
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export default function PortfolioDetailPage() { + const { portfolioId } = useParams<{ portfolioId: string }>(); + const navigate = useNavigate(); + + // Local state + const [portfolio, setPortfolio] = useState(null); + const [performanceData, setPerformanceData] = useState([]); + const [period, setPeriod] = useState('month'); + const [loading, setLoading] = useState(true); + const [loadingPerformance, setLoadingPerformance] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ ); + } + + if (error && !portfolio) { + return ( +
+

{error}

+ +
+ ); + } + + if (!portfolio) return null; + + const riskProfileLabels = { + conservative: 'Conservador', + moderate: 'Moderado', + aggressive: 'Agresivo', + }; + + return ( +
+ {/* Header */} +
+
+ +
+

+ {portfolio.name} +

+
+ + Perfil: {riskProfileLabels[portfolio.riskProfile]} + + | + + {portfolio.allocations.length} activos + +
+
+
+ +
+ + + + + Agregar + +
+ + {showActions && ( +
+ + + Editar Portfolio + + +
+ )} +
+
+
+ + {/* Stats Grid */} +
+ } + color="bg-blue-100 dark:bg-blue-900/30" + /> + = 0 ? '+' : ''}$${Math.abs(portfolio.unrealizedPnl).toLocaleString(undefined, { minimumFractionDigits: 2 })}`} + change={portfolio.unrealizedPnlPercent} + icon={} + color="bg-green-100 dark:bg-green-900/30" + /> + } + color="bg-purple-100 dark:bg-purple-900/30" + /> + = 0 ? '+' : ''}$${Math.abs(portfolio.realizedPnl).toLocaleString(undefined, { minimumFractionDigits: 2 })}`} + icon={} + color="bg-orange-100 dark:bg-orange-900/30" + /> +
+ + {/* Performance Chart */} +
+ +
+ + {/* Main Content Grid */} +
+ {/* Left Column: Allocations & Positions */} +
+ {/* Allocations Chart */} + setShowRebalanceModal(true)} + maxDriftThreshold={5} + /> + + {/* Positions Table */} +
+
+

+ Detalle de Posiciones +

+ + Editar Allocaciones + +
+ +
+
+ + {/* Right Column: Metrics */} +
+ + + {/* Portfolio Info */} +
+

+ Informacion del Portfolio +

+
+
+ Perfil de Riesgo + + {riskProfileLabels[portfolio.riskProfile]} + +
+
+ Ultimo Rebalanceo + + {portfolio.lastRebalanced + ? new Date(portfolio.lastRebalanced).toLocaleDateString() + : 'Nunca'} + +
+
+ Creado + + {new Date(portfolio.createdAt).toLocaleDateString()} + +
+
+ Actualizado + + {new Date(portfolio.updatedAt).toLocaleString()} + +
+
+
+
+
+ + {/* Rebalance Modal */} + setShowRebalanceModal(false)} + portfolioId={portfolio.id} + onSuccess={handleRebalanceSuccess} + /> + + {/* Click outside to close actions menu */} + {showActions && ( +
setShowActions(false)} + /> + )} +
+ ); +} diff --git a/src/modules/portfolio/pages/index.ts b/src/modules/portfolio/pages/index.ts new file mode 100644 index 0000000..81996d6 --- /dev/null +++ b/src/modules/portfolio/pages/index.ts @@ -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'; diff --git a/src/services/portfolio.service.ts b/src/services/portfolio.service.ts index 1f9f318..b39f9a3 100644 --- a/src/services/portfolio.service.ts +++ b/src/services/portfolio.service.ts @@ -308,3 +308,164 @@ export async function deleteGoal(goalId: string): Promise { }); if (!response.ok) throw new Error('Failed to delete goal'); } + +/** + * Get goal progress details + */ +export async function getGoalProgress(goalId: string): Promise { + 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 { + 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 { + 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> +): Promise { + 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 { + 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 { + 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; +}