[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:
parent
c02625f37b
commit
fd54724ede
@ -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'
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
396
src/stores/portfolioStore.ts
Normal file
396
src/stores/portfolioStore.ts
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user