import { useEffect, useState } from 'react'; import { io, Socket } from 'socket.io-client'; import { buildTradingSocketOptions } from '../../../shared/realtime.js'; import { getPlatformAccessToken } from '../lib/authSession'; export interface TradingControlSnapshot { mode: 'RUNNING' | 'PAUSED'; lastChangedBy: string; lastChangedAt: number; reason?: string; } export interface HealthSnapshot { tradingLoopHealthy: boolean; tradingLoopLastRun: number | null; monitorLoopHealthy: boolean; monitorLoopLastRun: number | null; orderSyncHealthy: boolean; orderSyncLastRun: number | null; lockContentionCount: number; reconciliationLoopHealthy: boolean; reconciliationLoopLastRun: number | null; reconciliationMismatchCount: number; reconciliationMissingFromExchange: number; reconciliationMissingInDb: number; reconciliationNoGoTrades: number; reconciliationParityMismatchTrades?: number; reconciliationParityQuarantinedTrades?: number; reconciliationParityAutoClosedTrades?: number; reconciliationParityMaxMismatchNotionalUsd?: number; reconciliationParityTotalMismatchNotionalUsd?: number; reconciliationIntegrityWatchdogTriggered: boolean; reconciliationLockContentionCount: number; tradingControl: TradingControlSnapshot; } export interface BotState { symbols: { [symbol: string]: { price: number; change24h: number; changeToday: number; session: string; volatility: string; signal: string; signalTime?: number; tradingMode?: 'Paper' | 'Live' | 'Alerts'; activePosition?: { side: 'BUY' | 'SELL'; entryPrice: number; size: number; stopLoss: number; takeProfit: number; unrealizedPnl?: number; unrealizedPnlPercent?: number; marketValue?: number; profileId?: string; } | null; priceHistory: Array<{ timestamp: number; price: number }>; rules: { [ruleName: string]: { passed: boolean; reason: string; metadata?: any; }; }; profileSignals?: { [profileId: string]: { profileName?: string; signal: string; passed: boolean; reason?: string; execution?: { status: 'EXECUTED' | 'BLOCKED' | 'SKIPPED'; code: string; reason: string; orderId?: string; }; rules?: { [ruleName: string]: { passed: boolean; reason: string; metadata?: any; }; }; }; }; indicators: { ema20_1h?: number; ema20_15m?: number; ema50_4h?: number; ema200_4h?: number; rsi_1h?: number; rsi_15m?: number; }; }; }; alerts: Array<{ timestamp: number; type: 'signal' | 'pulse' | 'error' | 'info'; symbol: string; message: 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; trade_id?: string; subTag?: string; action?: string; 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; trade_id?: string; source?: 'BOT' | 'MANUAL'; }>; settings: { executionMode: string; riskPerTrade: number; totalCapital: number; maxOpenTrades: number; isAlgoEnabled: boolean; enabledRules: string[]; }; health?: HealthSnapshot; uptime: number; accountSnapshot?: { buying_power: number; cash: number; currency: string; timestamp: number; } | null; orderFailures?: Array<{ profileId?: string; userId?: string; symbol: string; side: 'BUY' | 'SELL'; qty: number; reason: string; tradeId?: string; subTag?: string; timestamp: number; }>; operationalEvents?: Array<{ id: string; type: string; severity: string; message: string; profileId?: string; userId?: string; symbol?: string; setupId?: string; tradeId?: string; orderId?: string; timestamp: number; }>; } function isOperationalEventRecord(value: unknown): value is NonNullable[number] { if (!value || typeof value !== 'object') return false; const event = value as Record; return typeof event.id === 'string' && typeof event.type === 'string' && typeof event.severity === 'string' && typeof event.message === 'string' && typeof event.timestamp === 'number'; } export const DEFAULT_BOT_STATE: BotState = { symbols: {}, positions: [], alerts: [], orders: [], history: [], settings: { executionMode: 'Alerts', riskPerTrade: 0.01, totalCapital: 1000, maxOpenTrades: 3, isAlgoEnabled: false, enabledRules: [] }, health: { tradingLoopHealthy: true, tradingLoopLastRun: null, monitorLoopHealthy: true, monitorLoopLastRun: null, orderSyncHealthy: true, orderSyncLastRun: null, lockContentionCount: 0, reconciliationLoopHealthy: true, reconciliationLoopLastRun: null, reconciliationMismatchCount: 0, reconciliationMissingFromExchange: 0, reconciliationMissingInDb: 0, reconciliationNoGoTrades: 0, reconciliationIntegrityWatchdogTriggered: false, reconciliationLockContentionCount: 0, tradingControl: { mode: 'RUNNING', lastChangedBy: 'system', lastChangedAt: Date.now() } }, uptime: 0, accountSnapshot: null, orderFailures: [], operationalEvents: [] }; export const applySymbolUpdate = ( prev: BotState, symbol: string, data: BotState['symbols'][string] ): BotState => ({ ...prev, symbols: { ...prev.symbols, [symbol]: data } }); export const appendAlertUpdate = (prev: BotState, alert: BotState['alerts'][0]): BotState => ({ ...prev, alerts: [...prev.alerts, alert] }); export const replacePositionsUpdate = (prev: BotState, positions: BotState['positions']): BotState => ({ ...prev, positions }); export const replaceOrdersUpdate = (prev: BotState, orders: BotState['orders']): BotState => ({ ...prev, orders }); export const appendHistoryUpdate = (prev: BotState, trade: BotState['history'][0]): BotState => ({ ...prev, history: [...prev.history, trade] }); export const replaceSettingsUpdate = (prev: BotState, settings: BotState['settings']): BotState => ({ ...prev, settings }); /** * Derive the Socket.IO server origin and path from a trading API URL. * - Production: https://api.bytelyst.com/invttrdg/api * → origin: https://api.bytelyst.com, path: /invttrdg/socket.io * - Local dev: http://localhost:4018/api * → origin: http://localhost:4018, path: /socket.io (default) * VITE_SOCKET_PATH overrides the derived path when explicitly set. */ function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } { const envPath = (import.meta.env.VITE_SOCKET_PATH as string | undefined)?.trim(); try { const parsed = new URL(tradingApiUrl); const prefix = parsed.pathname.replace(/\/api\/?$/, ''); // e.g. '/invttrdg' 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' }; } } export const useWebSocket = (url: string) => { const [socket, setSocket] = useState(null); const [botState, setBotState] = useState(DEFAULT_BOT_STATE); const [connected, setConnected] = useState(false); const EVENT_BUFFER_LIMIT = 2000; useEffect(() => { let isCancelled = false; let newSocket: Socket | null = null; const connectSocket = async () => { const { socketOrigin, socketPath } = deriveSocketParams(url); console.log('🔌 Attempting to connect to:', socketOrigin, 'path:', socketPath); const token = await getPlatformAccessToken().catch(() => null); if (!token) { console.warn('Socket connection skipped: missing authenticated session token'); setConnected(false); return; } const socketOptions = buildTradingSocketOptions(token, socketPath); newSocket = io(socketOrigin, socketOptions); newSocket.on('connect', () => { console.log('✅ Connected to bot'); setConnected(true); }); newSocket.on('disconnect', () => { console.log('❌ Disconnected from bot'); setConnected(false); }); newSocket.on('connect_error', (error) => { console.error('🚨 Connection error:', error.message); }); newSocket.on('state', (state: BotState) => { setBotState(prev => ({ ...prev, ...state, health: state.health || prev.health })); }); newSocket.on('health_update', (health: HealthSnapshot) => { setBotState(prev => ({ ...prev, health })); }); newSocket.on('symbol_update', ({ symbol, data }: any) => { setBotState(prev => applySymbolUpdate(prev, symbol, data)); }); newSocket.on('new_alert', (alert: BotState['alerts'][0]) => { setBotState(prev => appendAlertUpdate(prev, alert)); }); newSocket.on('positions_update', (positions: BotState['positions']) => { setBotState(prev => replacePositionsUpdate(prev, positions)); }); newSocket.on('orders_update', (orders: BotState['orders']) => { setBotState(prev => replaceOrdersUpdate(prev, orders)); }); newSocket.on('history_update', (trade: BotState['history'][0]) => { setBotState(prev => appendHistoryUpdate(prev, trade)); }); newSocket.on('settings_update', (settings: BotState['settings']) => { setBotState(prev => replaceSettingsUpdate(prev, settings)); }); newSocket.on('account_snapshot', (snapshot: any) => { setBotState(prev => ({ ...prev, accountSnapshot: snapshot })); }); newSocket.on('order_failure', (failure: any) => { const enrichedFailure = { ...failure, timestamp: failure.timestamp || Date.now() }; setBotState(prev => ({ ...prev, orderFailures: [enrichedFailure, ...(prev.orderFailures || [])].slice(0, EVENT_BUFFER_LIMIT) })); }); newSocket.on('operational_event', (event: any) => { if (!isOperationalEventRecord(event)) { console.warn('Ignoring malformed operational_event payload', event); return; } setBotState(prev => ({ ...prev, operationalEvents: [event, ...(prev.operationalEvents || [])].slice(0, EVENT_BUFFER_LIMIT) })); }); newSocket.on('operational_event_cleared', () => { setBotState(prev => ({ ...prev, operationalEvents: [] })); }); if (!isCancelled) { setSocket(newSocket); } else { newSocket.close(); } }; connectSocket().catch((error) => { console.error('🚨 Failed to initialize socket:', error); setConnected(false); }); return () => { isCancelled = true; if (newSocket) { newSocket.close(); } }; }, [url]); return { socket, botState, connected }; };