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