[OQI-008] feat: Add portfolio store with WebSocket real-time updates

- Create portfolioStore.ts with Zustand for state management
- Add portfolioWS instance for real-time portfolio updates
- Add usePortfolioUpdates hook for WebSocket subscriptions
- Refactor PortfolioDashboard to use store instead of local state
- Add WebSocket connection status indicator (Live/Offline)
- Add last update timestamp display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 08:47:46 -06:00
parent c02625f37b
commit fd54724ede
3 changed files with 540 additions and 91 deletions

View File

@ -3,7 +3,7 @@
* Main dashboard for portfolio management with allocations, rebalancing, and goals
*/
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
PlusIcon,
@ -13,18 +13,10 @@ import {
CurrencyDollarIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
SignalIcon,
SignalSlashIcon,
} from '@heroicons/react/24/solid';
import {
getUserPortfolios,
getPortfolioStats,
getRebalanceRecommendations,
executeRebalance,
getUserGoals,
type Portfolio,
type PortfolioStats,
type RebalanceRecommendation,
type PortfolioGoal,
} from '../../../services/portfolio.service';
import { usePortfolioStore } from '../../../stores/portfolioStore';
import { AllocationChart } from '../components/AllocationChart';
import { AllocationTable } from '../components/AllocationTable';
import { RebalanceCard } from '../components/RebalanceCard';
@ -74,90 +66,52 @@ const StatCard: React.FC<StatCardProps> = ({ label, value, change, icon, color }
// ============================================================================
export default function PortfolioDashboard() {
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | null>(null);
const [stats, setStats] = useState<PortfolioStats | null>(null);
const [recommendations, setRecommendations] = useState<RebalanceRecommendation[]>([]);
const [goals, setGoals] = useState<PortfolioGoal[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRebalancing, setIsRebalancing] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'goals'>('overview');
// Fetch all data
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [portfolioData, goalsData] = await Promise.all([
getUserPortfolios(),
getUserGoals(),
]);
setPortfolios(portfolioData);
setGoals(goalsData);
if (portfolioData.length > 0) {
const primary = portfolioData[0];
setSelectedPortfolio(primary);
const [statsData, rebalanceData] = await Promise.all([
getPortfolioStats(primary.id),
getRebalanceRecommendations(primary.id),
]);
setStats(statsData);
setRecommendations(rebalanceData);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Error loading data');
} finally {
setLoading(false);
}
}, []);
// Store state
const {
portfolios,
selectedPortfolio,
stats,
recommendations,
goals,
loading,
isRebalancing,
error,
wsConnected,
lastUpdate,
fetchPortfolios,
selectPortfolio,
executeRebalance,
deleteGoal,
connectWebSocket,
disconnectWebSocket,
clearError,
} = usePortfolioStore();
// Initial data fetch and WebSocket connection
useEffect(() => {
fetchData();
}, [fetchData]);
fetchPortfolios();
connectWebSocket();
// Handle portfolio selection
const handlePortfolioSelect = async (portfolio: Portfolio) => {
setSelectedPortfolio(portfolio);
try {
const [statsData, rebalanceData] = await Promise.all([
getPortfolioStats(portfolio.id),
getRebalanceRecommendations(portfolio.id),
]);
setStats(statsData);
setRecommendations(rebalanceData);
} catch (err) {
console.error('Error loading portfolio data:', err);
}
};
return () => {
disconnectWebSocket();
};
}, [fetchPortfolios, connectWebSocket, disconnectWebSocket]);
// Handle rebalance execution
const handleRebalance = async () => {
if (!selectedPortfolio) return;
try {
setIsRebalancing(true);
await executeRebalance(selectedPortfolio.id);
await fetchData();
} catch (err) {
console.error('Error executing rebalance:', err);
} finally {
setIsRebalancing(false);
}
await executeRebalance();
};
// Handle goal deletion
const handleDeleteGoal = async (goalId: string) => {
// TODO: Implement delete confirmation modal
console.log('Delete goal:', goalId);
if (confirm('¿Estás seguro de eliminar esta meta?')) {
await deleteGoal(goalId);
}
};
if (loading) {
if (loading && portfolios.length === 0) {
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>
@ -165,12 +119,15 @@ export default function PortfolioDashboard() {
);
}
if (error) {
if (error && portfolios.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-96">
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={fetchData}
onClick={() => {
clearError();
fetchPortfolios();
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Reintentar
@ -187,16 +144,35 @@ export default function PortfolioDashboard() {
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Portfolio Manager
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Gestiona tus activos y alcanza tus metas financieras
</p>
<div className="flex items-center gap-3 mt-1">
<p className="text-gray-600 dark:text-gray-400">
Gestiona tus activos y alcanza tus metas financieras
</p>
{/* WebSocket status indicator */}
<div
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-full ${
wsConnected
? 'bg-green-100 dark:bg-green-900/30 text-green-600'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500'
}`}
title={wsConnected ? 'Conectado en tiempo real' : 'Sin conexión en tiempo real'}
>
{wsConnected ? (
<SignalIcon className="w-3 h-3" />
) : (
<SignalSlashIcon className="w-3 h-3" />
)}
{wsConnected ? 'Live' : 'Offline'}
</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={fetchData}
className="p-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
onClick={fetchPortfolios}
disabled={loading}
className="p-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50"
>
<ArrowPathIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
<ArrowPathIcon className={`w-5 h-5 text-gray-600 dark:text-gray-300 ${loading ? 'animate-spin' : ''}`} />
</button>
<Link
to="/portfolio/new"
@ -208,13 +184,20 @@ export default function PortfolioDashboard() {
</div>
</div>
{/* Last update indicator */}
{lastUpdate && (
<div className="text-xs text-gray-400 mb-4">
Última actualización: {new Date(lastUpdate).toLocaleTimeString()}
</div>
)}
{/* Portfolio Selector (if multiple) */}
{portfolios.length > 1 && (
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
{portfolios.map((p) => (
<button
key={p.id}
onClick={() => handlePortfolioSelect(p)}
onClick={() => selectPortfolio(p)}
className={`px-4 py-2 rounded-lg whitespace-nowrap transition-colors ${
selectedPortfolio?.id === p.id
? 'bg-blue-600 text-white'

View File

@ -144,6 +144,13 @@ export const mlSignalsWS = new WebSocketService({
maxReconnectAttempts: 10,
});
// Portfolio WebSocket - for real-time portfolio updates
export const portfolioWS = new WebSocketService({
url: `${WS_BASE_URL}/ws/portfolio`,
reconnectInterval: 5000,
maxReconnectAttempts: 15,
});
// ============================================================================
// Signal Types
// ============================================================================
@ -184,6 +191,21 @@ export interface TradeNotification {
timestamp: string;
}
export interface PortfolioUpdate {
portfolioId: string;
totalValue: number;
unrealizedPnl: number;
unrealizedPnlPercent: number;
allocations: {
asset: string;
value: number;
currentPercent: number;
pnl: number;
pnlPercent: number;
}[];
timestamp: string;
}
// ============================================================================
// React Hooks for WebSocket
// ============================================================================
@ -346,8 +368,56 @@ export function usePositionUpdates() {
return { positions };
}
/**
* Hook for real-time portfolio updates
*/
export function usePortfolioUpdates(portfolioId: string | null) {
const [portfolioData, setPortfolioData] = useState<PortfolioUpdate | null>(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!portfolioId) return;
portfolioWS.connect();
const unsubConnect = portfolioWS.onConnect(() => {
setConnected(true);
// Subscribe to portfolio updates
portfolioWS.send('portfolio:subscribe', { portfolioId });
});
const unsubDisconnect = portfolioWS.onDisconnect(() => {
setConnected(false);
});
const unsubUpdate = portfolioWS.subscribe('portfolio:update', (data) => {
const update = data as PortfolioUpdate;
if (update.portfolioId === portfolioId) {
setPortfolioData(update);
}
});
return () => {
// Unsubscribe when unmounting
portfolioWS.send('portfolio:unsubscribe', { portfolioId });
unsubConnect();
unsubDisconnect();
unsubUpdate();
};
}, [portfolioId]);
const refresh = useCallback(() => {
if (portfolioId) {
portfolioWS.send('portfolio:refresh', { portfolioId });
}
}, [portfolioId]);
return { portfolioData, connected, refresh };
}
export default {
tradingWS,
mlSignalsWS,
portfolioWS,
WebSocketService,
};

View File

@ -0,0 +1,396 @@
/**
* Portfolio Store
* Zustand store for portfolio management with real-time updates
*/
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import {
getUserPortfolios,
getPortfolio,
getPortfolioStats,
getRebalanceRecommendations,
executeRebalance,
updateAllocations,
getUserGoals,
createGoal,
deleteGoal,
type Portfolio,
type PortfolioStats,
type RebalanceRecommendation,
type PortfolioGoal,
type CreateGoalInput,
} from '../services/portfolio.service';
import { portfolioWS, type PortfolioUpdate } from '../services/websocket.service';
// ============================================================================
// State Interface
// ============================================================================
interface PortfolioState {
// Portfolio data
portfolios: Portfolio[];
selectedPortfolio: Portfolio | null;
stats: PortfolioStats | null;
recommendations: RebalanceRecommendation[];
goals: PortfolioGoal[];
// Loading states
loading: boolean;
loadingStats: boolean;
loadingRecommendations: boolean;
loadingGoals: boolean;
isRebalancing: boolean;
// Error state
error: string | null;
// WebSocket state
wsConnected: boolean;
lastUpdate: string | null;
// Actions
fetchPortfolios: () => Promise<void>;
selectPortfolio: (portfolio: Portfolio) => Promise<void>;
selectPortfolioById: (portfolioId: string) => Promise<void>;
refreshStats: () => Promise<void>;
refreshRecommendations: () => Promise<void>;
executeRebalance: () => Promise<void>;
updateAllocations: (allocations: { asset: string; targetPercent: number }[]) => Promise<void>;
// Goal actions
fetchGoals: () => Promise<void>;
createGoal: (input: CreateGoalInput) => Promise<void>;
deleteGoal: (goalId: string) => Promise<void>;
// WebSocket actions
connectWebSocket: () => void;
disconnectWebSocket: () => void;
handlePortfolioUpdate: (update: PortfolioUpdate) => void;
// Utility actions
clearError: () => void;
reset: () => void;
}
// ============================================================================
// Initial State
// ============================================================================
const initialState = {
portfolios: [],
selectedPortfolio: null,
stats: null,
recommendations: [],
goals: [],
loading: false,
loadingStats: false,
loadingRecommendations: false,
loadingGoals: false,
isRebalancing: false,
error: null,
wsConnected: false,
lastUpdate: null,
};
// ============================================================================
// Store
// ============================================================================
export const usePortfolioStore = create<PortfolioState>()(
devtools(
(set, get) => ({
...initialState,
// ========================================================================
// Portfolio Actions
// ========================================================================
fetchPortfolios: async () => {
set({ loading: true, error: null });
try {
const [portfolios, goals] = await Promise.all([
getUserPortfolios(),
getUserGoals(),
]);
set({ portfolios, goals, loading: false });
// Auto-select first portfolio if none selected
if (portfolios.length > 0 && !get().selectedPortfolio) {
await get().selectPortfolio(portfolios[0]);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Error loading portfolios';
set({ error: message, loading: false });
}
},
selectPortfolio: async (portfolio: Portfolio) => {
set({ selectedPortfolio: portfolio, loadingStats: true, loadingRecommendations: true });
// Disconnect from previous portfolio WebSocket
const prevPortfolio = get().selectedPortfolio;
if (prevPortfolio && prevPortfolio.id !== portfolio.id) {
portfolioWS.send('portfolio:unsubscribe', { portfolioId: prevPortfolio.id });
}
// Subscribe to new portfolio WebSocket
if (get().wsConnected) {
portfolioWS.send('portfolio:subscribe', { portfolioId: portfolio.id });
}
try {
const [stats, recommendations] = await Promise.all([
getPortfolioStats(portfolio.id),
getRebalanceRecommendations(portfolio.id),
]);
set({
stats,
recommendations,
loadingStats: false,
loadingRecommendations: false,
});
} catch (error) {
console.error('Error loading portfolio data:', error);
set({ loadingStats: false, loadingRecommendations: false });
}
},
selectPortfolioById: async (portfolioId: string) => {
set({ loading: true, error: null });
try {
const portfolio = await getPortfolio(portfolioId);
await get().selectPortfolio(portfolio);
set({ loading: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Portfolio not found';
set({ error: message, loading: false });
}
},
refreshStats: async () => {
const { selectedPortfolio } = get();
if (!selectedPortfolio) return;
set({ loadingStats: true });
try {
const stats = await getPortfolioStats(selectedPortfolio.id);
set({ stats, loadingStats: false });
} catch (error) {
console.error('Error refreshing stats:', error);
set({ loadingStats: false });
}
},
refreshRecommendations: async () => {
const { selectedPortfolio } = get();
if (!selectedPortfolio) return;
set({ loadingRecommendations: true });
try {
const recommendations = await getRebalanceRecommendations(selectedPortfolio.id);
set({ recommendations, loadingRecommendations: false });
} catch (error) {
console.error('Error refreshing recommendations:', error);
set({ loadingRecommendations: false });
}
},
executeRebalance: async () => {
const { selectedPortfolio } = get();
if (!selectedPortfolio) return;
set({ isRebalancing: true, error: null });
try {
const updated = await executeRebalance(selectedPortfolio.id);
set({
selectedPortfolio: updated,
isRebalancing: false,
});
// Refresh recommendations after rebalance
await get().refreshRecommendations();
} catch (error) {
const message = error instanceof Error ? error.message : 'Error executing rebalance';
set({ error: message, isRebalancing: false });
}
},
updateAllocations: async (allocations) => {
const { selectedPortfolio } = get();
if (!selectedPortfolio) return;
set({ loading: true, error: null });
try {
const updated = await updateAllocations(selectedPortfolio.id, allocations);
set({
selectedPortfolio: updated,
loading: false,
});
// Refresh recommendations after update
await get().refreshRecommendations();
} catch (error) {
const message = error instanceof Error ? error.message : 'Error updating allocations';
set({ error: message, loading: false });
throw error;
}
},
// ========================================================================
// Goal Actions
// ========================================================================
fetchGoals: async () => {
set({ loadingGoals: true, error: null });
try {
const goals = await getUserGoals();
set({ goals, loadingGoals: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Error loading goals';
set({ error: message, loadingGoals: false });
}
},
createGoal: async (input: CreateGoalInput) => {
set({ loadingGoals: true, error: null });
try {
const newGoal = await createGoal(input);
set((state) => ({
goals: [...state.goals, newGoal],
loadingGoals: false,
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Error creating goal';
set({ error: message, loadingGoals: false });
throw error;
}
},
deleteGoal: async (goalId: string) => {
set({ error: null });
try {
await deleteGoal(goalId);
set((state) => ({
goals: state.goals.filter((g) => g.id !== goalId),
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Error deleting goal';
set({ error: message });
throw error;
}
},
// ========================================================================
// WebSocket Actions
// ========================================================================
connectWebSocket: () => {
portfolioWS.connect();
portfolioWS.onConnect(() => {
set({ wsConnected: true });
// Subscribe to selected portfolio if any
const { selectedPortfolio } = get();
if (selectedPortfolio) {
portfolioWS.send('portfolio:subscribe', { portfolioId: selectedPortfolio.id });
}
});
portfolioWS.onDisconnect(() => {
set({ wsConnected: false });
});
// Handle portfolio updates
portfolioWS.subscribe('portfolio:update', (data) => {
get().handlePortfolioUpdate(data as PortfolioUpdate);
});
},
disconnectWebSocket: () => {
const { selectedPortfolio } = get();
if (selectedPortfolio) {
portfolioWS.send('portfolio:unsubscribe', { portfolioId: selectedPortfolio.id });
}
portfolioWS.disconnect();
set({ wsConnected: false });
},
handlePortfolioUpdate: (update: PortfolioUpdate) => {
const { selectedPortfolio } = get();
if (selectedPortfolio && update.portfolioId === selectedPortfolio.id) {
// Update portfolio with real-time data
set((state) => ({
selectedPortfolio: state.selectedPortfolio
? {
...state.selectedPortfolio,
totalValue: update.totalValue,
unrealizedPnl: update.unrealizedPnl,
unrealizedPnlPercent: update.unrealizedPnlPercent,
allocations: state.selectedPortfolio.allocations.map((alloc) => {
const updated = update.allocations.find((a) => a.asset === alloc.asset);
if (updated) {
return {
...alloc,
value: updated.value,
currentPercent: updated.currentPercent,
pnl: updated.pnl,
pnlPercent: updated.pnlPercent,
};
}
return alloc;
}),
}
: null,
lastUpdate: update.timestamp,
}));
}
},
// ========================================================================
// Utility Actions
// ========================================================================
clearError: () => {
set({ error: null });
},
reset: () => {
get().disconnectWebSocket();
set(initialState);
},
}),
{
name: 'portfolio-store',
}
)
);
// ============================================================================
// Selectors
// ============================================================================
export const usePortfolios = () => usePortfolioStore((state) => state.portfolios);
export const useSelectedPortfolio = () => usePortfolioStore((state) => state.selectedPortfolio);
export const usePortfolioStats = () => usePortfolioStore((state) => state.stats);
export const useRecommendations = () => usePortfolioStore((state) => state.recommendations);
export const useGoals = () => usePortfolioStore((state) => state.goals);
export const usePortfolioLoading = () => usePortfolioStore((state) => state.loading);
export const usePortfolioError = () => usePortfolioStore((state) => state.error);
export const useWsConnected = () => usePortfolioStore((state) => state.wsConnected);
export const useIsRebalancing = () => usePortfolioStore((state) => state.isRebalancing);
export default usePortfolioStore;