import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { io, type Socket } from 'socket.io-client'; import { mobileRuntime } from '@/lib/runtime'; import { mobileTelemetry, trackMobileError } from '@/lib/telemetry'; import { useMobileAuth } from '@/providers/MobileAuthProvider'; import { buildTradingSocketOptions, isUnauthorizedSocketError, SOCKET_NAMESPACES } from '../../shared/realtime.js'; import { createRequestId } from '../../shared/request-id.js'; type HealthSnapshot = { tradingControl?: { mode: 'RUNNING' | 'PAUSED'; lastChangedBy: string; lastChangedAt: number; reason?: string; }; }; type BotState = { symbols: Record; profileSignals?: Record; }>; alerts: Array<{ timestamp: number; type: 'signal' | 'pulse' | 'error' | 'info'; symbol: string; message: string; profileId?: string; }>; positions: Array<{ id: string; symbol: string; side: 'BUY' | 'SELL'; size: number; entryPrice: number; currentPrice: number; stopLoss: number; takeProfit: number; unrealizedPnl: number; unrealizedPnlPercent: number; marketValue: number; profileId?: string; profileName?: string; tradeId?: string; }>; orders: Array<{ id: string; symbol: string; type: string; side: string; qty: number; price: number; status: string; timestamp: number; profileId?: string; action?: 'ENTRY' | 'EXIT'; source?: 'BOT' | 'MANUAL'; }>; history: Array<{ symbol: string; side: string; entryPrice: number; exitPrice: number; size: number; pnl: number; pnlPercent: number; reason: string; timestamp: number; profileId?: string; profileName?: string; source?: 'BOT' | 'MANUAL'; }>; settings: { executionMode: string; riskPerTrade: number; totalCapital: number; maxOpenTrades: number; isAlgoEnabled: boolean; }; uptime: number; health?: HealthSnapshot; }; export interface TradingPortfolioSummary { netPnl: number; netPnlPercent: number; totalCapital: number; deployed: number; available: number; utilization: number; realizedPnl: number; unrealizedPnl: number; } interface TradingDataContextValue { botState: BotState | null; loading: boolean; error: string | null; connected: boolean; connectionState: 'live' | 'degraded' | 'offline'; lastUpdatedAt: number | null; refresh: () => Promise; pauseTrading: (reason?: string) => Promise<{ error?: string }>; resumeTrading: (reason?: string) => Promise<{ error?: string }>; portfolio: TradingPortfolioSummary; marketTicker: Array<{ symbol: string; price: number; change: number }>; } const TradingDataContext = createContext(null); function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } { const envPath = process.env.EXPO_PUBLIC_SOCKET_PATH?.trim(); try { const parsed = new URL(tradingApiUrl); const prefix = parsed.pathname.replace(/\/api\/?$/, ''); const socketPath = envPath || (prefix && prefix !== '/' ? `${prefix}/socket.io` : '/socket.io'); return { socketOrigin: parsed.origin, socketPath }; } catch { return { socketOrigin: tradingApiUrl.replace(/\/api$/, ''), socketPath: envPath || '/socket.io' }; } } const { socketOrigin: tradingSocketOrigin, socketPath: tradingSocketPath } = deriveSocketParams(mobileRuntime.tradingApiUrl); const EMPTY_STATE: TradingPortfolioSummary = { netPnl: 0, netPnlPercent: 0, totalCapital: 0, deployed: 0, available: 0, utilization: 0, realizedPnl: 0, unrealizedPnl: 0, }; export function TradingDataProvider({ children }: { children: ReactNode }) { const { accessToken, user, invalidateSession } = useMobileAuth(); const [botState, setBotState] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [connected, setConnected] = useState(false); const [lastUpdatedAt, setLastUpdatedAt] = useState(null); const [statusClock, setStatusClock] = useState(() => Date.now()); const markStateSynced = useCallback(() => { setLastUpdatedAt(Date.now()); }, []); const fetchState = useCallback(async () => { if (!accessToken || !user) { setBotState(null); setConnected(false); setLoading(false); return; } setLoading(true); try { const response = await fetch(`${mobileRuntime.tradingApiUrl}/state`, { headers: { Authorization: `Bearer ${accessToken}`, 'x-request-id': createRequestId('mobile-state'), }, }); if (!response.ok) { if (response.status === 401 || response.status === 403) { await invalidateSession(`Trading session expired (${response.status})`); return; } throw new Error(`Trading state request failed (${response.status})`); } const data = (await response.json()) as BotState; setBotState(data); setConnected(true); setError(null); markStateSynced(); } catch (fetchError) { setConnected(false); setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state'); trackMobileError('trading_data', 'state_fetch_failed', fetchError); } finally { setLoading(false); } }, [accessToken, user, invalidateSession, markStateSynced]); useEffect(() => { void fetchState(); if (!accessToken || !user) { return; } const interval = setInterval(() => { void fetchState(); }, 60000); return () => clearInterval(interval); }, [accessToken, user, fetchState]); useEffect(() => { const interval = setInterval(() => { setStatusClock(Date.now()); }, 30000); return () => clearInterval(interval); }, []); useEffect(() => { if (!accessToken || !user) { return; } let socket: Socket | null = null; const mergePartialState = (nextState: BotState) => { setBotState((prev) => ({ ...(prev || nextState), ...nextState, health: nextState.health || prev?.health, })); }; socket = io(tradingSocketOrigin, buildTradingSocketOptions(accessToken, tradingSocketPath)); socket.on('connect', () => { setConnected(true); setError(null); mobileTelemetry.trackEvent('info', 'realtime', 'socket_connected'); }); socket.on('disconnect', () => { setConnected(false); mobileTelemetry.trackEvent('warn', 'realtime', 'socket_disconnected'); }); socket.on('connect_error', (socketError) => { setError(socketError.message); trackMobileError('realtime', 'socket_connect_failed', socketError); if (isUnauthorizedSocketError(socketError.message)) { void invalidateSession(socketError.message); } }); socket.on('state', (nextState: BotState) => { mergePartialState(nextState); markStateSynced(); }); socket.on('health_update', (health: HealthSnapshot) => { markStateSynced(); setBotState((prev) => (prev ? { ...prev, health } : prev)); }); socket.on('symbol_update', ({ symbol, data }: { symbol: string; data: BotState['symbols'][string] }) => { markStateSynced(); setBotState((prev) => { if (!prev) return prev; return { ...prev, symbols: { ...prev.symbols, [symbol]: data, }, }; }); }); socket.on('new_alert', (alert: BotState['alerts'][number]) => { markStateSynced(); setBotState((prev) => { if (!prev) return prev; return { ...prev, alerts: [alert, ...prev.alerts].slice(0, 25), }; }); }); socket.on('positions_update', (positions: BotState['positions']) => { markStateSynced(); setBotState((prev) => (prev ? { ...prev, positions } : prev)); }); socket.on('orders_update', (orders: BotState['orders']) => { markStateSynced(); setBotState((prev) => (prev ? { ...prev, orders } : prev)); }); socket.on('history_update', (trade: BotState['history'][number]) => { markStateSynced(); setBotState((prev) => { if (!prev) return prev; return { ...prev, history: [trade, ...prev.history].slice(0, 100), }; }); }); socket.on('settings_update', (settings: BotState['settings']) => { markStateSynced(); setBotState((prev) => (prev ? { ...prev, settings } : prev)); }); socket.on('account_snapshot', () => { void fetchState(); }); socket.on('order_failure', () => { void fetchState(); }); socket.on('operational_event', () => { void fetchState(); }); return () => { socket?.close(); }; }, [accessToken, user, fetchState, invalidateSession, markStateSynced]); const postTradingAction = useCallback( async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => { if (!accessToken) { return { error: 'Not authenticated' }; } try { const response = await fetch(`${mobileRuntime.tradingApiUrl.replace(/\/api$/, '')}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, 'x-request-id': createRequestId('mobile-control'), }, body: JSON.stringify({ reason: reason || 'Requested from mobile trading surface', }), }); const body = await response.json().catch(() => ({} as { error?: string })); if (!response.ok) { if (response.status === 401 || response.status === 403) { await invalidateSession(body.error || `Trading control unauthorized (${response.status})`); return { error: body.error || `Request failed (${response.status})` }; } mobileTelemetry.trackEvent('warn', 'controls', 'trading_action_failed', { message: body.error || `Request failed (${response.status})`, tags: { path }, }); return { error: body.error || `Request failed (${response.status})` }; } await fetchState(); mobileTelemetry.trackEvent('info', 'controls', 'trading_action_succeeded', { tags: { path }, }); return {}; } catch (actionError) { trackMobileError('controls', 'trading_action_failed', actionError, { path }); return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' }; } }, [accessToken, fetchState, invalidateSession] ); const portfolio = useMemo(() => { if (!botState) { return EMPTY_STATE; } const unrealizedPnl = botState.positions.reduce((sum, position) => sum + Number(position.unrealizedPnl || 0), 0); const deployed = botState.positions.reduce((sum, position) => sum + Number(position.marketValue || 0), 0); const realizedPnl = botState.history.reduce((sum, trade) => sum + Number(trade.pnl || 0), 0); const totalCapital = Number(botState.settings.totalCapital || 0); const available = Math.max(totalCapital - deployed, 0); const netPnl = realizedPnl + unrealizedPnl; const netPnlPercent = totalCapital > 0 ? (netPnl / totalCapital) * 100 : 0; const utilization = totalCapital > 0 ? Math.min((deployed / totalCapital) * 100, 100) : 0; return { netPnl, netPnlPercent, totalCapital, deployed, available, utilization, realizedPnl, unrealizedPnl, }; }, [botState]); const marketTicker = useMemo( () => Object.entries(botState?.symbols || {}).map(([symbol, data]) => ({ symbol, price: Number(data.price || 0), change: Number(data.change24h || 0), })), [botState] ); const connectionState = useMemo<'live' | 'degraded' | 'offline'>(() => { if (!connected && !botState) { return 'offline'; } const staleMs = lastUpdatedAt ? statusClock - lastUpdatedAt : Number.POSITIVE_INFINITY; if (connected && !error && staleMs < 90_000) { return 'live'; } if (botState || connected) { return 'degraded'; } return 'offline'; }, [connected, botState, error, lastUpdatedAt, statusClock]); const value = useMemo( () => ({ botState, loading, error, connected, connectionState, lastUpdatedAt, refresh: fetchState, pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason), resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason), portfolio, marketTicker, }), [botState, loading, error, connected, connectionState, lastUpdatedAt, fetchState, postTradingAction, portfolio, marketTicker] ); return {children}; } export function useTradingData() { const context = useContext(TradingDataContext); if (!context) { throw new Error('useTradingData must be used within a TradingDataProvider'); } return context; }