[OQI-008] feat: Add portfolio Phase 3 - Performance chart and edit allocations

- Add PerformanceChart component with canvas-based line chart
- Add EditAllocations page for modifying target allocations
- Integrate PerformanceChart into PortfolioDashboard
- Add route for /portfolio/:portfolioId/edit
- Extend portfolio.service with performance API functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 08:40:47 -06:00
parent b8a7cbe691
commit c02625f37b
5 changed files with 723 additions and 0 deletions

View File

@ -34,6 +34,7 @@ const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard'));
const CreatePortfolio = lazy(() => import('./modules/portfolio/pages/CreatePortfolio'));
const CreateGoal = lazy(() => import('./modules/portfolio/pages/CreateGoal'));
const EditAllocations = lazy(() => import('./modules/portfolio/pages/EditAllocations'));
// Lazy load modules - Education
const Courses = lazy(() => import('./modules/education/pages/Courses'));
@ -91,6 +92,7 @@ function App() {
<Route path="/portfolio" element={<PortfolioDashboard />} />
<Route path="/portfolio/new" element={<CreatePortfolio />} />
<Route path="/portfolio/goals/new" element={<CreateGoal />} />
<Route path="/portfolio/:portfolioId/edit" element={<EditAllocations />} />
{/* Education */}
<Route path="/education/courses" element={<Courses />} />

View File

@ -0,0 +1,311 @@
/**
* Performance Chart Component
* Line chart showing portfolio performance over time
*/
import React, { useEffect, useState, useRef } from 'react';
import {
getPortfolioPerformance,
type PerformanceDataPoint,
type PerformancePeriod,
} from '../../../services/portfolio.service';
interface PerformanceChartProps {
portfolioId: string;
height?: number;
}
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' },
];
export const PerformanceChart: React.FC<PerformanceChartProps> = ({
portfolioId,
height = 300,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [data, setData] = useState<PerformanceDataPoint[]>([]);
const [period, setPeriod] = useState<PerformancePeriod>('month');
const [loading, setLoading] = useState(true);
const [hoveredPoint, setHoveredPoint] = useState<PerformanceDataPoint | null>(null);
useEffect(() => {
loadData();
}, [portfolioId, period]);
useEffect(() => {
if (data.length > 0) {
drawChart();
}
}, [data, hoveredPoint]);
const loadData = async () => {
try {
setLoading(true);
const performanceData = await getPortfolioPerformance(portfolioId, period);
setData(performanceData);
} catch (error) {
console.error('Error loading performance data:', error);
// Generate mock data for demo
setData(generateMockData(period));
} finally {
setLoading(false);
}
};
const generateMockData = (p: PerformancePeriod): PerformanceDataPoint[] => {
const days = p === 'week' ? 7 : p === 'month' ? 30 : p === '3months' ? 90 : p === 'year' ? 365 : 180;
const mockData: PerformanceDataPoint[] = [];
let value = 10000;
let pnl = 0;
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;
pnl = value - 10000;
mockData.push({
date: date.toISOString().split('T')[0],
value,
pnl,
pnlPercent: (pnl / 10000) * 100,
change,
changePercent: (change / (value - change)) * 100,
});
}
return mockData;
};
const drawChart = () => {
const canvas = canvasRef.current;
if (!canvas || data.length === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const chartHeight = rect.height;
const padding = { top: 20, right: 20, bottom: 30, left: 60 };
const chartWidth = width - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
// Clear canvas
ctx.clearRect(0, 0, width, chartHeight);
// Get min/max values
const values = data.map((d) => d.value);
const minValue = Math.min(...values) * 0.995;
const maxValue = Math.max(...values) * 1.005;
const valueRange = maxValue - minValue;
// Calculate points
const points = data.map((d, i) => ({
x: padding.left + (i / (data.length - 1)) * chartWidth,
y: padding.top + innerHeight - ((d.value - minValue) / valueRange) * innerHeight,
data: d,
}));
// Determine color based on overall performance
const isPositive = data[data.length - 1].value >= data[0].value;
const lineColor = isPositive ? '#10B981' : '#EF4444';
const fillColor = isPositive ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)';
// Draw grid lines
ctx.strokeStyle = 'rgba(156, 163, 175, 0.2)';
ctx.lineWidth = 1;
const gridLines = 5;
for (let i = 0; i <= gridLines; i++) {
const y = padding.top + (i / gridLines) * innerHeight;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
// Y-axis labels
const value = maxValue - (i / gridLines) * valueRange;
ctx.fillStyle = '#9CA3AF';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
ctx.fillText(`$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`, padding.left - 8, y + 4);
}
// Draw fill gradient
ctx.beginPath();
ctx.moveTo(points[0].x, chartHeight - padding.bottom);
points.forEach((p) => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, chartHeight - padding.bottom);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// Draw line
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
points.forEach((p) => ctx.lineTo(p.x, p.y));
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.stroke();
// Draw X-axis labels (dates)
ctx.fillStyle = '#9CA3AF';
ctx.font = '11px system-ui';
ctx.textAlign = 'center';
const labelCount = Math.min(6, data.length);
for (let i = 0; i < labelCount; i++) {
const idx = Math.floor((i / (labelCount - 1)) * (data.length - 1));
const point = points[idx];
const date = new Date(data[idx].date);
const label = `${date.getDate()}/${date.getMonth() + 1}`;
ctx.fillText(label, point.x, chartHeight - 10);
}
// Draw hovered point
if (hoveredPoint) {
const idx = data.findIndex((d) => d.date === hoveredPoint.date);
if (idx >= 0) {
const point = points[idx];
ctx.beginPath();
ctx.arc(point.x, point.y, 6, 0, Math.PI * 2);
ctx.fillStyle = lineColor;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// Vertical line
ctx.beginPath();
ctx.moveTo(point.x, padding.top);
ctx.lineTo(point.x, chartHeight - padding.bottom);
ctx.strokeStyle = 'rgba(156, 163, 175, 0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
}
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || data.length === 0) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const padding = { left: 60, right: 20 };
const chartWidth = rect.width - padding.left - padding.right;
if (x < padding.left || x > rect.width - padding.right) {
setHoveredPoint(null);
return;
}
const relativeX = (x - padding.left) / chartWidth;
const idx = Math.round(relativeX * (data.length - 1));
const point = data[Math.max(0, Math.min(idx, data.length - 1))];
setHoveredPoint(point);
};
const handleMouseLeave = () => {
setHoveredPoint(null);
};
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">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-bold text-gray-900 dark:text-white">Rendimiento</h3>
{data.length > 0 && (
<div className="flex items-center gap-2 mt-1">
<span
className={`text-lg font-bold ${
totalChange >= 0 ? 'text-green-500' : 'text-red-500'
}`}
>
{totalChange >= 0 ? '+' : ''}${totalChange.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
<span
className={`text-sm ${
totalChangePercent >= 0 ? 'text-green-500' : 'text-red-500'
}`}
>
({totalChangePercent >= 0 ? '+' : ''}{totalChangePercent.toFixed(2)}%)
</span>
</div>
)}
</div>
<div className="flex gap-1">
{PERIOD_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setPeriod(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>
{/* Hovered point info */}
{hoveredPoint && (
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">{hoveredPoint.date}</span>
<span className="font-medium text-gray-900 dark:text-white">
${hoveredPoint.value.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
<div className="flex justify-between text-xs mt-1">
<span className="text-gray-500 dark:text-gray-400">Cambio diario</span>
<span className={hoveredPoint.change >= 0 ? 'text-green-500' : 'text-red-500'}>
{hoveredPoint.change >= 0 ? '+' : ''}
{hoveredPoint.changePercent.toFixed(2)}%
</span>
</div>
</div>
)}
{/* Chart */}
{loading ? (
<div className="flex items-center justify-center" style={{ height }}>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<canvas
ref={canvasRef}
style={{ width: '100%', height }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="cursor-crosshair"
/>
)}
</div>
);
};
export default PerformanceChart;

View File

@ -0,0 +1,348 @@
/**
* Edit Allocations Page
* Form to edit portfolio target allocations
*/
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeftIcon,
PlusIcon,
TrashIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/solid';
import {
getPortfolio,
updateAllocations,
type Portfolio,
} from '../../../services/portfolio.service';
// ============================================================================
// Available Assets
// ============================================================================
const AVAILABLE_ASSETS = [
{ symbol: 'BTC', name: 'Bitcoin', icon: 'https://cryptologos.cc/logos/bitcoin-btc-logo.svg' },
{ symbol: 'ETH', name: 'Ethereum', icon: 'https://cryptologos.cc/logos/ethereum-eth-logo.svg' },
{ symbol: 'USDT', name: 'Tether', icon: 'https://cryptologos.cc/logos/tether-usdt-logo.svg' },
{ symbol: 'SOL', name: 'Solana', icon: 'https://cryptologos.cc/logos/solana-sol-logo.svg' },
{ symbol: 'LINK', name: 'Chainlink', icon: 'https://cryptologos.cc/logos/chainlink-link-logo.svg' },
{ symbol: 'AVAX', name: 'Avalanche', icon: 'https://cryptologos.cc/logos/avalanche-avax-logo.svg' },
{ symbol: 'ADA', name: 'Cardano', icon: 'https://cryptologos.cc/logos/cardano-ada-logo.svg' },
{ symbol: 'DOT', name: 'Polkadot', icon: 'https://cryptologos.cc/logos/polkadot-new-dot-logo.svg' },
{ symbol: 'MATIC', name: 'Polygon', icon: 'https://cryptologos.cc/logos/polygon-matic-logo.svg' },
{ symbol: 'UNI', name: 'Uniswap', icon: 'https://cryptologos.cc/logos/uniswap-uni-logo.svg' },
];
interface AllocationInput {
asset: string;
targetPercent: number;
}
// ============================================================================
// Main Component
// ============================================================================
export default function EditAllocations() {
const navigate = useNavigate();
const { portfolioId } = useParams<{ portfolioId: string }>();
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
const [allocations, setAllocations] = useState<AllocationInput[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
useEffect(() => {
if (portfolioId) {
loadPortfolio();
}
}, [portfolioId]);
const loadPortfolio = async () => {
try {
setLoading(true);
const data = await getPortfolio(portfolioId!);
setPortfolio(data);
setAllocations(
data.allocations.map((a) => ({
asset: a.asset,
targetPercent: a.targetPercent,
}))
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error loading portfolio');
} finally {
setLoading(false);
}
};
const totalPercent = allocations.reduce((sum, a) => sum + a.targetPercent, 0);
const isValid = Math.abs(totalPercent - 100) < 0.01;
const handlePercentChange = (index: number, value: string) => {
const newAllocations = [...allocations];
newAllocations[index].targetPercent = parseFloat(value) || 0;
setAllocations(newAllocations);
};
const handleRemove = (index: number) => {
const newAllocations = allocations.filter((_, i) => i !== index);
setAllocations(newAllocations);
};
const handleAddAsset = (asset: string) => {
if (allocations.some((a) => a.asset === asset)) {
setShowAddModal(false);
return;
}
const remaining = Math.max(0, 100 - totalPercent);
setAllocations([...allocations, { asset, targetPercent: remaining }]);
setShowAddModal(false);
};
const handleAutoBalance = () => {
if (allocations.length === 0) return;
const equalPercent = 100 / allocations.length;
setAllocations(allocations.map((a) => ({ ...a, targetPercent: equalPercent })));
};
const handleSubmit = async () => {
if (!isValid || !portfolioId) {
setError('Las allocaciones deben sumar 100%');
return;
}
try {
setSaving(true);
setError(null);
await updateAllocations(portfolioId, allocations);
navigate('/portfolio');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error saving allocations');
} finally {
setSaving(false);
}
};
const availableToAdd = AVAILABLE_ASSETS.filter(
(asset) => !allocations.some((a) => a.asset === asset.symbol)
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<button
onClick={() => navigate('/portfolio')}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
>
<ArrowLeftIcon className="w-5 h-5" />
Volver
</button>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Editar Allocaciones
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{portfolio?.name} - Perfil{' '}
{portfolio?.riskProfile === 'conservative'
? 'Conservador'
: portfolio?.riskProfile === 'moderate'
? 'Moderado'
: 'Agresivo'}
</p>
</div>
{/* Total indicator */}
<div
className={`mb-6 p-4 rounded-xl flex items-center justify-between ${
isValid
? '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-2">
{!isValid && <ExclamationTriangleIcon className="w-5 h-5 text-red-500" />}
<span className="font-medium text-gray-900 dark:text-white">
Total: {totalPercent.toFixed(1)}%
</span>
</div>
{!isValid && (
<span className="text-sm text-red-600 dark:text-red-400">
{totalPercent < 100
? `Faltan ${(100 - totalPercent).toFixed(1)}%`
: `Sobran ${(totalPercent - 100).toFixed(1)}%`}
</span>
)}
</div>
{/* Allocations list */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-bold text-gray-900 dark:text-white">Distribución</h2>
<button
onClick={handleAutoBalance}
className="text-sm text-blue-600 hover:text-blue-700"
>
Distribuir equitativamente
</button>
</div>
<div className="space-y-4">
{allocations.map((alloc, index) => {
const assetInfo = AVAILABLE_ASSETS.find((a) => a.symbol === alloc.asset);
return (
<div
key={alloc.asset}
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="flex items-center gap-3 flex-1">
{assetInfo?.icon ? (
<img src={assetInfo.icon} alt={alloc.asset} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center font-bold text-sm">
{alloc.asset.slice(0, 2)}
</div>
)}
<div>
<p className="font-medium text-gray-900 dark:text-white">
{alloc.asset}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{assetInfo?.name || alloc.asset}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="number"
value={alloc.targetPercent}
onChange={(e) => handlePercentChange(index, e.target.value)}
min="0"
max="100"
step="0.1"
className="w-20 px-3 py-2 bg-white dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg text-right text-gray-900 dark:text-white"
/>
<span className="text-gray-500 dark:text-gray-400">%</span>
</div>
<button
onClick={() => handleRemove(index)}
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
>
<TrashIcon className="w-5 h-5" />
</button>
{/* Visual bar */}
<div className="w-24 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all"
style={{ width: `${Math.min(100, alloc.targetPercent)}%` }}
/>
</div>
</div>
);
})}
</div>
{/* Add asset button */}
{availableToAdd.length > 0 && (
<button
onClick={() => setShowAddModal(true)}
className="mt-4 w-full py-3 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-500 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-colors flex items-center justify-center gap-2"
>
<PlusIcon className="w-5 h-5" />
Agregar Activo
</button>
)}
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Action buttons */}
<div className="flex justify-end gap-4">
<button
onClick={() => navigate('/portfolio')}
className="px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={!isValid || saving}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Guardando...
</>
) : (
'Guardar Cambios'
)}
</button>
</div>
{/* Add Asset Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 max-h-[80vh] overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-bold text-gray-900 dark:text-white">Agregar Activo</h3>
</div>
<div className="p-4 overflow-y-auto max-h-96">
<div className="space-y-2">
{availableToAdd.map((asset) => (
<button
key={asset.symbol}
onClick={() => handleAddAsset(asset.symbol)}
className="w-full flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
>
{asset.icon ? (
<img src={asset.icon} alt={asset.symbol} className="w-8 h-8" />
) : (
<div className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center font-bold text-xs">
{asset.symbol.slice(0, 2)}
</div>
)}
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-white">
{asset.symbol}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{asset.name}
</p>
</div>
</button>
))}
</div>
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowAddModal(false)}
className="w-full py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancelar
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -29,6 +29,7 @@ import { AllocationChart } from '../components/AllocationChart';
import { AllocationTable } from '../components/AllocationTable';
import { RebalanceCard } from '../components/RebalanceCard';
import { GoalCard } from '../components/GoalCard';
import { PerformanceChart } from '../components/PerformanceChart';
// ============================================================================
// Subcomponents
@ -289,6 +290,11 @@ export default function PortfolioDashboard() {
/>
</div>
{/* Performance Chart */}
<div className="mb-8">
<PerformanceChart portfolioId={selectedPortfolio.id} height={280} />
</div>
{/* Main Content Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Left: Allocation Chart & Table */}

View File

@ -193,6 +193,62 @@ export async function getPortfolioStats(portfolioId: string): Promise<PortfolioS
return data.data || data;
}
// ============================================================================
// Performance API Functions
// ============================================================================
export interface PerformanceDataPoint {
date: string;
value: number;
pnl: number;
pnlPercent: number;
change: number;
changePercent: number;
}
export interface PerformanceStats {
dayChange: number;
dayChangePercent: number;
weekChange: number;
weekChangePercent: number;
monthChange: number;
monthChangePercent: number;
yearChange: number;
yearChangePercent: number;
allTimeChange: number;
allTimeChangePercent: number;
}
export type PerformancePeriod = 'week' | 'month' | '3months' | 'year' | 'all';
/**
* Get portfolio performance history for charts
*/
export async function getPortfolioPerformance(
portfolioId: string,
period: PerformancePeriod = 'month'
): Promise<PerformanceDataPoint[]> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/performance?period=${period}`,
{ credentials: 'include' }
);
if (!response.ok) throw new Error('Failed to fetch performance');
const data = await response.json();
return data.data || data;
}
/**
* Get detailed performance statistics
*/
export async function getPerformanceStats(portfolioId: string): Promise<PerformanceStats> {
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/performance/stats`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch performance stats');
const data = await response.json();
return data.data || data;
}
// ============================================================================
// Goals API Functions
// ============================================================================